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/release_docker_k8_operator.yaml b/.github/workflows/release_docker_k8_operator.yaml index 1f894df47..81472bf88 100644 --- a/.github/workflows/release_docker_k8_operator.yaml +++ b/.github/workflows/release_docker_k8_operator.yaml @@ -44,10 +44,7 @@ jobs: - name: Generate Helm Chart working-directory: k8-operator - run: make helm - - - name: Update Helm Chart Version - run: ./k8-operator/scripts/update-version.sh ${{ steps.extract_version.outputs.version }} + run: make helm VERSION=${{ steps.extract_version.outputs.version }} - name: Debug - Check file changes run: | 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/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index e07c8a9da..a4e5c150a 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -55,6 +55,8 @@ USER non-root-user ## FROM base AS backend-build +ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ + WORKDIR /app # Install all required dependencies for build @@ -84,6 +86,8 @@ RUN npm run build # Production stage FROM base AS backend-runner +ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ + WORKDIR /app # Install all required dependencies for runtime @@ -112,6 +116,11 @@ RUN mkdir frontend-build FROM base AS production RUN apt-get update && apt-get install -y \ + build-essential \ + autoconf \ + automake \ + libtool \ + libssl-dev \ ca-certificates \ bash \ curl \ @@ -171,6 +180,7 @@ ENV NODE_ENV production ENV STANDALONE_BUILD true ENV STANDALONE_MODE true ENV NODE_OPTIONS="--max-old-space-size=1024" +ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ WORKDIR /backend 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-reference.spec.ts b/backend/e2e-test/routes/v3/secret-reference.spec.ts index 560565342..a5d8ba61d 100644 --- a/backend/e2e-test/routes/v3/secret-reference.spec.ts +++ b/backend/e2e-test/routes/v3/secret-reference.spec.ts @@ -314,8 +314,8 @@ describe("Secret expansion", () => { expect(listSecrets.imports).toEqual( expect.arrayContaining([ expect.objectContaining({ - secretPath: `/__reserve_replication_${secretImportFromProdToDev.id}`, - environment: seedData1.environment.slug, + secretPath: "/deep/nested", + environment: "prod", secrets: expect.arrayContaining([ expect.objectContaining({ secretKey: "NESTED_KEY_1", 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/package-lock.json b/backend/package-lock.json index cb9efa148..c6ac0b147 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "backend", "version": "1.0.0", - "hasInstallScript": true, "license": "ISC", "dependencies": { "@aws-sdk/client-elasticache": "^3.637.0", @@ -26,6 +25,7 @@ "@fastify/multipart": "8.3.1", "@fastify/passport": "^2.4.0", "@fastify/rate-limit": "^9.0.0", + "@fastify/reply-from": "^9.8.0", "@fastify/request-context": "^5.1.0", "@fastify/session": "^10.7.0", "@fastify/static": "^7.0.4", @@ -34,11 +34,12 @@ "@gitbeaker/rest": "^42.5.0", "@google-cloud/kms": "^4.5.0", "@infisical/quic": "^1.0.8", - "@node-saml/passport-saml": "^5.0.1", + "@node-saml/passport-saml": "^5.1.0", "@octokit/auth-app": "^7.1.1", "@octokit/core": "^5.2.1", "@octokit/plugin-paginate-graphql": "^4.0.1", "@octokit/plugin-retry": "^5.0.5", + "@octokit/request": "8.4.1", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", "@octopusdeploy/api-client": "^3.4.1", @@ -63,6 +64,7 @@ "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", "axios": "^1.11.0", + "axios-ntlm": "^1.4.4", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "botbuilder": "^4.23.2", @@ -8043,6 +8045,42 @@ "toad-cache": "^3.3.0" } }, + "node_modules/@fastify/reply-from": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz", + "integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^3.0.0", + "end-of-stream": "^1.4.4", + "fast-content-type-parse": "^1.1.0", + "fast-querystring": "^1.0.0", + "fastify-plugin": "^4.0.0", + "toad-cache": "^3.7.0", + "undici": "^5.19.1" + } + }, + "node_modules/@fastify/reply-from/node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@fastify/reply-from/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@fastify/request-context": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fastify/request-context/-/request-context-5.1.0.tgz", @@ -9574,20 +9612,20 @@ } }, "node_modules/@node-saml/node-saml": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.0.1.tgz", - "integrity": "sha512-YQzFPEC+CnsfO9AFYnwfYZKIzOLx3kITaC1HrjHVLTo6hxcQhc+LgHODOMvW4VCV95Gwrz1MshRUWCPzkDqmnA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.1.0.tgz", + "integrity": "sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==", "license": "MIT", "dependencies": { "@types/debug": "^4.1.12", - "@types/qs": "^6.9.11", + "@types/qs": "^6.9.18", "@types/xml-encryption": "^1.2.4", "@types/xml2js": "^0.4.14", "@xmldom/is-dom-node": "^1.0.1", "@xmldom/xmldom": "^0.8.10", - "debug": "^4.3.4", - "xml-crypto": "^6.0.1", - "xml-encryption": "^3.0.2", + "debug": "^4.4.0", + "xml-crypto": "^6.1.2", + "xml-encryption": "^3.1.0", "xml2js": "^0.6.2", "xmlbuilder": "^15.1.1", "xpath": "^0.0.34" @@ -9597,9 +9635,9 @@ } }, "node_modules/@node-saml/node-saml/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -9636,14 +9674,14 @@ } }, "node_modules/@node-saml/passport-saml": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@node-saml/passport-saml/-/passport-saml-5.0.1.tgz", - "integrity": "sha512-fMztg3zfSnjLEgxvpl6HaDMNeh0xeQX4QHiF9e2Lsie2dc4qFE37XYbQZhVmn8XJ2awPpSWLQ736UskYgGU8lQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@node-saml/passport-saml/-/passport-saml-5.1.0.tgz", + "integrity": "sha512-pBm+iFjv9eihcgeJuSUs4c0AuX1QEFdHwP8w1iaWCfDzXdeWZxUBU5HT2bY2S4dvNutcy+A9hYsH7ZLBGtgwDg==", "license": "MIT", "dependencies": { - "@node-saml/node-saml": "^5.0.1", - "@types/express": "^4.17.21", - "@types/passport": "^1.0.16", + "@node-saml/node-saml": "^5.1.0", + "@types/express": "^4.17.23", + "@types/passport": "^1.0.17", "@types/passport-strategy": "^0.2.38", "passport": "^0.7.0", "passport-strategy": "^1.0.0" @@ -9778,18 +9816,6 @@ "node": ">= 18" } }, - "node_modules/@octokit/auth-app/node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dependencies": { - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@octokit/auth-app/node_modules/@octokit/openapi-types": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", @@ -9836,11 +9862,6 @@ "node": "14 || >=16.14" } }, - "node_modules/@octokit/auth-app/node_modules/universal-user-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", - "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==" - }, "node_modules/@octokit/auth-oauth-app": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-8.1.1.tgz", @@ -9856,18 +9877,6 @@ "node": ">= 18" } }, - "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dependencies": { - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/openapi-types": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", @@ -9906,11 +9915,6 @@ "@octokit/openapi-types": "^22.2.0" } }, - "node_modules/@octokit/auth-oauth-app/node_modules/universal-user-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", - "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==" - }, "node_modules/@octokit/auth-oauth-device": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-7.1.1.tgz", @@ -9925,18 +9929,6 @@ "node": ">= 18" } }, - "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dependencies": { - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/openapi-types": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", @@ -9975,11 +9967,6 @@ "@octokit/openapi-types": "^22.2.0" } }, - "node_modules/@octokit/auth-oauth-device/node_modules/universal-user-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", - "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==" - }, "node_modules/@octokit/auth-oauth-user": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-5.1.1.tgz", @@ -9995,18 +9982,6 @@ "node": ">= 18" } }, - "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dependencies": { - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/openapi-types": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", @@ -10045,11 +10020,6 @@ "@octokit/openapi-types": "^22.2.0" } }, - "node_modules/@octokit/auth-oauth-user/node_modules/universal-user-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", - "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==" - }, "node_modules/@octokit/auth-token": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", @@ -10103,32 +10073,38 @@ "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@octokit/core/node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "license": "ISC" + }, "node_modules/@octokit/endpoint": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", - "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", + "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", "license": "MIT", "dependencies": { - "@octokit/types": "^13.1.0", - "universal-user-agent": "^6.0.0" + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" }, "engines": { "node": ">= 18" } }, "node_modules/@octokit/endpoint/node_modules/@octokit/openapi-types": { - "version": "24.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", - "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", "license": "MIT" }, "node_modules/@octokit/endpoint/node_modules/@octokit/types": { - "version": "13.10.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", - "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^24.2.0" + "@octokit/openapi-types": "^25.1.0" } }, "node_modules/@octokit/graphql": { @@ -10160,6 +10136,12 @@ "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@octokit/graphql/node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "license": "ISC" + }, "node_modules/@octokit/oauth-authorization-url": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-7.1.1.tgz", @@ -10182,18 +10164,6 @@ "node": ">= 18" } }, - "node_modules/@octokit/oauth-methods/node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dependencies": { - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@octokit/oauth-methods/node_modules/@octokit/openapi-types": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", @@ -10232,11 +10202,6 @@ "@octokit/openapi-types": "^22.2.0" } }, - "node_modules/@octokit/oauth-methods/node_modules/universal-user-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", - "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==" - }, "node_modules/@octokit/openapi-types": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-19.1.0.tgz", @@ -10377,31 +10342,54 @@ } }, "node_modules/@octokit/request-error/node_modules/@octokit/openapi-types": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", - "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==" + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" }, "node_modules/@octokit/request-error/node_modules/@octokit/types": { - "version": "13.6.1", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.6.1.tgz", - "integrity": "sha512-PHZE9Z+kWXb23Ndik8MKPirBPziOc0D2/3KH1P+6jK5nGWe96kadZuE4jev2/Jq7FvIfTlT2Ltg8Fv2x1v0a5g==", + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^22.2.0" + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/request/node_modules/@octokit/endpoint": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", + "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" } }, "node_modules/@octokit/request/node_modules/@octokit/openapi-types": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", - "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==" + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" }, "node_modules/@octokit/request/node_modules/@octokit/types": { - "version": "13.6.1", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.6.1.tgz", - "integrity": "sha512-PHZE9Z+kWXb23Ndik8MKPirBPziOc0D2/3KH1P+6jK5nGWe96kadZuE4jev2/Jq7FvIfTlT2Ltg8Fv2x1v0a5g==", + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^22.2.0" + "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@octokit/request/node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "license": "ISC" + }, "node_modules/@octokit/rest": { "version": "20.0.2", "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.0.2.tgz", @@ -13006,216 +12994,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@swc/core": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.107.tgz", - "integrity": "sha512-zKhqDyFcTsyLIYK1iEmavljZnf4CCor5pF52UzLAz4B6Nu/4GLU+2LQVAf+oRHjusG39PTPjd2AlRT3f3QWfsQ==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.3.107", - "@swc/core-darwin-x64": "1.3.107", - "@swc/core-linux-arm-gnueabihf": "1.3.107", - "@swc/core-linux-arm64-gnu": "1.3.107", - "@swc/core-linux-arm64-musl": "1.3.107", - "@swc/core-linux-x64-gnu": "1.3.107", - "@swc/core-linux-x64-musl": "1.3.107", - "@swc/core-win32-arm64-msvc": "1.3.107", - "@swc/core-win32-ia32-msvc": "1.3.107", - "@swc/core-win32-x64-msvc": "1.3.107" - }, - "peerDependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.107.tgz", - "integrity": "sha512-47tD/5vSXWxPd0j/ZllyQUg4bqalbQTsmqSw0J4dDdS82MWqCAwUErUrAZPRjBkjNQ6Kmrf5rpCWaGTtPw+ngw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.107.tgz", - "integrity": "sha512-hwiLJ2ulNkBGAh1m1eTfeY1417OAYbRGcb/iGsJ+LuVLvKAhU/itzsl535CvcwAlt2LayeCFfcI8gdeOLeZa9A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.107.tgz", - "integrity": "sha512-I2wzcC0KXqh0OwymCmYwNRgZ9nxX7DWnOOStJXV3pS0uB83TXAkmqd7wvMBuIl9qu4Hfomi9aDM7IlEEn9tumQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.107.tgz", - "integrity": "sha512-HWgnn7JORYlOYnGsdunpSF8A+BCZKPLzLtEUA27/M/ZuANcMZabKL9Zurt7XQXq888uJFAt98Gy+59PU90aHKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.107.tgz", - "integrity": "sha512-vfPF74cWfAm8hyhS8yvYI94ucMHIo8xIYU+oFOW9uvDlGQRgnUf/6DEVbLyt/3yfX5723Ln57U8uiMALbX5Pyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.107.tgz", - "integrity": "sha512-uBVNhIg0ip8rH9OnOsCARUFZ3Mq3tbPHxtmWk9uAa5u8jQwGWeBx5+nTHpDOVd3YxKb6+5xDEI/edeeLpha/9g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.107.tgz", - "integrity": "sha512-mvACkUvzSIB12q1H5JtabWATbk3AG+pQgXEN95AmEX2ZA5gbP9+B+mijsg7Sd/3tboHr7ZHLz/q3SHTvdFJrEw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.107.tgz", - "integrity": "sha512-J3P14Ngy/1qtapzbguEH41kY109t6DFxfbK4Ntz9dOWNuVY3o9/RTB841ctnJk0ZHEG+BjfCJjsD2n8H5HcaOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.107.tgz", - "integrity": "sha512-ZBUtgyjTHlz8TPJh7kfwwwFma+ktr6OccB1oXC8fMSopD0AxVnQasgun3l3099wIsAB9eEsJDQ/3lDkOLs1gBA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.107.tgz", - "integrity": "sha512-Eyzo2XRqWOxqhE1gk9h7LWmUf4Bp4Xn2Ttb0ayAXFp6YSTxQIThXcT9kipXZqcpxcmDwoq8iWbbf2P8XL743EA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -13233,14 +13011,6 @@ "tslib": "^2.8.0" } }, - "node_modules/@swc/types": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz", - "integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@techteamer/ocsp": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@techteamer/ocsp/-/ocsp-1.0.1.tgz", @@ -13351,9 +13121,10 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -13523,9 +13294,10 @@ } }, "node_modules/@types/passport": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", - "integrity": "sha512-FD0qD5hbPWQzaM0wHUnJ/T0BBCJBxCeemtnCwc/ThhTg3x9jfrAcRUmj5Dopza+MfFS9acTe3wk7rcVnRIp/0A==", + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "license": "MIT", "dependencies": { "@types/express": "*" } @@ -15243,6 +15015,18 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-ntlm": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/axios-ntlm/-/axios-ntlm-1.4.4.tgz", + "integrity": "sha512-kpCRdzMfL8gi0Z0o96P3QPAK4XuC8iciGgxGXe+PeQ4oyjI2LZN8WSOKbu0Y9Jo3T/A7pB81n6jYVPIpglEuRA==", + "license": "MIT", + "dependencies": { + "axios": "^1.8.4", + "des.js": "^1.1.0", + "dev-null": "^0.1.1", + "js-md4": "^0.3.2" + } + }, "node_modules/axios-retry": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.0.0.tgz", @@ -17002,6 +16786,16 @@ "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -17029,6 +16823,12 @@ "node": ">=8" } }, + "node_modules/dev-null": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/dev-null/-/dev-null-0.1.1.tgz", + "integrity": "sha512-nMNZG0zfMgmdv8S5O0TM5cpwNbGKRGPCxVsr0SmA3NZZy9CYBbuNLL0PD3Acx9e5LIUgwONXtM9kM6RlawPxEQ==", + "license": "MIT" + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -18287,7 +18087,8 @@ "node_modules/fast-content-type-parse": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", - "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==" + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", + "license": "MIT" }, "node_modules/fast-copy": { "version": "3.0.1", @@ -19076,49 +18877,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -24775,6 +24533,12 @@ "jsonwebtoken": "^9.0.2" } }, + "node_modules/octokit-auth-probot/node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "license": "ISC" + }, "node_modules/odbc": { "version": "2.4.9", "resolved": "https://registry.npmjs.org/odbc/-/odbc-2.4.9.tgz", @@ -29603,9 +29367,10 @@ } }, "node_modules/toad-cache": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.3.0.tgz", - "integrity": "sha512-3oDzcogWGHZdkwrHyvJVpPjA7oNzY6ENOV3PsWJY9XYPZ6INo94Yd47s5may1U+nleBPwDhrRiTPMIvKaa3MQg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", "engines": { "node": ">=12" } @@ -30704,9 +30469,10 @@ "integrity": "sha512-G5o6f95b5BggDGuUfKDApKaCgNYy2x7OdHY0zSMF081O0EJobw+1130VONhrA7ezGSV2FNOGyM+KQpQZAr9bIQ==" }, "node_modules/universal-user-agent": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", - "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==" + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" }, "node_modules/universalify": { "version": "2.0.1", @@ -31953,9 +31719,9 @@ "license": "MIT" }, "node_modules/xml-crypto": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.0.1.tgz", - "integrity": "sha512-v05aU7NS03z4jlZ0iZGRFeZsuKO1UfEbbYiaeRMiATBFs6Jq9+wqKquEMTn4UTrYZ9iGD8yz3KT4L9o2iF682w==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz", + "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", "license": "MIT", "dependencies": { "@xmldom/is-dom-node": "^1.0.1", diff --git a/backend/package.json b/backend/package.json index 01bb0c42a..0c8464eaf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,7 +37,7 @@ "build": "tsup --sourcemap", "build:frontend": "npm run build --prefix ../frontend", "start": "node --enable-source-maps dist/main.mjs", - "type:check": "tsc --noEmit", + "type:check": "node --max-old-space-size=8192 ./node_modules/.bin/tsc --noEmit", "lint:fix": "node --max-old-space-size=8192 ./node_modules/.bin/eslint --fix --ext js,ts ./src", "lint": "node --max-old-space-size=8192 ./node_modules/.bin/eslint 'src/**/*.ts'", "test:unit": "vitest run -c vitest.unit.config.ts", @@ -145,6 +145,7 @@ "@fastify/multipart": "8.3.1", "@fastify/passport": "^2.4.0", "@fastify/rate-limit": "^9.0.0", + "@fastify/reply-from": "^9.8.0", "@fastify/request-context": "^5.1.0", "@fastify/session": "^10.7.0", "@fastify/static": "^7.0.4", @@ -153,11 +154,12 @@ "@gitbeaker/rest": "^42.5.0", "@google-cloud/kms": "^4.5.0", "@infisical/quic": "^1.0.8", - "@node-saml/passport-saml": "^5.0.1", + "@node-saml/passport-saml": "^5.1.0", "@octokit/auth-app": "^7.1.1", "@octokit/core": "^5.2.1", "@octokit/plugin-paginate-graphql": "^4.0.1", "@octokit/plugin-retry": "^5.0.5", + "@octokit/request": "8.4.1", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", "@octopusdeploy/api-client": "^3.4.1", @@ -182,6 +184,7 @@ "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", "axios": "^1.11.0", + "axios-ntlm": "^1.4.4", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "botbuilder": "^4.23.2", diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index c0e18a763..111f42520 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -99,6 +99,7 @@ const main = async () => { (el) => !el.tableName.includes("_migrations") && !el.tableName.includes("audit_logs_") && + !el.tableName.includes("active_locks") && el.tableName !== "intermediate_audit_logs" ); diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index bcc50ee6c..b0a5fbb45 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -1,21 +1,24 @@ import "fastify"; -import { Redis } from "ioredis"; +import { Cluster, Redis } from "ioredis"; import { TUsers } from "@app/db/schemas"; import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-types"; import { TAssumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-types"; import { TAuditLogServiceFactory, TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; -import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-types"; +import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; import { TCertificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-types"; import { TCertificateEstServiceFactory } from "@app/ee/services/certificate-est/certificate-est-service"; import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-types"; import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-types"; +import { TEventBusService } from "@app/ee/services/event/event-bus-service"; +import { TServerSentEventsService } from "@app/ee/services/event/event-sse-service"; import { TExternalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TGithubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; +import { TIdentityAuthTemplateServiceFactory } from "@app/ee/services/identity-auth-template"; import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TIdentityProjectAdditionalPrivilegeV2ServiceFactory } from "@app/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service"; import { TKmipClientDALFactory } from "@app/ee/services/kmip/kmip-client-dal"; @@ -80,6 +83,8 @@ import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; +import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; @@ -145,6 +150,7 @@ declare module "fastify" { interface Session { callbackPort: string; isAdminLogin: boolean; + orgSlug?: string; } interface FastifyRequest { @@ -157,6 +163,7 @@ declare module "fastify" { }; // identity injection. depending on which kinda of token the information is filled in auth auth: TAuthMode; + shouldForwardWritesToPrimaryInstance: boolean; permission: { authMethod: ActorAuthMethod; type: ActorType; @@ -190,7 +197,7 @@ declare module "fastify" { } interface FastifyInstance { - redis: Redis; + redis: Redis | Cluster; services: { login: TAuthLoginFactory; password: TAuthPasswordFactory; @@ -296,6 +303,11 @@ declare module "fastify" { internalCertificateAuthority: TInternalCertificateAuthorityServiceFactory; pkiTemplate: TPkiTemplatesServiceFactory; reminder: TReminderServiceFactory; + bus: TEventBusService; + sse: TServerSentEventsService; + identityAuthTemplate: TIdentityAuthTemplateServiceFactory; + notification: TNotificationServiceFactory; + offlineUsageReport: TOfflineUsageReportServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 185a32356..525ad1619 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -494,6 +494,11 @@ import { TAccessApprovalPoliciesEnvironmentsInsert, TAccessApprovalPoliciesEnvironmentsUpdate } from "@app/db/schemas/access-approval-policies-environments"; +import { + TIdentityAuthTemplates, + TIdentityAuthTemplatesInsert, + TIdentityAuthTemplatesUpdate +} from "@app/db/schemas/identity-auth-templates"; import { TIdentityLdapAuths, TIdentityLdapAuthsInsert, @@ -525,6 +530,11 @@ import { TSecretReminderRecipientsInsert, TSecretReminderRecipientsUpdate } from "@app/db/schemas/secret-reminder-recipients"; +import { + TUserNotifications, + TUserNotificationsInsert, + TUserNotificationsUpdate +} from "@app/db/schemas/user-notifications"; declare module "knex" { namespace Knex { @@ -878,6 +888,11 @@ declare module "knex/types/tables" { TIdentityProjectAdditionalPrivilegeInsert, TIdentityProjectAdditionalPrivilegeUpdate >; + [TableName.IdentityAuthTemplate]: KnexOriginal.CompositeTableType< + TIdentityAuthTemplates, + TIdentityAuthTemplatesInsert, + TIdentityAuthTemplatesUpdate + >; [TableName.AccessApprovalPolicy]: KnexOriginal.CompositeTableType< TAccessApprovalPolicies, @@ -1244,5 +1259,10 @@ declare module "knex/types/tables" { TRemindersRecipientsInsert, TRemindersRecipientsUpdate >; + [TableName.UserNotifications]: KnexOriginal.CompositeTableType< + TUserNotifications, + TUserNotificationsInsert, + TUserNotificationsUpdate + >; } } 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/20250723220500_remove-srp.ts b/backend/src/db/migrations/20250723220500_remove-srp.ts new file mode 100644 index 000000000..4eb93e86a --- /dev/null +++ b/backend/src/db/migrations/20250723220500_remove-srp.ts @@ -0,0 +1,18 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.UserEncryptionKey, (table) => { + table.text("encryptedPrivateKey").nullable().alter(); + table.text("publicKey").nullable().alter(); + table.text("iv").nullable().alter(); + table.text("tag").nullable().alter(); + table.text("salt").nullable().alter(); + table.text("verifier").nullable().alter(); + }); +} + +export async function down(): Promise { + // do nothing for now to avoid breaking down migrations +} 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/20250730162101_add-start-from-reminder.ts b/backend/src/db/migrations/20250730162101_add-start-from-reminder.ts new file mode 100644 index 000000000..427e7fb5a --- /dev/null +++ b/backend/src/db/migrations/20250730162101_add-start-from-reminder.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Reminder, "fromDate"))) { + await knex.schema.alterTable(TableName.Reminder, (t) => { + t.timestamp("fromDate", { useTz: true }).nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Reminder, "fromDate")) { + await knex.schema.alterTable(TableName.Reminder, (t) => { + t.dropColumn("fromDate"); + }); + } +} diff --git a/backend/src/db/migrations/20250801170240_add-identity-auth-template.ts b/backend/src/db/migrations/20250801170240_add-identity-auth-template.ts new file mode 100644 index 000000000..60974a661 --- /dev/null +++ b/backend/src/db/migrations/20250801170240_add-identity-auth-template.ts @@ -0,0 +1,36 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityAuthTemplate))) { + await knex.schema.createTable(TableName.IdentityAuthTemplate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("templateFields").notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.string("name", 64).notNullable(); + t.string("authMethod").notNullable(); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.IdentityAuthTemplate); + } + if (!(await knex.schema.hasColumn(TableName.IdentityLdapAuth, "templateId"))) { + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + t.uuid("templateId").nullable(); + t.foreign("templateId").references("id").inTable(TableName.IdentityAuthTemplate).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.IdentityLdapAuth, "templateId")) { + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + t.dropForeign(["templateId"]); + t.dropColumn("templateId"); + }); + } + await knex.schema.dropTableIfExists(TableName.IdentityAuthTemplate); + await dropOnUpdateTrigger(knex, TableName.IdentityAuthTemplate); +} diff --git a/backend/src/db/migrations/20250805151349_last-logged-auth-method.ts b/backend/src/db/migrations/20250805151349_last-logged-auth-method.ts new file mode 100644 index 000000000..a373e0a91 --- /dev/null +++ b/backend/src/db/migrations/20250805151349_last-logged-auth-method.ts @@ -0,0 +1,65 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const lastUserLoggedInAuthMethod = await knex.schema.hasColumn(TableName.OrgMembership, "lastLoginAuthMethod"); + const lastIdentityLoggedInAuthMethod = await knex.schema.hasColumn( + TableName.IdentityOrgMembership, + "lastLoginAuthMethod" + ); + const lastUserLoggedInTime = await knex.schema.hasColumn(TableName.OrgMembership, "lastLoginTime"); + const lastIdentityLoggedInTime = await knex.schema.hasColumn(TableName.IdentityOrgMembership, "lastLoginTime"); + if (!lastUserLoggedInAuthMethod || !lastUserLoggedInTime) { + await knex.schema.alterTable(TableName.OrgMembership, (t) => { + if (!lastUserLoggedInAuthMethod) { + t.string("lastLoginAuthMethod").nullable(); + } + if (!lastUserLoggedInTime) { + t.datetime("lastLoginTime").nullable(); + } + }); + } + + if (!lastIdentityLoggedInAuthMethod || !lastIdentityLoggedInTime) { + await knex.schema.alterTable(TableName.IdentityOrgMembership, (t) => { + if (!lastIdentityLoggedInAuthMethod) { + t.string("lastLoginAuthMethod").nullable(); + } + if (!lastIdentityLoggedInTime) { + t.datetime("lastLoginTime").nullable(); + } + }); + } +} + +export async function down(knex: Knex): Promise { + const lastUserLoggedInAuthMethod = await knex.schema.hasColumn(TableName.OrgMembership, "lastLoginAuthMethod"); + const lastIdentityLoggedInAuthMethod = await knex.schema.hasColumn( + TableName.IdentityOrgMembership, + "lastLoginAuthMethod" + ); + const lastUserLoggedInTime = await knex.schema.hasColumn(TableName.OrgMembership, "lastLoginTime"); + const lastIdentityLoggedInTime = await knex.schema.hasColumn(TableName.IdentityOrgMembership, "lastLoginTime"); + if (lastUserLoggedInAuthMethod || lastUserLoggedInTime) { + await knex.schema.alterTable(TableName.OrgMembership, (t) => { + if (lastUserLoggedInAuthMethod) { + t.dropColumn("lastLoginAuthMethod"); + } + if (lastUserLoggedInTime) { + t.dropColumn("lastLoginTime"); + } + }); + } + + if (lastIdentityLoggedInAuthMethod || lastIdentityLoggedInTime) { + await knex.schema.alterTable(TableName.IdentityOrgMembership, (t) => { + if (lastIdentityLoggedInAuthMethod) { + t.dropColumn("lastLoginAuthMethod"); + } + if (lastIdentityLoggedInTime) { + t.dropColumn("lastLoginTime"); + } + }); + } +} 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/20250808174003_add-user-alias-is-email-verified.ts b/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts new file mode 100644 index 000000000..03a45b1fd --- /dev/null +++ b/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts @@ -0,0 +1,49 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const BATCH_SIZE = 1000; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.UserAliases, "isEmailVerified"))) { + // Add the column + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.boolean("isEmailVerified").defaultTo(false); + }); + + const aliasesToUpdate: { aliasId: string; isEmailVerified: boolean }[] = await knex(TableName.UserAliases) + .join(TableName.Users, `${TableName.UserAliases}.userId`, `${TableName.Users}.id`) + .select([`${TableName.UserAliases}.id as aliasId`, `${TableName.Users}.isEmailVerified`]); + + for (let i = 0; i < aliasesToUpdate.length; i += BATCH_SIZE) { + const batch = aliasesToUpdate.slice(i, i + BATCH_SIZE); + + const trueIds = batch.filter((row) => row.isEmailVerified).map((row) => row.aliasId); + + if (trueIds.length > 0) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.UserAliases).whereIn("id", trueIds).update({ isEmailVerified: true }); + } + } + } + + if (!(await knex.schema.hasColumn(TableName.AuthTokens, "aliasId"))) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.string("aliasId").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.UserAliases, "isEmailVerified")) { + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.dropColumn("isEmailVerified"); + }); + } + + if (await knex.schema.hasColumn(TableName.AuthTokens, "aliasId")) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.dropColumn("aliasId"); + }); + } +} 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/migrations/20250815022242_identity-lockouts.ts b/backend/src/db/migrations/20250815022242_identity-lockouts.ts new file mode 100644 index 000000000..d25ee4f47 --- /dev/null +++ b/backend/src/db/migrations/20250815022242_identity-lockouts.ts @@ -0,0 +1,57 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDurationSeconds"); + const hasLockoutCounterReset = await knex.schema.hasColumn( + TableName.IdentityUniversalAuth, + "lockoutCounterResetSeconds" + ); + + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { + if (!hasLockoutEnabled) { + t.boolean("lockoutEnabled").notNullable().defaultTo(true); + } + if (!hasLockoutThreshold) { + t.integer("lockoutThreshold").notNullable().defaultTo(3); + } + if (!hasLockoutDuration) { + t.integer("lockoutDurationSeconds").notNullable().defaultTo(300); // 5 minutes + } + if (!hasLockoutCounterReset) { + t.integer("lockoutCounterResetSeconds").notNullable().defaultTo(30); // 30 seconds + } + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDurationSeconds"); + const hasLockoutCounterReset = await knex.schema.hasColumn( + TableName.IdentityUniversalAuth, + "lockoutCounterResetSeconds" + ); + + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { + if (hasLockoutEnabled) { + t.dropColumn("lockoutEnabled"); + } + if (hasLockoutThreshold) { + t.dropColumn("lockoutThreshold"); + } + if (hasLockoutDuration) { + t.dropColumn("lockoutDurationSeconds"); + } + if (hasLockoutCounterReset) { + t.dropColumn("lockoutCounterResetSeconds"); + } + }); + } +} diff --git a/backend/src/db/migrations/20250824173438_add-approval-secret-read-compat.ts b/backend/src/db/migrations/20250824173438_add-approval-secret-read-compat.ts new file mode 100644 index 000000000..7398920af --- /dev/null +++ b/backend/src/db/migrations/20250824173438_add-approval-secret-read-compat.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission"))) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.boolean("shouldCheckSecretPermission").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission")) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropColumn("shouldCheckSecretPermission"); + }); + } +} diff --git a/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts b/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts new file mode 100644 index 000000000..7a629ca52 --- /dev/null +++ b/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { selectAllTableCols } from "@app/lib/knex"; + +import { TableName } from "../schemas"; + +const BATCH_SIZE = 100; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission")) { + // find all existing SecretApprovalPolicy rows to backfill shouldCheckSecretPermission flag + const rows = await knex(TableName.SecretApprovalPolicy).select(selectAllTableCols(TableName.SecretApprovalPolicy)); + + if (rows.length > 0) { + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + const batch = rows.slice(i, i + BATCH_SIZE); + // eslint-disable-next-line no-await-in-loop + await knex(TableName.SecretApprovalPolicy) + .whereIn( + "id", + batch.map((row) => row.id) + ) + .update({ shouldCheckSecretPermission: true }); + } + } + } +} + +export async function down(): Promise {} diff --git a/backend/src/db/migrations/20250826190000_add-properties-to-pki-subscriber.ts b/backend/src/db/migrations/20250826190000_add-properties-to-pki-subscriber.ts new file mode 100644 index 000000000..83a85c94f --- /dev/null +++ b/backend/src/db/migrations/20250826190000_add-properties-to-pki-subscriber.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasPropertiesCol = await knex.schema.hasColumn(TableName.PkiSubscriber, "properties"); + + if (!hasPropertiesCol) { + await knex.schema.alterTable(TableName.PkiSubscriber, (t) => { + t.jsonb("properties").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasPropertiesCol = await knex.schema.hasColumn(TableName.PkiSubscriber, "properties"); + + if (hasPropertiesCol) { + await knex.schema.alterTable(TableName.PkiSubscriber, (t) => { + t.dropColumn("properties"); + }); + } +} diff --git a/backend/src/db/migrations/20250829203610_user-notifications.ts b/backend/src/db/migrations/20250829203610_user-notifications.ts new file mode 100644 index 000000000..6fabfa882 --- /dev/null +++ b/backend/src/db/migrations/20250829203610_user-notifications.ts @@ -0,0 +1,50 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.UserNotifications))) { + const createTableSql = knex.schema + .createTable(TableName.UserNotifications, (t) => { + t.uuid("id").defaultTo(knex.fn.uuid()); + t.uuid("userId").notNullable(); + t.uuid("orgId").nullable(); + + t.string("type").notNullable(); + t.string("title").notNullable(); // Markdown + t.text("body").nullable(); // Markdown + t.string("link").nullable(); + t.boolean("isRead").notNullable().defaultTo(false); + + t.timestamps(true, true, true); + + t.primary(["id", "createdAt"]); + }) + .toString(); + + await knex.schema.raw(` + ${createTableSql} PARTITION BY RANGE ("createdAt"); + `); + + await knex.schema.raw( + `CREATE TABLE ${TableName.UserNotifications}_default PARTITION OF ${TableName.UserNotifications} DEFAULT` + ); + + await knex.schema.alterTable(TableName.UserNotifications, (t) => { + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + + t.index("type"); + t.index(["userId", "isRead"]); + t.index(["userId", "createdAt", "orgId"]); + }); + + await createOnUpdateTrigger(knex, TableName.UserNotifications); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.UserNotifications); + await dropOnUpdateTrigger(knex, TableName.UserNotifications); +} diff --git a/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts b/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts new file mode 100644 index 000000000..a70dcb8b9 --- /dev/null +++ b/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts @@ -0,0 +1,221 @@ +import { Knex } from "knex"; + +import { inMemoryKeyStore } from "@app/keystore/memory"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { KmsDataKey } from "@app/services/kms/kms-types"; +import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; + +import { SecretKeyEncoding, TableName } from "../schemas"; +import { getMigrationEnvConfig } from "./utils/env-config"; +import { createCircularCache } from "./utils/ring-buffer"; +import { getMigrationEncryptionServices } from "./utils/services"; + +const BATCH_SIZE = 500; +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.AuditLogStream)) { + const hasProvider = await knex.schema.hasColumn(TableName.AuditLogStream, "provider"); + const hasEncryptedCredentials = await knex.schema.hasColumn(TableName.AuditLogStream, "encryptedCredentials"); + + await knex.schema.alterTable(TableName.AuditLogStream, (t) => { + if (!hasProvider) t.string("provider").notNullable().defaultTo("custom"); + if (!hasEncryptedCredentials) t.binary("encryptedCredentials"); + + // This column will no longer be used but we're not dropping it so that we can have a backup in case the migration goes wrong + t.string("url").nullable().alter(); + }); + + if (!hasEncryptedCredentials) { + const superAdminDAL = superAdminDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL); + const keyStore = inMemoryKeyStore(); + + const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); + + const orgEncryptionRingBuffer = + createCircularCache>>(25); + + const logStreams = await knex(TableName.AuditLogStream).select( + "id", + "orgId", + + "url", + "encryptedHeadersAlgorithm", + "encryptedHeadersCiphertext", + "encryptedHeadersIV", + "encryptedHeadersKeyEncoding", + "encryptedHeadersTag" + ); + + const updatedLogStreams = await Promise.all( + logStreams.map(async (el) => { + let orgKmsService = orgEncryptionRingBuffer.getItem(el.orgId); + if (!orgKmsService) { + orgKmsService = await kmsService.createCipherPairWithDataKey( + { + type: KmsDataKey.Organization, + orgId: el.orgId + }, + knex + ); + orgEncryptionRingBuffer.push(el.orgId, orgKmsService); + } + + const provider = "custom"; + let credentials; + + if ( + el.encryptedHeadersTag && + el.encryptedHeadersIV && + el.encryptedHeadersCiphertext && + el.encryptedHeadersKeyEncoding + ) { + const decryptedHeaders = crypto + .encryption() + .symmetric() + .decryptWithRootEncryptionKey({ + tag: el.encryptedHeadersTag, + iv: el.encryptedHeadersIV, + ciphertext: el.encryptedHeadersCiphertext, + keyEncoding: el.encryptedHeadersKeyEncoding as SecretKeyEncoding + }); + + credentials = { + url: el.url, + headers: JSON.parse(decryptedHeaders) + }; + } else { + credentials = { + url: el.url, + headers: [] + }; + } + + const encryptedCredentials = orgKmsService.encryptor({ + plainText: Buffer.from(JSON.stringify(credentials), "utf8") + }).cipherTextBlob; + + return { + id: el.id, + orgId: el.orgId, + url: el.url, + provider, + encryptedCredentials + }; + }) + ); + + for (let i = 0; i < updatedLogStreams.length; i += BATCH_SIZE) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.AuditLogStream) + .insert(updatedLogStreams.slice(i, i + BATCH_SIZE)) + .onConflict("id") + .merge(); + } + + await knex.schema.alterTable(TableName.AuditLogStream, (t) => { + t.binary("encryptedCredentials").notNullable().alter(); + }); + } + } +} + +// IMPORTANT: The down migration does not utilize the existing "url" and encrypted header columns +// because we're taking the latest data from the credentials column and re-encrypting it into relevant columns +// +// If this down migration was to fail, you can fall-back to the existing URL and encrypted header columns to retrieve +// data that was created prior to this migration + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.AuditLogStream)) { + const hasProvider = await knex.schema.hasColumn(TableName.AuditLogStream, "provider"); + const hasEncryptedCredentials = await knex.schema.hasColumn(TableName.AuditLogStream, "encryptedCredentials"); + + if (hasEncryptedCredentials) { + const superAdminDAL = superAdminDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL); + const keyStore = inMemoryKeyStore(); + + const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); + + const orgEncryptionRingBuffer = + createCircularCache>>(25); + + const logStreamsToRevert = await knex(TableName.AuditLogStream) + .select("id", "orgId", "encryptedCredentials") + .where("provider", "custom") + .whereNotNull("encryptedCredentials"); + + const updatedLogStreams = await Promise.all( + logStreamsToRevert.map(async (el) => { + let orgKmsService = orgEncryptionRingBuffer.getItem(el.orgId); + if (!orgKmsService) { + orgKmsService = await kmsService.createCipherPairWithDataKey( + { + type: KmsDataKey.Organization, + orgId: el.orgId + }, + knex + ); + orgEncryptionRingBuffer.push(el.orgId, orgKmsService); + } + + const decryptedCredentials = orgKmsService + .decryptor({ + cipherTextBlob: el.encryptedCredentials + }) + .toString(); + + const credentials: { url: string; headers: { key: string; value: string }[] } = + JSON.parse(decryptedCredentials); + + const originalUrl: string = credentials.url; + + const encryptedHeadersResult = crypto + .encryption() + .symmetric() + .encryptWithRootEncryptionKey(JSON.stringify(credentials.headers), envConfig); + + const encryptedHeadersAlgorithm: string = encryptedHeadersResult.algorithm; + const encryptedHeadersCiphertext: string = encryptedHeadersResult.ciphertext; + const encryptedHeadersIV: string = encryptedHeadersResult.iv; + const encryptedHeadersKeyEncoding: string = encryptedHeadersResult.encoding; + const encryptedHeadersTag: string = encryptedHeadersResult.tag; + + return { + id: el.id, + orgId: el.orgId, + encryptedCredentials: el.encryptedCredentials, + + url: originalUrl, + encryptedHeadersAlgorithm, + encryptedHeadersCiphertext, + encryptedHeadersIV, + encryptedHeadersKeyEncoding, + encryptedHeadersTag + }; + }) + ); + + for (let i = 0; i < updatedLogStreams.length; i += BATCH_SIZE) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.AuditLogStream) + .insert(updatedLogStreams.slice(i, i + BATCH_SIZE)) + .onConflict("id") + .merge(); + } + + await knex(TableName.AuditLogStream) + .where((qb) => { + void qb.whereNot("provider", "custom").orWhereNull("url"); + }) + .del(); + } + + await knex.schema.alterTable(TableName.AuditLogStream, (t) => { + t.string("url").notNullable().alter(); + + if (hasProvider) t.dropColumn("provider"); + if (hasEncryptedCredentials) t.dropColumn("encryptedCredentials"); + }); + } +} 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/audit-log-streams.ts b/backend/src/db/schemas/audit-log-streams.ts index 901dd8d27..a3f6bafba 100644 --- a/backend/src/db/schemas/audit-log-streams.ts +++ b/backend/src/db/schemas/audit-log-streams.ts @@ -5,11 +5,13 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const AuditLogStreamsSchema = z.object({ id: z.string().uuid(), - url: z.string(), + url: z.string().nullable().optional(), encryptedHeadersCiphertext: z.string().nullable().optional(), encryptedHeadersIV: z.string().nullable().optional(), encryptedHeadersTag: z.string().nullable().optional(), @@ -17,7 +19,9 @@ export const AuditLogStreamsSchema = z.object({ encryptedHeadersKeyEncoding: z.string().nullable().optional(), orgId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + provider: z.string().default("custom"), + encryptedCredentials: zodBuffer }); export type TAuditLogStreams = z.infer; diff --git a/backend/src/db/schemas/auth-tokens.ts b/backend/src/db/schemas/auth-tokens.ts index dd8563b85..0d3e93219 100644 --- a/backend/src/db/schemas/auth-tokens.ts +++ b/backend/src/db/schemas/auth-tokens.ts @@ -17,7 +17,8 @@ export const AuthTokensSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), userId: z.string().uuid().nullable().optional(), - orgId: z.string().uuid().nullable().optional() + orgId: z.string().uuid().nullable().optional(), + aliasId: z.string().nullable().optional() }); export type TAuthTokens = z.infer; diff --git a/backend/src/db/schemas/identity-auth-templates.ts b/backend/src/db/schemas/identity-auth-templates.ts new file mode 100644 index 000000000..efe8ccb8c --- /dev/null +++ b/backend/src/db/schemas/identity-auth-templates.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityAuthTemplatesSchema = z.object({ + id: z.string().uuid(), + templateFields: zodBuffer, + orgId: z.string().uuid(), + name: z.string(), + authMethod: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityAuthTemplates = z.infer; +export type TIdentityAuthTemplatesInsert = Omit, TImmutableDBKeys>; +export type TIdentityAuthTemplatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-ldap-auths.ts b/backend/src/db/schemas/identity-ldap-auths.ts index e8d0658d5..3e4d88649 100644 --- a/backend/src/db/schemas/identity-ldap-auths.ts +++ b/backend/src/db/schemas/identity-ldap-auths.ts @@ -25,7 +25,8 @@ export const IdentityLdapAuthsSchema = z.object({ allowedFields: z.unknown().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - accessTokenPeriod: z.coerce.number().default(0) + accessTokenPeriod: z.coerce.number().default(0), + templateId: z.string().uuid().nullable().optional() }); export type TIdentityLdapAuths = z.infer; diff --git a/backend/src/db/schemas/identity-org-memberships.ts b/backend/src/db/schemas/identity-org-memberships.ts index 2f29c52e4..85cba8dfd 100644 --- a/backend/src/db/schemas/identity-org-memberships.ts +++ b/backend/src/db/schemas/identity-org-memberships.ts @@ -14,7 +14,9 @@ export const IdentityOrgMembershipsSchema = z.object({ orgId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - identityId: z.string().uuid() + identityId: z.string().uuid(), + lastLoginAuthMethod: z.string().nullable().optional(), + lastLoginTime: z.date().nullable().optional() }); export type TIdentityOrgMemberships = z.infer; diff --git a/backend/src/db/schemas/identity-universal-auths.ts b/backend/src/db/schemas/identity-universal-auths.ts index da27b4a55..29e8314aa 100644 --- a/backend/src/db/schemas/identity-universal-auths.ts +++ b/backend/src/db/schemas/identity-universal-auths.ts @@ -18,7 +18,11 @@ export const IdentityUniversalAuthsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), identityId: z.string().uuid(), - accessTokenPeriod: z.coerce.number().default(0) + accessTokenPeriod: z.coerce.number().default(0), + lockoutEnabled: z.boolean().default(true), + lockoutThreshold: z.number().default(3), + lockoutDurationSeconds: z.number().default(300), + lockoutCounterResetSeconds: z.number().default(30) }); export type TIdentityUniversalAuths = z.infer; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 55ec12faa..3e3e81fd0 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -91,6 +91,7 @@ export enum TableName { IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege", + IdentityAuthTemplate = "identity_auth_templates", // used by both identity and users IdentityMetadata = "identity_metadata", ResourceMetadata = "resource_metadata", @@ -130,6 +131,7 @@ export enum TableName { SecretApprovalRequestSecretTagV2 = "secret_approval_request_secret_tags_v2", SnapshotSecretV2 = "secret_snapshot_secrets_v2", ProjectSplitBackfillIds = "project_split_backfill_ids", + UserNotifications = "user_notifications", // Gateway OrgGatewayConfig = "org_gateway_config", Gateway = "gateways", diff --git a/backend/src/db/schemas/org-memberships.ts b/backend/src/db/schemas/org-memberships.ts index 939033c71..2112870d1 100644 --- a/backend/src/db/schemas/org-memberships.ts +++ b/backend/src/db/schemas/org-memberships.ts @@ -19,7 +19,9 @@ export const OrgMembershipsSchema = z.object({ roleId: z.string().uuid().nullable().optional(), projectFavorites: z.string().array().nullable().optional(), isActive: z.boolean().default(true), - lastInvitedAt: z.date().nullable().optional() + lastInvitedAt: z.date().nullable().optional(), + lastLoginAuthMethod: z.string().nullable().optional(), + lastLoginTime: z.date().nullable().optional() }); export type TOrgMemberships = 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/db/schemas/pki-subscribers.ts b/backend/src/db/schemas/pki-subscribers.ts index 0cdff4250..1641ba528 100644 --- a/backend/src/db/schemas/pki-subscribers.ts +++ b/backend/src/db/schemas/pki-subscribers.ts @@ -25,7 +25,8 @@ export const PkiSubscribersSchema = z.object({ lastAutoRenewAt: z.date().nullable().optional(), lastOperationStatus: z.string().nullable().optional(), lastOperationMessage: z.string().nullable().optional(), - lastOperationAt: z.date().nullable().optional() + lastOperationAt: z.date().nullable().optional(), + properties: z.unknown().nullable().optional() }); export type TPkiSubscribers = z.infer; diff --git a/backend/src/db/schemas/reminders.ts b/backend/src/db/schemas/reminders.ts index 6656ad077..f9d5d8b42 100644 --- a/backend/src/db/schemas/reminders.ts +++ b/backend/src/db/schemas/reminders.ts @@ -14,7 +14,8 @@ export const RemindersSchema = z.object({ repeatDays: z.number().nullable().optional(), nextReminderDate: z.date(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + fromDate: z.date().nullable().optional() }); export type TReminders = z.infer; diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index 0273e617c..dbb881db3 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -17,7 +17,8 @@ export const SecretApprovalPoliciesSchema = 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), + shouldCheckSecretPermission: z.boolean().nullable().optional() }); export type TSecretApprovalPolicies = z.infer; diff --git a/backend/src/db/schemas/user-aliases.ts b/backend/src/db/schemas/user-aliases.ts index 14147abf8..428fa62ca 100644 --- a/backend/src/db/schemas/user-aliases.ts +++ b/backend/src/db/schemas/user-aliases.ts @@ -16,7 +16,8 @@ export const UserAliasesSchema = z.object({ emails: z.string().array().nullable().optional(), orgId: z.string().uuid().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + isEmailVerified: z.boolean().default(false).nullable().optional() }); export type TUserAliases = z.infer; diff --git a/backend/src/db/schemas/user-encryption-keys.ts b/backend/src/db/schemas/user-encryption-keys.ts index fd9d21a9d..cfe61fdc5 100644 --- a/backend/src/db/schemas/user-encryption-keys.ts +++ b/backend/src/db/schemas/user-encryption-keys.ts @@ -15,12 +15,12 @@ export const UserEncryptionKeysSchema = z.object({ protectedKey: z.string().nullable().optional(), protectedKeyIV: z.string().nullable().optional(), protectedKeyTag: z.string().nullable().optional(), - publicKey: z.string(), - encryptedPrivateKey: z.string(), - iv: z.string(), - tag: z.string(), - salt: z.string(), - verifier: z.string(), + publicKey: z.string().nullable().optional(), + encryptedPrivateKey: z.string().nullable().optional(), + iv: z.string().nullable().optional(), + tag: z.string().nullable().optional(), + salt: z.string().nullable().optional(), + verifier: z.string().nullable().optional(), userId: z.string().uuid(), hashedPassword: z.string().nullable().optional(), serverEncryptedPrivateKey: z.string().nullable().optional(), diff --git a/backend/src/db/schemas/user-notifications.ts b/backend/src/db/schemas/user-notifications.ts new file mode 100644 index 000000000..146526600 --- /dev/null +++ b/backend/src/db/schemas/user-notifications.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const UserNotificationsSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + orgId: z.string().uuid().nullable().optional(), + type: z.string(), + title: z.string(), + body: z.string().nullable().optional(), + link: z.string().nullable().optional(), + isRead: z.boolean().default(false), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TUserNotifications = z.infer; +export type TUserNotificationsInsert = Omit, TImmutableDBKeys>; +export type TUserNotificationsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/seed-data.ts b/backend/src/db/seed-data.ts index 2aee85fb1..f1a70ca05 100644 --- a/backend/src/db/seed-data.ts +++ b/backend/src/db/seed-data.ts @@ -115,6 +115,10 @@ export const generateUserSrpKeys = async (password: string) => { }; export const getUserPrivateKey = async (password: string, user: TUserEncryptionKeys) => { + if (!user.encryptedPrivateKey || !user.iv || !user.tag || !user.salt) { + throw new Error("User encrypted private key not found"); + } + const derivedKey = await argon2.hash(password, { salt: Buffer.from(user.salt), memoryCost: 65536, diff --git a/backend/src/db/seeds/1-user.ts b/backend/src/db/seeds/1-user.ts index 5c6245382..43ce4dadf 100644 --- a/backend/src/db/seeds/1-user.ts +++ b/backend/src/db/seeds/1-user.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; -import { crypto } from "@app/lib/crypto"; -import { initLogger } from "@app/lib/logger"; +import { initEnvConfig } from "@app/lib/config/env"; +import { initLogger, logger } from "@app/lib/logger"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { AuthMethod } from "../../services/auth/auth-type"; @@ -17,7 +17,7 @@ export async function seed(knex: Knex): Promise { initLogger(); const superAdminDAL = superAdminDALFactory(knex); - await crypto.initialize(superAdminDAL); + await initEnvConfig(superAdminDAL, logger); await knex(TableName.SuperAdmin).insert([ // eslint-disable-next-line @@ -25,6 +25,7 @@ export async function seed(knex: Knex): Promise { { id: "00000000-0000-0000-0000-000000000000", initialized: true, allowSignUp: true } ]); // Inserts seed entries + const [user] = await knex(TableName.Users) .insert([ { diff --git a/backend/src/db/seeds/3-project.ts b/backend/src/db/seeds/3-project.ts index 26f96eafb..47a41a95c 100644 --- a/backend/src/db/seeds/3-project.ts +++ b/backend/src/db/seeds/3-project.ts @@ -1,9 +1,28 @@ import { Knex } from "knex"; +import { initEnvConfig } from "@app/lib/config/env"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; +import { generateUserSrpKeys } from "@app/lib/crypto/srp"; +import { initLogger, logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { AuthMethod } from "@app/services/auth/auth-type"; +import { assignWorkspaceKeysToMembers, createProjectKey } from "@app/services/project/project-fns"; +import { projectKeyDALFactory } from "@app/services/project-key/project-key-dal"; +import { projectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal"; +import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; +import { userDALFactory } from "@app/services/user/user-dal"; -import { ProjectMembershipRole, ProjectType, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; -import { buildUserProjectKey, getUserPrivateKey, seedData1 } from "../seed-data"; +import { + OrgMembershipRole, + OrgMembershipStatus, + ProjectMembershipRole, + ProjectType, + SecretEncryptionAlgo, + SecretKeyEncoding, + TableName +} from "../schemas"; +import { seedData1 } from "../seed-data"; export const DEFAULT_PROJECT_ENVS = [ { name: "Development", slug: "dev" }, @@ -11,12 +30,159 @@ export const DEFAULT_PROJECT_ENVS = [ { name: "Production", slug: "prod" } ]; +const createUserWithGhostUser = async ( + orgId: string, + projectId: string, + userId: string, + userOrgMembershipId: string, + knex: Knex +) => { + const projectKeyDAL = projectKeyDALFactory(knex); + const userDAL = userDALFactory(knex); + const projectMembershipDAL = projectMembershipDALFactory(knex); + const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(knex); + + const email = `sudo-${alphaNumericNanoId(16)}-${orgId}@infisical.com`; // We add a nanoid because the email is unique. And we have to create a new ghost user each time, so we can have access to the private key. + + const password = crypto.randomBytes(128).toString("hex"); + + const [ghostUser] = await knex(TableName.Users) + .insert({ + isGhost: true, + authMethods: [AuthMethod.EMAIL], + username: email, + email, + isAccepted: true + }) + .returning("*"); + + const encKeys = await generateUserSrpKeys(email, password); + + await knex(TableName.UserEncryptionKey) + .insert({ userId: ghostUser.id, encryptionVersion: 2, publicKey: encKeys.publicKey }) + .onConflict("userId") + .merge(); + + await knex(TableName.OrgMembership) + .insert({ + orgId, + userId: ghostUser.id, + role: OrgMembershipRole.Admin, + status: OrgMembershipStatus.Accepted, + isActive: true + }) + .returning("*"); + + const [projectMembership] = await knex(TableName.ProjectMembership) + .insert({ + userId: ghostUser.id, + projectId + }) + .returning("*"); + + await knex(TableName.ProjectUserMembershipRole).insert({ + projectMembershipId: projectMembership.id, + role: ProjectMembershipRole.Admin + }); + + const { key: encryptedProjectKey, iv: encryptedProjectKeyIv } = createProjectKey({ + publicKey: encKeys.publicKey, + privateKey: encKeys.plainPrivateKey + }); + + await knex(TableName.ProjectKeys).insert({ + projectId, + receiverId: ghostUser.id, + encryptedKey: encryptedProjectKey, + nonce: encryptedProjectKeyIv, + senderId: ghostUser.id + }); + + const { iv, tag, ciphertext, encoding, algorithm } = crypto + .encryption() + .symmetric() + .encryptWithRootEncryptionKey(encKeys.plainPrivateKey); + + await knex(TableName.ProjectBot).insert({ + name: "Infisical Bot (Ghost)", + projectId, + tag, + iv, + encryptedProjectKey, + encryptedProjectKeyNonce: encryptedProjectKeyIv, + encryptedPrivateKey: ciphertext, + isActive: true, + publicKey: encKeys.publicKey, + senderId: ghostUser.id, + algorithm, + keyEncoding: encoding + }); + + const latestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId, knex); + + if (!latestKey) { + throw new Error("Latest key not found for user"); + } + + const user = await userDAL.findUserEncKeyByUserId(userId, knex); + + if (!user || !user.publicKey) { + throw new Error("User not found"); + } + + const [projectAdmin] = assignWorkspaceKeysToMembers({ + decryptKey: latestKey, + userPrivateKey: encKeys.plainPrivateKey, + members: [ + { + userPublicKey: user.publicKey, + orgMembershipId: userOrgMembershipId + } + ] + }); + + // Create a membership for the user + const userProjectMembership = await projectMembershipDAL.create( + { + projectId, + userId: user.id + }, + knex + ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: userProjectMembership.id, role: ProjectMembershipRole.Admin }, + knex + ); + + // Create a project key for the user + await projectKeyDAL.create( + { + encryptedKey: projectAdmin.workspaceEncryptedKey, + nonce: projectAdmin.workspaceEncryptedNonce, + senderId: ghostUser.id, + receiverId: user.id, + projectId + }, + knex + ); + + return { + user: ghostUser, + keys: encKeys + }; +}; + export async function seed(knex: Knex): Promise { // Deletes ALL existing entries await knex(TableName.Project).del(); await knex(TableName.Environment).del(); await knex(TableName.SecretFolder).del(); + initLogger(); + + const superAdminDAL = superAdminDALFactory(knex); + await initEnvConfig(superAdminDAL, logger); + const [project] = await knex(TableName.Project) .insert({ name: seedData1.project.name, @@ -29,29 +195,24 @@ export async function seed(knex: Knex): Promise { }) .returning("*"); - const projectMembership = await knex(TableName.ProjectMembership) - .insert({ - projectId: project.id, + const userOrgMembership = await knex(TableName.OrgMembership) + .where({ + orgId: seedData1.organization.id, userId: seedData1.id }) - .returning("*"); - await knex(TableName.ProjectUserMembershipRole).insert({ - role: ProjectMembershipRole.Admin, - projectMembershipId: projectMembership[0].id - }); + .first(); + if (!userOrgMembership) { + throw new Error("User org membership not found"); + } const user = await knex(TableName.UserEncryptionKey).where({ userId: seedData1.id }).first(); if (!user) throw new Error("User not found"); - const userPrivateKey = await getUserPrivateKey(seedData1.password, user); - const projectKey = buildUserProjectKey(userPrivateKey, user.publicKey); - await knex(TableName.ProjectKeys).insert({ - projectId: project.id, - nonce: projectKey.nonce, - encryptedKey: projectKey.ciphertext, - receiverId: seedData1.id, - senderId: seedData1.id - }); + if (!user.publicKey) { + throw new Error("User public key not found"); + } + + await createUserWithGhostUser(seedData1.organization.id, project.id, seedData1.id, userOrgMembership.id, knex); // create default environments and default folders const envs = await knex(TableName.Environment) diff --git a/backend/src/db/seeds/5-machine-identity.ts b/backend/src/db/seeds/5-machine-identity.ts index 391f785ec..ae3d04514 100644 --- a/backend/src/db/seeds/5-machine-identity.ts +++ b/backend/src/db/seeds/5-machine-identity.ts @@ -1,6 +1,9 @@ import { Knex } from "knex"; +import { initEnvConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; +import { initLogger, logger } from "@app/lib/logger"; +import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { IdentityAuthMethod, OrgMembershipRole, ProjectMembershipRole, TableName } from "../schemas"; import { seedData1 } from "../seed-data"; @@ -10,6 +13,11 @@ export async function seed(knex: Knex): Promise { await knex(TableName.Identity).del(); await knex(TableName.IdentityOrgMembership).del(); + initLogger(); + + const superAdminDAL = superAdminDALFactory(knex); + await initEnvConfig(superAdminDAL, logger); + // Inserts seed entries await knex(TableName.Identity).insert([ { 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/audit-log-stream-router.ts b/backend/src/ee/routes/v1/audit-log-stream-router.ts deleted file mode 100644 index 17bd9e64b..000000000 --- a/backend/src/ee/routes/v1/audit-log-stream-router.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { z } from "zod"; - -import { AUDIT_LOG_STREAMS } from "@app/lib/api-docs"; -import { readLimit } from "@app/server/config/rateLimiter"; -import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { SanitizedAuditLogStreamSchema } from "@app/server/routes/sanitizedSchemas"; -import { AuthMode } from "@app/services/auth/auth-type"; - -export const registerAuditLogStreamRouter = async (server: FastifyZodProvider) => { - server.route({ - method: "POST", - url: "/", - config: { - rateLimit: readLimit - }, - schema: { - description: "Create an Audit Log Stream.", - security: [ - { - bearerAuth: [] - } - ], - body: z.object({ - url: z.string().min(1).describe(AUDIT_LOG_STREAMS.CREATE.url), - headers: z - .object({ - key: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.CREATE.headers.key), - value: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.CREATE.headers.value) - }) - .describe(AUDIT_LOG_STREAMS.CREATE.headers.desc) - .array() - .optional() - }), - response: { - 200: z.object({ - auditLogStream: SanitizedAuditLogStreamSchema - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const auditLogStream = await server.services.auditLogStream.create({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - url: req.body.url, - headers: req.body.headers - }); - - return { auditLogStream }; - } - }); - - server.route({ - method: "PATCH", - url: "/:id", - config: { - rateLimit: readLimit - }, - schema: { - description: "Update an Audit Log Stream by ID.", - security: [ - { - bearerAuth: [] - } - ], - params: z.object({ - id: z.string().describe(AUDIT_LOG_STREAMS.UPDATE.id) - }), - body: z.object({ - url: z.string().optional().describe(AUDIT_LOG_STREAMS.UPDATE.url), - headers: z - .object({ - key: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.UPDATE.headers.key), - value: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.UPDATE.headers.value) - }) - .describe(AUDIT_LOG_STREAMS.UPDATE.headers.desc) - .array() - .optional() - }), - response: { - 200: z.object({ - auditLogStream: SanitizedAuditLogStreamSchema - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const auditLogStream = await server.services.auditLogStream.updateById({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - id: req.params.id, - url: req.body.url, - headers: req.body.headers - }); - - return { auditLogStream }; - } - }); - - server.route({ - method: "DELETE", - url: "/:id", - config: { - rateLimit: readLimit - }, - schema: { - description: "Delete an Audit Log Stream by ID.", - security: [ - { - bearerAuth: [] - } - ], - params: z.object({ - id: z.string().describe(AUDIT_LOG_STREAMS.DELETE.id) - }), - response: { - 200: z.object({ - auditLogStream: SanitizedAuditLogStreamSchema - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const auditLogStream = await server.services.auditLogStream.deleteById({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - id: req.params.id - }); - - return { auditLogStream }; - } - }); - - server.route({ - method: "GET", - url: "/:id", - config: { - rateLimit: readLimit - }, - schema: { - description: "Get an Audit Log Stream by ID.", - security: [ - { - bearerAuth: [] - } - ], - params: z.object({ - id: z.string().describe(AUDIT_LOG_STREAMS.GET_BY_ID.id) - }), - response: { - 200: z.object({ - auditLogStream: SanitizedAuditLogStreamSchema.extend({ - headers: z - .object({ - key: z.string(), - value: z.string() - }) - .array() - .optional() - }) - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const auditLogStream = await server.services.auditLogStream.getById({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - id: req.params.id - }); - - return { auditLogStream }; - } - }); - - server.route({ - method: "GET", - url: "/", - config: { - rateLimit: readLimit - }, - schema: { - description: "List Audit Log Streams.", - security: [ - { - bearerAuth: [] - } - ], - response: { - 200: z.object({ - auditLogStreams: SanitizedAuditLogStreamSchema.array() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const auditLogStreams = await server.services.auditLogStream.list({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod - }); - - return { auditLogStreams }; - } - }); -}; diff --git a/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-endpoints.ts b/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-endpoints.ts new file mode 100644 index 000000000..816fc62fb --- /dev/null +++ b/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-endpoints.ts @@ -0,0 +1,142 @@ +import { z } from "zod"; + +import { LogProvider } from "@app/ee/services/audit-log-stream/audit-log-stream-enums"; +import { TAuditLogStream } from "@app/ee/services/audit-log-stream/audit-log-stream-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAuditLogStreamEndpoints = ({ + server, + provider, + createSchema, + updateSchema, + sanitizedResponseSchema +}: { + server: FastifyZodProvider; + provider: LogProvider; + createSchema: z.ZodType<{ + credentials: T["credentials"]; + }>; + updateSchema: z.ZodType<{ + credentials: T["credentials"]; + }>; + sanitizedResponseSchema: z.ZodTypeAny; +}) => { + server.route({ + method: "GET", + url: "/:logStreamId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + logStreamId: z.string().uuid() + }), + response: { + 200: z.object({ + auditLogStream: sanitizedResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { logStreamId } = req.params; + + const auditLogStream = await server.services.auditLogStream.getById(logStreamId, provider, req.permission); + + return { auditLogStream }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: createSchema, + response: { + 200: z.object({ + auditLogStream: sanitizedResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { credentials } = req.body; + + const auditLogStream = await server.services.auditLogStream.create( + { + provider, + credentials + }, + req.permission + ); + + return { auditLogStream }; + } + }); + + server.route({ + method: "PATCH", + url: "/:logStreamId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + logStreamId: z.string().uuid() + }), + body: updateSchema, + response: { + 200: z.object({ + auditLogStream: sanitizedResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { logStreamId } = req.params; + const { credentials } = req.body; + + const auditLogStream = await server.services.auditLogStream.updateById( + { + logStreamId, + provider, + credentials + }, + req.permission + ); + + return { auditLogStream }; + } + }); + + server.route({ + method: "DELETE", + url: "/:logStreamId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + logStreamId: z.string().uuid() + }), + response: { + 200: z.object({ + auditLogStream: sanitizedResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { logStreamId } = req.params; + + const auditLogStream = await server.services.auditLogStream.deleteById(logStreamId, provider, req.permission); + + return { auditLogStream }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-router.ts b/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-router.ts new file mode 100644 index 000000000..bf10e48bf --- /dev/null +++ b/backend/src/ee/routes/v1/audit-log-stream-routers/audit-log-stream-router.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +import { + CustomProviderListItemSchema, + SanitizedCustomProviderSchema +} from "@app/ee/services/audit-log-stream/custom/custom-provider-schemas"; +import { + DatadogProviderListItemSchema, + SanitizedDatadogProviderSchema +} from "@app/ee/services/audit-log-stream/datadog/datadog-provider-schemas"; +import { + SanitizedSplunkProviderSchema, + SplunkProviderListItemSchema +} from "@app/ee/services/audit-log-stream/splunk/splunk-provider-schemas"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const SanitizedAuditLogStreamSchema = z.union([ + SanitizedCustomProviderSchema, + SanitizedDatadogProviderSchema, + SanitizedSplunkProviderSchema +]); + +const ProviderOptionsSchema = z.discriminatedUnion("provider", [ + CustomProviderListItemSchema, + DatadogProviderListItemSchema, + SplunkProviderListItemSchema +]); + +export const registerAuditLogStreamRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + providerOptions: ProviderOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const providerOptions = server.services.auditLogStream.listProviderOptions(); + + return { providerOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + auditLogStreams: SanitizedAuditLogStreamSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogStreams = await server.services.auditLogStream.list(req.permission); + + return { auditLogStreams }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/audit-log-stream-routers/index.ts b/backend/src/ee/routes/v1/audit-log-stream-routers/index.ts new file mode 100644 index 000000000..ea3d48b30 --- /dev/null +++ b/backend/src/ee/routes/v1/audit-log-stream-routers/index.ts @@ -0,0 +1,51 @@ +import { LogProvider } from "@app/ee/services/audit-log-stream/audit-log-stream-enums"; +import { + CreateCustomProviderLogStreamSchema, + SanitizedCustomProviderSchema, + UpdateCustomProviderLogStreamSchema +} from "@app/ee/services/audit-log-stream/custom/custom-provider-schemas"; +import { + CreateDatadogProviderLogStreamSchema, + SanitizedDatadogProviderSchema, + UpdateDatadogProviderLogStreamSchema +} from "@app/ee/services/audit-log-stream/datadog/datadog-provider-schemas"; +import { + CreateSplunkProviderLogStreamSchema, + SanitizedSplunkProviderSchema, + UpdateSplunkProviderLogStreamSchema +} from "@app/ee/services/audit-log-stream/splunk/splunk-provider-schemas"; + +import { registerAuditLogStreamEndpoints } from "./audit-log-stream-endpoints"; + +export * from "./audit-log-stream-router"; + +export const AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP: Record Promise> = + { + [LogProvider.Custom]: async (server: FastifyZodProvider) => { + registerAuditLogStreamEndpoints({ + server, + provider: LogProvider.Custom, + sanitizedResponseSchema: SanitizedCustomProviderSchema, + createSchema: CreateCustomProviderLogStreamSchema, + updateSchema: UpdateCustomProviderLogStreamSchema + }); + }, + [LogProvider.Datadog]: async (server: FastifyZodProvider) => { + registerAuditLogStreamEndpoints({ + server, + provider: LogProvider.Datadog, + sanitizedResponseSchema: SanitizedDatadogProviderSchema, + createSchema: CreateDatadogProviderLogStreamSchema, + updateSchema: UpdateDatadogProviderLogStreamSchema + }); + }, + [LogProvider.Splunk]: async (server: FastifyZodProvider) => { + registerAuditLogStreamEndpoints({ + server, + provider: LogProvider.Splunk, + sanitizedResponseSchema: SanitizedSplunkProviderSchema, + createSchema: CreateSplunkProviderLogStreamSchema, + updateSchema: UpdateSplunkProviderLogStreamSchema + }); + } + }; diff --git a/backend/src/ee/routes/v1/github-org-sync-router.ts b/backend/src/ee/routes/v1/github-org-sync-router.ts index 3f33a5d8f..0d7279928 100644 --- a/backend/src/ee/routes/v1/github-org-sync-router.ts +++ b/backend/src/ee/routes/v1/github-org-sync-router.ts @@ -126,4 +126,39 @@ export const registerGithubOrgSyncRouter = async (server: FastifyZodProvider) => return { githubOrgSyncConfig }; } }); + + server.route({ + url: "/sync-all-teams", + method: "POST", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + response: { + 200: z.object({ + totalUsers: z.number(), + errors: z.array(z.string()), + createdTeams: z.array(z.string()), + updatedTeams: z.array(z.string()), + removedMemberships: z.number(), + syncDuration: z.number() + }) + } + }, + handler: async (req) => { + const result = await server.services.githubOrgSync.syncAllTeams({ + orgPermission: req.permission + }); + + return { + totalUsers: result.totalUsers, + errors: result.errors, + createdTeams: result.createdTeams, + updatedTeams: result.updatedTeams, + removedMemberships: result.removedMemberships, + syncDuration: result.syncDuration + }; + } + }); }; diff --git a/backend/src/ee/routes/v1/identity-template-router.ts b/backend/src/ee/routes/v1/identity-template-router.ts new file mode 100644 index 000000000..b30069643 --- /dev/null +++ b/backend/src/ee/routes/v1/identity-template-router.ts @@ -0,0 +1,391 @@ +import { z } from "zod"; + +import { IdentityAuthTemplatesSchema } from "@app/db/schemas/identity-auth-templates"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + IdentityAuthTemplateMethod, + TEMPLATE_SUCCESS_MESSAGES, + TEMPLATE_VALIDATION_MESSAGES +} from "@app/ee/services/identity-auth-template/identity-auth-template-enums"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const ldapTemplateFieldsSchema = z.object({ + url: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.URL_REQUIRED), + bindDN: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.BIND_DN_REQUIRED), + bindPass: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.BIND_PASSWORD_REQUIRED), + searchBase: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.SEARCH_BASE_REQUIRED), + ldapCaCertificate: z.string().trim().optional() +}); + +export const registerIdentityTemplateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Create identity auth template", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + name: z + .string() + .trim() + .min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_NAME_REQUIRED) + .max(64, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_NAME_MAX_LENGTH), + authMethod: z.nativeEnum(IdentityAuthTemplateMethod), + templateFields: ldapTemplateFieldsSchema + }), + response: { + 200: IdentityAuthTemplatesSchema.extend({ + templateFields: z.record(z.string(), z.unknown()) + }) + } + }, + handler: async (req) => { + const template = await server.services.identityAuthTemplate.createTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.body.name, + authMethod: req.body.authMethod, + templateFields: req.body.templateFields + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_CREATE, + metadata: { + templateId: template.id, + name: template.name + } + } + }); + + return template; + } + }); + + server.route({ + method: "PATCH", + url: "/:templateId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Update identity auth template", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_ID_REQUIRED) + }), + body: z.object({ + name: z + .string() + .trim() + .min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_NAME_REQUIRED) + .max(64, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_NAME_MAX_LENGTH) + .optional(), + templateFields: ldapTemplateFieldsSchema.partial().optional() + }), + response: { + 200: IdentityAuthTemplatesSchema.extend({ + templateFields: z.record(z.string(), z.unknown()) + }) + } + }, + handler: async (req) => { + const template = await server.services.identityAuthTemplate.updateTemplate({ + templateId: req.params.templateId, + name: req.body.name, + templateFields: req.body.templateFields, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_UPDATE, + metadata: { + templateId: template.id, + name: template.name + } + } + }); + + return template; + } + }); + + server.route({ + method: "DELETE", + url: "/:templateId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Delete identity auth template", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_ID_REQUIRED) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req) => { + const template = await server.services.identityAuthTemplate.deleteTemplate({ + templateId: req.params.templateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_DELETE, + metadata: { + templateId: template.id, + name: template.name + } + } + }); + + return { message: TEMPLATE_SUCCESS_MESSAGES.DELETED }; + } + }); + + server.route({ + method: "GET", + url: "/:templateId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Get identity auth template by ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_ID_REQUIRED) + }), + response: { + 200: IdentityAuthTemplatesSchema.extend({ + templateFields: ldapTemplateFieldsSchema + }) + } + }, + handler: async (req) => { + const template = await server.services.identityAuthTemplate.getTemplate({ + templateId: req.params.templateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return template; + } + }); + + server.route({ + method: "GET", + url: "/search", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "List identity auth templates", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + limit: z.coerce.number().positive().max(100).default(5).optional(), + offset: z.coerce.number().min(0).default(0).optional(), + search: z.string().optional() + }), + response: { + 200: z.object({ + templates: IdentityAuthTemplatesSchema.extend({ + templateFields: ldapTemplateFieldsSchema + }).array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { templates, totalCount } = await server.services.identityAuthTemplate.listTemplates({ + limit: req.query.limit, + offset: req.query.offset, + search: req.query.search, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { templates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Get identity auth templates by authentication method", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + authMethod: z.nativeEnum(IdentityAuthTemplateMethod) + }), + response: { + 200: IdentityAuthTemplatesSchema.extend({ + templateFields: ldapTemplateFieldsSchema + }).array() + } + }, + handler: async (req) => { + const templates = await server.services.identityAuthTemplate.getTemplatesByAuthMethod({ + authMethod: req.query.authMethod, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return templates; + } + }); + + server.route({ + method: "GET", + url: "/:templateId/usage", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Get template usage by template ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string() + }), + response: { + 200: z + .object({ + identityId: z.string(), + identityName: z.string() + }) + .array() + } + }, + handler: async (req) => { + const templates = await server.services.identityAuthTemplate.findTemplateUsages({ + templateId: req.params.templateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return templates; + } + }); + + server.route({ + method: "POST", + url: "/:templateId/delete-usage", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Unlink identity auth template usage", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string() + }), + body: z.object({ + identityIds: z.string().array() + }), + response: { + 200: z + .object({ + authId: z.string(), + identityId: z.string(), + identityName: z.string() + }) + .array() + } + }, + handler: async (req) => { + const templates = await server.services.identityAuthTemplate.unlinkTemplateUsage({ + templateId: req.params.templateId, + identityIds: req.body.identityIds, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return templates; + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 8f3b69dfa..8bf066013 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -3,7 +3,7 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-templat import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; import { registerAssumePrivilegeRouter } from "./assume-privilege-router"; -import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; +import { AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP, registerAuditLogStreamRouter } from "./audit-log-stream-routers"; import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router"; @@ -13,6 +13,7 @@ import { registerGatewayRouter } from "./gateway-router"; import { registerGithubOrgSyncRouter } from "./github-org-sync-router"; import { registerGroupRouter } from "./group-router"; import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; +import { registerIdentityTemplateRouter } from "./identity-template-router"; import { registerKmipRouter } from "./kmip-router"; import { registerKmipSpecRouter } from "./kmip-spec-router"; import { registerLdapRouter } from "./ldap-router"; @@ -113,7 +114,21 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerSecretRouter, { prefix: "/secrets" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); await server.register(registerGroupRouter, { prefix: "/groups" }); - await server.register(registerAuditLogStreamRouter, { prefix: "/audit-log-streams" }); + + await server.register( + async (auditLogStreamRouter) => { + await auditLogStreamRouter.register(registerAuditLogStreamRouter); + + // Provider-specific endpoints + await Promise.all( + Object.entries(AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP).map(([provider, router]) => + auditLogStreamRouter.register(router, { prefix: `/${provider}` }) + ) + ); + }, + { prefix: "/audit-log-streams" } + ); + await server.register(registerUserAdditionalPrivilegeRouter, { prefix: "/user-project-additional-privilege" }); await server.register( async (privilegeRouter) => { @@ -125,6 +140,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerExternalKmsRouter, { prefix: "/external-kms" }); + await server.register(registerIdentityTemplateRouter, { prefix: "/identity-templates" }); await server.register(registerProjectTemplateRouter, { prefix: "/project-templates" }); diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts index 7c520e2ab..52a819be8 100644 --- a/backend/src/ee/routes/v1/ldap-router.ts +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -379,14 +379,17 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/config/:configId/test-connection", + url: "/config/test-connection", config: { rateLimit: readLimit }, onRequest: verifyAuth([AuthMode.JWT]), schema: { - params: z.object({ - configId: z.string().trim() + body: z.object({ + url: z.string().trim(), + bindDN: z.string().trim(), + bindPass: z.string().trim(), + caCert: z.string().trim() }), response: { 200: z.boolean() @@ -399,8 +402,9 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { orgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - ldapConfigId: req.params.configId + ...req.body }); + return result; } }); 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..bdc9c2dcd 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -294,22 +294,30 @@ 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(), deletedAt: z.date().nullish(), - allowedSelfApprovals: z.boolean() + allowedSelfApprovals: z.boolean(), + shouldCheckSecretPermission: z.boolean().nullable().optional() }), 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/routes/v2/secret-scanning-v2-routers/gitlab-secret-scanning-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/gitlab-secret-scanning-router.ts new file mode 100644 index 000000000..c5e832d54 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/gitlab-secret-scanning-router.ts @@ -0,0 +1,16 @@ +import { registerSecretScanningEndpoints } from "@app/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints"; +import { + CreateGitLabDataSourceSchema, + GitLabDataSourceSchema, + UpdateGitLabDataSourceSchema +} from "@app/ee/services/secret-scanning-v2/gitlab"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +export const registerGitLabSecretScanningRouter = async (server: FastifyZodProvider) => + registerSecretScanningEndpoints({ + type: SecretScanningDataSource.GitLab, + server, + responseSchema: GitLabDataSourceSchema, + createSchema: CreateGitLabDataSourceSchema, + updateSchema: UpdateGitLabDataSourceSchema + }); diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts index 2258f9c82..81e393f7c 100644 --- a/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts @@ -1,3 +1,4 @@ +import { registerGitLabSecretScanningRouter } from "@app/ee/routes/v2/secret-scanning-v2-routers/gitlab-secret-scanning-router"; import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; import { registerBitbucketSecretScanningRouter } from "./bitbucket-secret-scanning-router"; @@ -10,5 +11,6 @@ export const SECRET_SCANNING_REGISTER_ROUTER_MAP: Record< (server: FastifyZodProvider) => Promise > = { [SecretScanningDataSource.GitHub]: registerGitHubSecretScanningRouter, - [SecretScanningDataSource.Bitbucket]: registerBitbucketSecretScanningRouter + [SecretScanningDataSource.Bitbucket]: registerBitbucketSecretScanningRouter, + [SecretScanningDataSource.GitLab]: registerGitLabSecretScanningRouter }; diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts index 0a672437d..e22b9afe2 100644 --- a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts @@ -4,6 +4,7 @@ import { SecretScanningConfigsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { BitbucketDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/bitbucket"; import { GitHubDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/github"; +import { GitLabDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/gitlab"; import { SecretScanningFindingStatus, SecretScanningScanStatus @@ -24,7 +25,8 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SecretScanningDataSourceOptionsSchema = z.discriminatedUnion("type", [ GitHubDataSourceListItemSchema, - BitbucketDataSourceListItemSchema + BitbucketDataSourceListItemSchema, + GitLabDataSourceListItemSchema ]); export const registerSecretScanningV2Router = async (server: FastifyZodProvider) => { 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..008b61919 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 @@ -20,6 +20,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TNotificationServiceFactory } from "../../../services/notification/notification-service"; +import { NotificationType } from "../../../services/notification/notification-types"; import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { TGroupDALFactory } from "../group/group-dal"; @@ -54,7 +56,7 @@ type TSecretApprovalRequestServiceFactoryDep = { accessApprovalPolicyDAL: Pick; accessApprovalRequestReviewerDAL: Pick< TAccessApprovalRequestReviewerDALFactory, - "create" | "find" | "findOne" | "transaction" + "create" | "find" | "findOne" | "transaction" | "delete" >; groupDAL: Pick; projectMembershipDAL: Pick; @@ -67,6 +69,7 @@ type TSecretApprovalRequestServiceFactoryDep = { projectSlackConfigDAL: Pick; microsoftTeamsService: Pick; projectMicrosoftTeamsConfigDAL: Pick; + notificationService: Pick; }; export const accessApprovalRequestServiceFactory = ({ @@ -84,7 +87,8 @@ export const accessApprovalRequestServiceFactory = ({ kmsService, microsoftTeamsService, projectMicrosoftTeamsConfigDAL, - projectSlackConfigDAL + projectSlackConfigDAL, + notificationService }: TSecretApprovalRequestServiceFactoryDep): TAccessApprovalRequestServiceFactory => { const $getEnvironmentFromPermissions = (permissions: unknown): string | null => { if (!Array.isArray(permissions) || permissions.length === 0) { @@ -156,6 +160,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[] = []; @@ -236,7 +249,8 @@ export const accessApprovalRequestServiceFactory = ({ ); const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; - const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`; + const approvalPath = `/projects/secret-management/${project.id}/approval`; + const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; await triggerWorkflowIntegrationNotification({ input: { @@ -265,6 +279,17 @@ export const accessApprovalRequestServiceFactory = ({ } }); + await notificationService.createUserNotifications( + approverUsers.map((approver) => ({ + userId: approver.id, + orgId: actorOrgId, + type: NotificationType.ACCESS_APPROVAL_REQUEST, + title: "Access Approval Request", + body: `**${requesterFullName}** (${requestedByUser.email}) has requested ${isTemporary ? "temporary" : "permanent"} access to **${secretPath}** in the **${envSlug}** environment for project **${project.name}**.`, + link: approvalPath + })) + ); + await smtpService.sendMail({ recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!), subjectLine: "Access Approval Request", @@ -292,6 +317,173 @@ 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 approvalPath = `/projects/secret-management/${project.id}/approval`; + const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; + + 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 notificationService.createUserNotifications( + policy.approvers + .filter((approver) => Boolean(approver.userId) && approver.userId !== editedByUser.id) + .map((approver) => ({ + userId: approver.userId!, + orgId: actorOrgId, + type: NotificationType.ACCESS_APPROVAL_REQUEST_UPDATED, + title: "Access Approval Request Updated", + body: `**${editorFullName}** (${editedByUser.email}) has updated the access request submitted by **${requesterFullName}** (${requestedByUser.email}) for **${secretPath}** in the **${envSlug}** environment for project **${project.name}**.`, + link: approvalPath + })) + ); + + const recipients = policy.approvers + .filter((approver) => Boolean(approver.email) && approver.userId !== editedByUser.id) + .map((approver) => approver.email!); + + if (recipients.length > 0) { + await smtpService.sendMail({ + recipients, + 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 +833,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-stream/audit-log-stream-enums.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-enums.ts new file mode 100644 index 000000000..9c03dc2bd --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-enums.ts @@ -0,0 +1,5 @@ +export enum LogProvider { + Datadog = "datadog", + Splunk = "splunk", + Custom = "custom" +} diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-factory.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-factory.ts new file mode 100644 index 000000000..f70dc02a0 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-factory.ts @@ -0,0 +1,13 @@ +import { LogProvider } from "./audit-log-stream-enums"; +import { TAuditLogStreamCredentials, TLogStreamFactory } from "./audit-log-stream-types"; +import { CustomProviderFactory } from "./custom/custom-provider-factory"; +import { DatadogProviderFactory } from "./datadog/datadog-provider-factory"; +import { SplunkProviderFactory } from "./splunk/splunk-provider-factory"; + +type TLogStreamFactoryImplementation = TLogStreamFactory; + +export const LOG_STREAM_FACTORY_MAP: Record = { + [LogProvider.Datadog]: DatadogProviderFactory as TLogStreamFactoryImplementation, + [LogProvider.Splunk]: SplunkProviderFactory as TLogStreamFactoryImplementation, + [LogProvider.Custom]: CustomProviderFactory as TLogStreamFactoryImplementation +}; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-fns.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-fns.ts index dc93f238e..87a7736a3 100644 --- a/backend/src/ee/services/audit-log-stream/audit-log-stream-fns.ts +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-fns.ts @@ -1,21 +1,70 @@ -export function providerSpecificPayload(url: string) { - const { hostname } = new URL(url); +import { TAuditLogStreams } from "@app/db/schemas"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; - const payload: Record = {}; +import { TAuditLogStream, TAuditLogStreamCredentials } from "./audit-log-stream-types"; +import { getCustomProviderListItem } from "./custom/custom-provider-fns"; +import { getDatadogProviderListItem } from "./datadog/datadog-provider-fns"; +import { getSplunkProviderListItem } from "./splunk/splunk-provider-fns"; - switch (hostname) { - case "http-intake.logs.datadoghq.com": - case "http-intake.logs.us3.datadoghq.com": - case "http-intake.logs.us5.datadoghq.com": - case "http-intake.logs.datadoghq.eu": - case "http-intake.logs.ap1.datadoghq.com": - case "http-intake.logs.ddog-gov.com": - payload.ddsource = "infisical"; - payload.service = "audit-logs"; - break; - default: - break; - } +export const listProviderOptions = () => { + return [getDatadogProviderListItem(), getSplunkProviderListItem(), getCustomProviderListItem()].sort((a, b) => + a.name.localeCompare(b.name) + ); +}; - return payload; -} +export const encryptLogStreamCredentials = async ({ + orgId, + credentials, + kmsService +}: { + orgId: string; + credentials: TAuditLogStreamCredentials; + kmsService: Pick; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + return encryptedCredentialsBlob; +}; + +export const decryptLogStreamCredentials = async ({ + orgId, + encryptedCredentials, + kmsService +}: { + orgId: string; + encryptedCredentials: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedCredentials + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TAuditLogStreamCredentials; +}; + +export const decryptLogStream = async ( + logStream: TAuditLogStreams, + kmsService: Pick +) => { + return { + ...logStream, + credentials: await decryptLogStreamCredentials({ + encryptedCredentials: logStream.encryptedCredentials, + orgId: logStream.orgId, + kmsService + }) + } as TAuditLogStream; +}; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-schemas.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-schemas.ts new file mode 100644 index 000000000..4fba79107 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-schemas.ts @@ -0,0 +1,14 @@ +import { AuditLogStreamsSchema } from "@app/db/schemas"; + +export const BaseProviderSchema = AuditLogStreamsSchema.omit({ + encryptedCredentials: true, + provider: true, + + // Old "archived" values + encryptedHeadersAlgorithm: true, + encryptedHeadersCiphertext: true, + encryptedHeadersIV: true, + encryptedHeadersKeyEncoding: true, + encryptedHeadersTag: true, + url: true +}); diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts index 46d2782b3..5dd0fd4ba 100644 --- a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts @@ -1,242 +1,252 @@ import { ForbiddenError } from "@casl/ability"; -import { RawAxiosRequestHeaders } from "axios"; +import { AxiosError } from "axios"; -import { SecretKeyEncoding } from "@app/db/schemas"; -import { getConfig } from "@app/lib/config/env"; -import { request } from "@app/lib/config/request"; -import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; -import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { TAuditLogs } from "@app/db/schemas"; +import { + decryptLogStream, + decryptLogStreamCredentials, + encryptLogStreamCredentials, + listProviderOptions +} from "@app/ee/services/audit-log-stream/audit-log-stream-fns"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; -import { AUDIT_LOG_STREAM_TIMEOUT } from "../audit-log/audit-log-queue"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TAuditLogStreamDALFactory } from "./audit-log-stream-dal"; -import { providerSpecificPayload } from "./audit-log-stream-fns"; -import { LogStreamHeaders, TAuditLogStreamServiceFactory } from "./audit-log-stream-types"; +import { LogProvider } from "./audit-log-stream-enums"; +import { LOG_STREAM_FACTORY_MAP } from "./audit-log-stream-factory"; +import { TAuditLogStream, TCreateAuditLogStreamDTO, TUpdateAuditLogStreamDTO } from "./audit-log-stream-types"; +import { TCustomProviderCredentials } from "./custom/custom-provider-types"; -type TAuditLogStreamServiceFactoryDep = { +export type TAuditLogStreamServiceFactoryDep = { auditLogStreamDAL: TAuditLogStreamDALFactory; permissionService: Pick; licenseService: Pick; + kmsService: Pick; }; +export type TAuditLogStreamServiceFactory = ReturnType; + export const auditLogStreamServiceFactory = ({ auditLogStreamDAL, permissionService, - licenseService -}: TAuditLogStreamServiceFactoryDep): TAuditLogStreamServiceFactory => { - const create: TAuditLogStreamServiceFactory["create"] = async ({ - url, - actor, - headers = [], - actorId, - actorOrgId, - actorAuthMethod - }) => { - if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" }); - - const plan = await licenseService.getPlan(actorOrgId); + licenseService, + kmsService +}: TAuditLogStreamServiceFactoryDep) => { + const create = async ({ provider, credentials }: TCreateAuditLogStreamDTO, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); if (!plan.auditLogStreams) { throw new BadRequestError({ - message: "Failed to create audit log streams due to plan restriction. Upgrade plan to create group." + message: "Failed to create Audit Log Stream: Plan restriction. Upgrade plan to continue." }); } const { permission } = await permissionService.getOrgPermission( - actor, - actorId, - actorOrgId, - actorAuthMethod, - actorOrgId + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); - const appCfg = getConfig(); - if (appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url); - - const totalStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); + const totalStreams = await auditLogStreamDAL.find({ orgId: actor.orgId }); if (totalStreams.length >= plan.auditLogStreamLimit) { throw new BadRequestError({ - message: - "Failed to create audit log streams due to plan limit reached. Kindly contact Infisical to add more streams." + message: "Failed to create Audit Log Stream: Plan limit reached. Contact Infisical to increase quota." }); } - // testing connection first - const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - if (headers.length) - headers.forEach(({ key, value }) => { - streamHeaders[key] = value; - }); + const factory = LOG_STREAM_FACTORY_MAP[provider](); + const validatedCredentials = await factory.validateCredentials({ credentials }); - await request - .post( - url, - { ...providerSpecificPayload(url), ping: "ok" }, - { - headers: streamHeaders, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - } - ) - .catch((err) => { - throw new BadRequestError({ message: `Failed to connect with upstream source: ${(err as Error)?.message}` }); - }); + const encryptedCredentials = await encryptLogStreamCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService + }); - const encryptedHeaders = headers - ? crypto.encryption().symmetric().encryptWithRootEncryptionKey(JSON.stringify(headers)) - : undefined; const logStream = await auditLogStreamDAL.create({ - orgId: actorOrgId, - url, - ...(encryptedHeaders - ? { - encryptedHeadersCiphertext: encryptedHeaders.ciphertext, - encryptedHeadersIV: encryptedHeaders.iv, - encryptedHeadersTag: encryptedHeaders.tag, - encryptedHeadersAlgorithm: encryptedHeaders.algorithm, - encryptedHeadersKeyEncoding: encryptedHeaders.encoding - } - : {}) + orgId: actor.orgId, + provider, + encryptedCredentials }); - return logStream; + + return { ...logStream, credentials: validatedCredentials } as TAuditLogStream; }; - const updateById: TAuditLogStreamServiceFactory["updateById"] = async ({ - id, - url, - actor, - headers = [], - actorId, - actorOrgId, - actorAuthMethod - }) => { - if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" }); - - const plan = await licenseService.getPlan(actorOrgId); - if (!plan.auditLogStreams) + const updateById = async ( + { logStreamId, provider, credentials }: TUpdateAuditLogStreamDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + if (!plan.auditLogStreams) { throw new BadRequestError({ - message: "Failed to update audit log streams due to plan restriction. Upgrade plan to create group." + message: "Failed to update Audit Log Stream: Plan restriction. Upgrade plan to continue." }); + } - const logStream = await auditLogStreamDAL.findById(id); - if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${id}' not found` }); + const logStream = await auditLogStreamDAL.findById(logStreamId); + if (!logStream) throw new NotFoundError({ message: `Audit Log Stream with ID '${logStreamId}' not found` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + logStream.orgId + ); - const { orgId } = logStream; - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); - const appCfg = getConfig(); - if (url && appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url); - // testing connection first - const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - if (headers.length) - headers.forEach(({ key, value }) => { - streamHeaders[key] = value; - }); + const finalCredentials = { ...credentials }; - await request - .post( - url || logStream.url, - { ...providerSpecificPayload(url || logStream.url), ping: "ok" }, - { - headers: streamHeaders, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - } - ) - .catch((err) => { - throw new Error(`Failed to connect with the source ${(err as Error)?.message}`); - }); + // For the "Custom" provider, we must handle masked header values ('******'). + // These are placeholders from the frontend for secrets that haven't been changed. + // We need to replace them with the original, unmasked values from the database. + if ( + provider === LogProvider.Custom && + "headers" in finalCredentials && + Array.isArray(finalCredentials.headers) && + finalCredentials.headers.some((header) => header.value === "******") + ) { + const decryptedOldCredentials = (await decryptLogStreamCredentials({ + encryptedCredentials: logStream.encryptedCredentials, + orgId: logStream.orgId, + kmsService + })) as TCustomProviderCredentials; - const encryptedHeaders = headers - ? crypto.encryption().symmetric().encryptWithRootEncryptionKey(JSON.stringify(headers)) - : undefined; - const updatedLogStream = await auditLogStreamDAL.updateById(id, { - url, - ...(encryptedHeaders - ? { - encryptedHeadersCiphertext: encryptedHeaders.ciphertext, - encryptedHeadersIV: encryptedHeaders.iv, - encryptedHeadersTag: encryptedHeaders.tag, - encryptedHeadersAlgorithm: encryptedHeaders.algorithm, - encryptedHeadersKeyEncoding: encryptedHeaders.encoding + const oldHeadersMap = decryptedOldCredentials.headers.reduce>((acc, header) => { + acc[header.key] = header.value; + return acc; + }, {}); + + const finalHeaders: { key: string; value: string }[] = []; + for (const header of finalCredentials.headers) { + if (header.value === "******") { + const oldValue = oldHeadersMap[header.key]; + if (oldValue) { + finalHeaders.push({ key: header.key, value: oldValue }); } - : {}) + } else { + finalHeaders.push(header); + } + } + finalCredentials.headers = finalHeaders; + } + + const factory = LOG_STREAM_FACTORY_MAP[provider](); + const validatedCredentials = await factory.validateCredentials({ credentials: finalCredentials }); + + const encryptedCredentials = await encryptLogStreamCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService }); - return updatedLogStream; + + const updatedLogStream = await auditLogStreamDAL.updateById(logStreamId, { + encryptedCredentials + }); + + return { ...updatedLogStream, credentials: validatedCredentials } as TAuditLogStream; }; - const deleteById: TAuditLogStreamServiceFactory["deleteById"] = async ({ - id, - actor, - actorId, - actorOrgId, - actorAuthMethod - }) => { - if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" }); + const deleteById = async (logStreamId: string, provider: LogProvider, actor: OrgServiceActor) => { + const logStream = await auditLogStreamDAL.findById(logStreamId); + if (!logStream) throw new NotFoundError({ message: `Audit Log Stream with ID '${logStreamId}' not found` }); - const logStream = await auditLogStreamDAL.findById(id); - if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${id}' not found` }); + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + logStream.orgId + ); - const { orgId } = logStream; - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); - const deletedLogStream = await auditLogStreamDAL.deleteById(id); - return deletedLogStream; + if (logStream.provider !== provider) { + throw new BadRequestError({ + message: `Audit Log Stream with ID '${logStreamId}' is not for provider '${provider}'` + }); + } + + const deletedLogStream = await auditLogStreamDAL.deleteById(logStreamId); + + return decryptLogStream(deletedLogStream, kmsService); }; - const getById: TAuditLogStreamServiceFactory["getById"] = async ({ - id, - actor, - actorId, - actorOrgId, - actorAuthMethod - }) => { - const logStream = await auditLogStreamDAL.findById(id); - if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${id}' not found` }); + const getById = async (logStreamId: string, provider: LogProvider, actor: OrgServiceActor) => { + const logStream = await auditLogStreamDAL.findById(logStreamId); - const { orgId } = logStream; - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${logStreamId}' not found` }); - const headers = - logStream?.encryptedHeadersCiphertext && logStream?.encryptedHeadersIV && logStream?.encryptedHeadersTag - ? (JSON.parse( - crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - tag: logStream.encryptedHeadersTag, - iv: logStream.encryptedHeadersIV, - ciphertext: logStream.encryptedHeadersCiphertext, - keyEncoding: logStream.encryptedHeadersKeyEncoding as SecretKeyEncoding - }) - ) as LogStreamHeaders[]) - : undefined; - - return { ...logStream, headers }; - }; - - const list: TAuditLogStreamServiceFactory["list"] = async ({ actor, actorId, actorOrgId, actorAuthMethod }) => { const { permission } = await permissionService.getOrgPermission( - actor, - actorId, - actorOrgId, - actorAuthMethod, - actorOrgId + actor.type, + actor.id, + logStream.orgId, + actor.authMethod, + actor.orgId ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); - const logStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); - return logStreams; + if (logStream.provider !== provider) { + throw new BadRequestError({ + message: `Audit Log Stream with ID '${logStreamId}' is not for provider '${provider}'` + }); + } + + return decryptLogStream(logStream, kmsService); + }; + + const list = async (actor: OrgServiceActor) => { + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const logStreams = await auditLogStreamDAL.find({ orgId: actor.orgId }); + + return Promise.all(logStreams.map((stream) => decryptLogStream(stream, kmsService))); + }; + + const streamLog = async (orgId: string, auditLog: TAuditLogs) => { + const logStreams = await auditLogStreamDAL.find({ orgId }); + await Promise.allSettled( + logStreams.map(async ({ provider, encryptedCredentials }) => { + const credentials = await decryptLogStreamCredentials({ + encryptedCredentials, + orgId, + kmsService + }); + + const factory = LOG_STREAM_FACTORY_MAP[provider as LogProvider](); + + try { + await factory.streamLog({ + credentials, + auditLog + }); + } catch (error) { + logger.error( + error, + `Failed to stream audit log [auditLogId=${auditLog.id}] [provider=${provider}] [orgId=${orgId}]${error instanceof AxiosError ? `: ${error.message}` : ""}` + ); + throw error; + } + }) + ); }; return { @@ -244,6 +254,8 @@ export const auditLogStreamServiceFactory = ({ updateById, deleteById, getById, - list + list, + listProviderOptions, + streamLog }; }; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts index 4c4a5609e..d0f78f632 100644 --- a/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts @@ -1,48 +1,38 @@ -import { TAuditLogStreams } from "@app/db/schemas"; -import { TOrgPermission } from "@app/lib/types"; +import { TAuditLogs } from "@app/db/schemas"; -export type LogStreamHeaders = { - key: string; - value: string; +import { LogProvider } from "./audit-log-stream-enums"; +import { TCustomProvider, TCustomProviderCredentials } from "./custom/custom-provider-types"; +import { TDatadogProvider, TDatadogProviderCredentials } from "./datadog/datadog-provider-types"; +import { TSplunkProvider, TSplunkProviderCredentials } from "./splunk/splunk-provider-types"; + +export type TAuditLogStream = TDatadogProvider | TSplunkProvider | TCustomProvider; + +export type TAuditLogStreamCredentials = + | TDatadogProviderCredentials + | TSplunkProviderCredentials + | TCustomProviderCredentials; + +export type TCreateAuditLogStreamDTO = { + provider: LogProvider; + credentials: TAuditLogStreamCredentials; }; -export type TCreateAuditLogStreamDTO = Omit & { - url: string; - headers?: LogStreamHeaders[]; +export type TUpdateAuditLogStreamDTO = { + logStreamId: string; + provider: LogProvider; + credentials: TAuditLogStreamCredentials; }; -export type TUpdateAuditLogStreamDTO = Omit & { - id: string; - url?: string; - headers?: LogStreamHeaders[]; -}; +export type TLogStreamFactoryValidateCredentials = (input: { + credentials: C; +}) => Promise; -export type TDeleteAuditLogStreamDTO = Omit & { - id: string; -}; +export type TLogStreamFactoryStreamLog = (input: { + credentials: C; + auditLog: TAuditLogs; +}) => Promise; -export type TListAuditLogStreamDTO = Omit; - -export type TGetDetailsAuditLogStreamDTO = Omit & { - id: string; -}; - -export type TAuditLogStreamServiceFactory = { - create: (arg: TCreateAuditLogStreamDTO) => Promise; - updateById: (arg: TUpdateAuditLogStreamDTO) => Promise; - deleteById: (arg: TDeleteAuditLogStreamDTO) => Promise; - getById: (arg: TGetDetailsAuditLogStreamDTO) => Promise<{ - headers: LogStreamHeaders[] | undefined; - orgId: string; - url: string; - id: string; - createdAt: Date; - updatedAt: Date; - encryptedHeadersCiphertext?: string | null | undefined; - encryptedHeadersIV?: string | null | undefined; - encryptedHeadersTag?: string | null | undefined; - encryptedHeadersAlgorithm?: string | null | undefined; - encryptedHeadersKeyEncoding?: string | null | undefined; - }>; - list: (arg: TListAuditLogStreamDTO) => Promise; +export type TLogStreamFactory = () => { + validateCredentials: TLogStreamFactoryValidateCredentials; + streamLog: TLogStreamFactoryStreamLog; }; diff --git a/backend/src/ee/services/audit-log-stream/custom/custom-provider-factory.ts b/backend/src/ee/services/audit-log-stream/custom/custom-provider-factory.ts new file mode 100644 index 000000000..6e397638c --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/custom/custom-provider-factory.ts @@ -0,0 +1,67 @@ +import { RawAxiosRequestHeaders } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { AUDIT_LOG_STREAM_TIMEOUT } from "../../audit-log/audit-log-queue"; +import { TLogStreamFactoryStreamLog, TLogStreamFactoryValidateCredentials } from "../audit-log-stream-types"; +import { TCustomProviderCredentials } from "./custom-provider-types"; + +export const CustomProviderFactory = () => { + const validateCredentials: TLogStreamFactoryValidateCredentials = async ({ + credentials + }) => { + const { url, headers } = credentials; + + await blockLocalAndPrivateIpAddresses(url); + + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + if (headers.length) { + headers.forEach(({ key, value }) => { + streamHeaders[key] = value; + }); + } + + await request + .post( + url, + { ping: "ok" }, + { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + } + ) + .catch((err) => { + throw new BadRequestError({ message: `Failed to connect with upstream source: ${(err as Error)?.message}` }); + }); + + return credentials; + }; + + const streamLog: TLogStreamFactoryStreamLog = async ({ credentials, auditLog }) => { + const { url, headers } = credentials; + + await blockLocalAndPrivateIpAddresses(url); + + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + + if (headers.length) { + headers.forEach(({ key, value }) => { + streamHeaders[key] = value; + }); + } + + await request.post(url, auditLog, { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + }; + + return { + validateCredentials, + streamLog + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/custom/custom-provider-fns.ts b/backend/src/ee/services/audit-log-stream/custom/custom-provider-fns.ts new file mode 100644 index 000000000..27b8bbf72 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/custom/custom-provider-fns.ts @@ -0,0 +1,8 @@ +import { LogProvider } from "../audit-log-stream-enums"; + +export const getCustomProviderListItem = () => { + return { + name: "Custom" as const, + provider: LogProvider.Custom as const + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/custom/custom-provider-schemas.ts b/backend/src/ee/services/audit-log-stream/custom/custom-provider-schemas.ts new file mode 100644 index 000000000..d960d0fe9 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/custom/custom-provider-schemas.ts @@ -0,0 +1,50 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { LogProvider } from "../audit-log-stream-enums"; +import { BaseProviderSchema } from "../audit-log-stream-schemas"; + +export const CustomProviderCredentialsSchema = z.object({ + url: z.string().url().trim().min(1).max(255), + headers: z + .object({ + key: z + .string() + .min(1) + .refine((val) => new RE2(/^[^\n\r]+$/).test(val), "Header keys cannot contain newlines or carriage returns"), + value: z + .string() + .min(1) + .refine((val) => new RE2(/^[^\n\r]+$/).test(val), "Header values cannot contain newlines or carriage returns") + }) + .array() +}); + +const BaseCustomProviderSchema = BaseProviderSchema.extend({ provider: z.literal(LogProvider.Custom) }); + +export const CustomProviderSchema = BaseCustomProviderSchema.extend({ + credentials: CustomProviderCredentialsSchema +}); + +export const SanitizedCustomProviderSchema = BaseCustomProviderSchema.extend({ + credentials: z.object({ + url: CustomProviderCredentialsSchema.shape.url, + // Return header keys and a redacted value + headers: CustomProviderCredentialsSchema.shape.headers.transform((headers) => + headers.map((header) => ({ ...header, value: "******" })) + ) + }) +}); + +export const CustomProviderListItemSchema = z.object({ + name: z.literal("Custom"), + provider: z.literal(LogProvider.Custom) +}); + +export const CreateCustomProviderLogStreamSchema = z.object({ + credentials: CustomProviderCredentialsSchema +}); + +export const UpdateCustomProviderLogStreamSchema = z.object({ + credentials: CustomProviderCredentialsSchema +}); diff --git a/backend/src/ee/services/audit-log-stream/custom/custom-provider-types.ts b/backend/src/ee/services/audit-log-stream/custom/custom-provider-types.ts new file mode 100644 index 000000000..9b2de8347 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/custom/custom-provider-types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { CustomProviderCredentialsSchema, CustomProviderSchema } from "./custom-provider-schemas"; + +export type TCustomProvider = z.infer; + +export type TCustomProviderCredentials = z.infer; diff --git a/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-factory.ts b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-factory.ts new file mode 100644 index 000000000..ec55784da --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-factory.ts @@ -0,0 +1,67 @@ +import { RawAxiosRequestHeaders } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { AUDIT_LOG_STREAM_TIMEOUT } from "../../audit-log/audit-log-queue"; +import { TLogStreamFactoryStreamLog, TLogStreamFactoryValidateCredentials } from "../audit-log-stream-types"; +import { TDatadogProviderCredentials } from "./datadog-provider-types"; + +function createPayload(event: Record) { + const appCfg = getConfig(); + + const ddtags = [`env:${appCfg.NODE_ENV || "unknown"}`].join(","); + + return { + ...event, + hostname: new URL(appCfg.SITE_URL || "http://infisical").hostname, + ddsource: "infisical", + service: "infisical", + ddtags + }; +} + +export const DatadogProviderFactory = () => { + const validateCredentials: TLogStreamFactoryValidateCredentials = async ({ + credentials + }) => { + const { url, token } = credentials; + + await blockLocalAndPrivateIpAddresses(url); + + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json", "DD-API-KEY": token }; + + await request + .post(url, createPayload({ ping: "ok" }), { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }) + .catch((err) => { + throw new BadRequestError({ message: `Failed to connect with Datadog: ${(err as Error)?.message}` }); + }); + + return credentials; + }; + + const streamLog: TLogStreamFactoryStreamLog = async ({ credentials, auditLog }) => { + const { url, token } = credentials; + + await blockLocalAndPrivateIpAddresses(url); + + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json", "DD-API-KEY": token }; + + await request.post(url, createPayload(auditLog), { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + }; + + return { + validateCredentials, + streamLog + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-fns.ts b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-fns.ts new file mode 100644 index 000000000..ec68fdb39 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-fns.ts @@ -0,0 +1,8 @@ +import { LogProvider } from "../audit-log-stream-enums"; + +export const getDatadogProviderListItem = () => { + return { + name: "Datadog" as const, + provider: LogProvider.Datadog as const + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-schemas.ts b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-schemas.ts new file mode 100644 index 000000000..0445d79f2 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-schemas.ts @@ -0,0 +1,38 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { LogProvider } from "../audit-log-stream-enums"; +import { BaseProviderSchema } from "../audit-log-stream-schemas"; + +export const DatadogProviderCredentialsSchema = z.object({ + url: z.string().url().trim().min(1).max(255), + token: z + .string() + .trim() + .refine((val) => new RE2(/^[a-fA-F0-9]{32}$/).test(val), "Invalid Datadog API key format") +}); + +const BaseDatadogProviderSchema = BaseProviderSchema.extend({ provider: z.literal(LogProvider.Datadog) }); + +export const DatadogProviderSchema = BaseDatadogProviderSchema.extend({ + credentials: DatadogProviderCredentialsSchema +}); + +export const SanitizedDatadogProviderSchema = BaseDatadogProviderSchema.extend({ + credentials: DatadogProviderCredentialsSchema.pick({ + url: true + }) +}); + +export const DatadogProviderListItemSchema = z.object({ + name: z.literal("Datadog"), + provider: z.literal(LogProvider.Datadog) +}); + +export const CreateDatadogProviderLogStreamSchema = z.object({ + credentials: DatadogProviderCredentialsSchema +}); + +export const UpdateDatadogProviderLogStreamSchema = z.object({ + credentials: DatadogProviderCredentialsSchema +}); diff --git a/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-types.ts b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-types.ts new file mode 100644 index 000000000..e7f6a1f28 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/datadog/datadog-provider-types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { DatadogProviderCredentialsSchema, DatadogProviderSchema } from "./datadog-provider-schemas"; + +export type TDatadogProvider = z.infer; + +export type TDatadogProviderCredentials = z.infer; diff --git a/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-factory.ts b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-factory.ts new file mode 100644 index 000000000..72fc90ce2 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-factory.ts @@ -0,0 +1,84 @@ +import { RawAxiosRequestHeaders } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; + +import { AUDIT_LOG_STREAM_TIMEOUT } from "../../audit-log/audit-log-queue"; +import { TLogStreamFactoryStreamLog, TLogStreamFactoryValidateCredentials } from "../audit-log-stream-types"; +import { TSplunkProviderCredentials } from "./splunk-provider-types"; + +function createPayload(event: Record) { + const appCfg = getConfig(); + + return { + time: Math.floor(Date.now() / 1000), + ...(appCfg.SITE_URL && { host: new URL(appCfg.SITE_URL).host }), + source: "infisical", + sourcetype: "_json", + event + }; +} + +async function createSplunkUrl(hostname: string) { + let parsedHostname: string; + try { + parsedHostname = new URL(`https://${hostname}`).hostname; + } catch (error) { + throw new BadRequestError({ message: `Invalid Splunk hostname provided: ${(error as Error).message}` }); + } + + await blockLocalAndPrivateIpAddresses(`https://${parsedHostname}`); + + return `https://${parsedHostname}:8088/services/collector/event`; +} + +export const SplunkProviderFactory = () => { + const validateCredentials: TLogStreamFactoryValidateCredentials = async ({ + credentials + }) => { + const { hostname, token } = credentials; + + const url = await createSplunkUrl(hostname); + + const streamHeaders: RawAxiosRequestHeaders = { + "Content-Type": "application/json", + Authorization: `Splunk ${token}` + }; + + await request + .post(url, createPayload({ ping: "ok" }), { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }) + .catch((err) => { + throw new BadRequestError({ message: `Failed to connect with Splunk: ${(err as Error)?.message}` }); + }); + + return credentials; + }; + + const streamLog: TLogStreamFactoryStreamLog = async ({ credentials, auditLog }) => { + const { hostname, token } = credentials; + + const url = await createSplunkUrl(hostname); + + const streamHeaders: RawAxiosRequestHeaders = { + "Content-Type": "application/json", + Authorization: `Splunk ${token}` + }; + + await request.post(url, createPayload(auditLog), { + headers: streamHeaders, + timeout: AUDIT_LOG_STREAM_TIMEOUT, + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + }; + + return { + validateCredentials, + streamLog + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-fns.ts b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-fns.ts new file mode 100644 index 000000000..e2ea2e316 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-fns.ts @@ -0,0 +1,8 @@ +import { LogProvider } from "../audit-log-stream-enums"; + +export const getSplunkProviderListItem = () => { + return { + name: "Splunk" as const, + provider: LogProvider.Splunk as const + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-schemas.ts b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-schemas.ts new file mode 100644 index 000000000..ab28db616 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-schemas.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +import { LogProvider } from "../audit-log-stream-enums"; +import { BaseProviderSchema } from "../audit-log-stream-schemas"; + +export const SplunkProviderCredentialsSchema = z.object({ + hostname: z + .string() + .trim() + .min(1) + .max(255) + .superRefine((val, ctx) => { + if (val.includes("://")) { + ctx.addIssue({ + code: "custom", + message: "Hostname should not include protocol" + }); + return; + } + + try { + const url = new URL(`https://${val}`); + if (url.hostname !== val) { + ctx.addIssue({ + code: "custom", + message: "Must be a valid hostname without port or path" + }); + } + } catch { + ctx.addIssue({ code: "custom", message: "Invalid hostname" }); + } + }), + token: z.string().uuid().trim().min(1) +}); + +const BaseSplunkProviderSchema = BaseProviderSchema.extend({ provider: z.literal(LogProvider.Splunk) }); + +export const SplunkProviderSchema = BaseSplunkProviderSchema.extend({ + credentials: SplunkProviderCredentialsSchema +}); + +export const SanitizedSplunkProviderSchema = BaseSplunkProviderSchema.extend({ + credentials: SplunkProviderCredentialsSchema.pick({ + hostname: true + }) +}); + +export const SplunkProviderListItemSchema = z.object({ + name: z.literal("Splunk"), + provider: z.literal(LogProvider.Splunk) +}); + +export const CreateSplunkProviderLogStreamSchema = z.object({ + credentials: SplunkProviderCredentialsSchema +}); + +export const UpdateSplunkProviderLogStreamSchema = z.object({ + credentials: SplunkProviderCredentialsSchema +}); diff --git a/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-types.ts b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-types.ts new file mode 100644 index 000000000..11f7c8fcb --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/splunk/splunk-provider-types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { SplunkProviderCredentialsSchema, SplunkProviderSchema } from "./splunk-provider-schemas"; + +export type TSplunkProvider = z.infer; + +export type TSplunkProviderCredentials = z.infer; 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 2df779795..7a986a2d3 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -1,8 +1,10 @@ // weird commonjs-related error in the CI requires us to do the import like this import knex from "knex"; +import { v4 as uuidv4 } from "uuid"; import { TDbClient } from "@app/db"; import { TableName, TAuditLogs } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; import { DatabaseError, GatewayTimeoutError } from "@app/lib/errors"; import { ormify, selectAllTableCols, TOrmify } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; @@ -12,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; @@ -39,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); @@ -149,10 +155,7 @@ export const auditLogDALFactory = (db: TDbClient) => { }; // delete all audit log that have expired - const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async (tx) => { - 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; @@ -161,21 +164,27 @@ export const auditLogDALFactory = (db: TDbClient) => { logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); do { try { - const findExpiredLogSubQuery = (tx || db)(TableName.AuditLog) - .where("expiresAt", "<", today) - .where("createdAt", "<", today) // to use audit log partition - .orderBy(`${TableName.AuditLog}.createdAt`, "desc") - .select("id") - .limit(AUDIT_LOG_PRUNE_BATCH_SIZE); - // eslint-disable-next-line no-await-in-loop - deletedAuditLogIds = await (tx || db)(TableName.AuditLog) - .whereIn("id", findExpiredLogSubQuery) - .del() - .returning("id"); + deletedAuditLogIds = await db.transaction(async (trx) => { + await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); + + const findExpiredLogSubQuery = trx(TableName.AuditLog) + .where("expiresAt", "<", today) + .where("createdAt", "<", today) // to use audit log partition + .orderBy(`${TableName.AuditLog}.createdAt`, "desc") + .select("id") + .limit(AUDIT_LOG_PRUNE_BATCH_SIZE); + + // eslint-disable-next-line no-await-in-loop + const results = await trx(TableName.AuditLog).whereIn("id", findExpiredLogSubQuery).del().returning("id"); + + 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 @@ -188,5 +197,20 @@ export const auditLogDALFactory = (db: TDbClient) => { logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`); }; - return { ...auditLogOrm, pruneAuditLog, find }; + const create: TAuditLogDALFactory["create"] = async (tx) => { + const config = getConfig(); + + if (config.DISABLE_AUDIT_LOG_STORAGE) { + return { + ...tx, + id: uuidv4(), + createdAt: new Date(), + updatedAt: new Date() + }; + } + + return auditLogOrm.create(tx); + }; + + return { ...auditLogOrm, create, pruneAuditLog, find }; }; 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 be58c4c43..6b286c2ec 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -1,23 +1,14 @@ -import { AxiosError, RawAxiosRequestHeaders } from "axios"; - -import { SecretKeyEncoding } from "@app/db/schemas"; -import { getConfig } from "@app/lib/config/env"; -import { request } from "@app/lib/config/request"; -import { crypto } from "@app/lib/crypto/cryptography"; -import { logger } from "@app/lib/logger"; +import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TProjectDALFactory } from "@app/services/project/project-dal"; -import { TAuditLogStreamDALFactory } from "../audit-log-stream/audit-log-stream-dal"; -import { providerSpecificPayload } from "../audit-log-stream/audit-log-stream-fns"; -import { LogStreamHeaders } from "../audit-log-stream/audit-log-stream-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { TAuditLogDALFactory } from "./audit-log-dal"; import { TCreateAuditLogDTO } from "./audit-log-types"; type TAuditLogQueueServiceFactoryDep = { auditLogDAL: TAuditLogDALFactory; - auditLogStreamDAL: Pick; + auditLogStreamService: Pick; queueService: TQueueServiceFactory; projectDAL: Pick; licenseService: Pick; @@ -36,133 +27,16 @@ export const auditLogQueueServiceFactory = async ({ queueService, projectDAL, licenseService, - auditLogStreamDAL + auditLogStreamService }: TAuditLogQueueServiceFactoryDep): Promise => { - const appCfg = getConfig(); - const pushToLog = async (data: TCreateAuditLogDTO) => { - if (appCfg.USE_PG_QUEUE && appCfg.SHOULD_INIT_PG_QUEUE) { - await queueService.queuePg(QueueJobs.AuditLog, data, { - retryLimit: 10, - retryBackoff: true - }); - } else { - await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { - removeOnFail: { - count: 3 - }, - removeOnComplete: true - }); - } - }; - - if (appCfg.SHOULD_INIT_PG_QUEUE) { - await queueService.startPg( - QueueJobs.AuditLog, - async ([job]) => { - const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; - let { orgId } = job.data; - const MS_IN_DAY = 24 * 60 * 60 * 1000; - let project; - - if (!orgId) { - // it will never be undefined for both org and project id - // TODO(akhilmhdh): use caching here in dal to avoid db calls - project = await projectDAL.findById(projectId as string); - orgId = project.orgId; - } - - const plan = await licenseService.getPlan(orgId); - if (plan.auditLogsRetentionDays === 0) { - // skip inserting if audit log retention is 0 meaning its not supported - return; - } - - // For project actions, set TTL to project-level audit log retention config - // This condition ensures that the plan's audit log retention days cannot be bypassed - const ttlInDays = - project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays - ? project.auditLogsRetentionDays - : plan.auditLogsRetentionDays; - - const ttl = ttlInDays * MS_IN_DAY; - - const auditLog = await auditLogDAL.create({ - actor: actor.type, - actorMetadata: actor.metadata, - userAgent, - projectId, - projectName: project?.name, - ipAddress, - orgId, - eventType: event.type, - expiresAt: new Date(Date.now() + ttl), - eventMetadata: event.metadata, - userAgentType - }); - - const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; - await Promise.allSettled( - logStreams.map( - async ({ - url, - encryptedHeadersTag, - encryptedHeadersIV, - encryptedHeadersKeyEncoding, - encryptedHeadersCiphertext - }) => { - const streamHeaders = - encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag - ? (JSON.parse( - crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, - iv: encryptedHeadersIV, - tag: encryptedHeadersTag, - ciphertext: encryptedHeadersCiphertext - }) - ) as LogStreamHeaders[]) - : []; - - const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - - if (streamHeaders.length) - streamHeaders.forEach(({ key, value }) => { - headers[key] = value; - }); - - try { - const response = await request.post( - url, - { ...providerSpecificPayload(url), ...auditLog }, - { - headers, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - } - ); - return response; - } catch (error) { - logger.error( - `Failed to stream audit log [url=${url}] for org [orgId=${orgId}] [error=${(error as AxiosError).message}]` - ); - return error; - } - } - ) - ); + await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { + removeOnFail: { + count: 3 }, - { - batchSize: 1, - workerCount: 30, - pollingIntervalSeconds: 0.5 - } - ); - } + removeOnComplete: true + }); + }; queueService.start(QueueName.AuditLog, async (job) => { const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; @@ -178,88 +52,34 @@ export const auditLogQueueServiceFactory = async ({ } const plan = await licenseService.getPlan(orgId); - if (plan.auditLogsRetentionDays === 0) { - // skip inserting if audit log retention is 0 meaning its not supported - return; + + // skip inserting if audit log retention is 0 meaning its not supported + if (plan.auditLogsRetentionDays !== 0) { + // For project actions, set TTL to project-level audit log retention config + // This condition ensures that the plan's audit log retention days cannot be bypassed + const ttlInDays = + project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays + ? project.auditLogsRetentionDays + : plan.auditLogsRetentionDays; + + const ttl = ttlInDays * MS_IN_DAY; + + const auditLog = await auditLogDAL.create({ + actor: actor.type, + actorMetadata: actor.metadata, + userAgent, + projectId, + projectName: project?.name, + ipAddress, + orgId, + eventType: event.type, + expiresAt: new Date(Date.now() + ttl), + eventMetadata: event.metadata, + userAgentType + }); + + await auditLogStreamService.streamLog(orgId, auditLog); } - - // For project actions, set TTL to project-level audit log retention config - // This condition ensures that the plan's audit log retention days cannot be bypassed - const ttlInDays = - project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays - ? project.auditLogsRetentionDays - : plan.auditLogsRetentionDays; - - const ttl = ttlInDays * MS_IN_DAY; - - const auditLog = await auditLogDAL.create({ - actor: actor.type, - actorMetadata: actor.metadata, - userAgent, - projectId, - projectName: project?.name, - ipAddress, - orgId, - eventType: event.type, - expiresAt: new Date(Date.now() + ttl), - eventMetadata: event.metadata, - userAgentType - }); - - const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; - await Promise.allSettled( - logStreams.map( - async ({ - url, - encryptedHeadersTag, - encryptedHeadersIV, - encryptedHeadersKeyEncoding, - encryptedHeadersCiphertext - }) => { - const streamHeaders = - encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag - ? (JSON.parse( - crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, - iv: encryptedHeadersIV, - tag: encryptedHeadersTag, - ciphertext: encryptedHeadersCiphertext - }) - ) as LogStreamHeaders[]) - : []; - - const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - - if (streamHeaders.length) - streamHeaders.forEach(({ key, value }) => { - headers[key] = value; - }); - - try { - const response = await request.post( - url, - { ...providerSpecificPayload(url), ...auditLog }, - { - headers, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - } - ); - return response; - } catch (error) { - logger.error( - `Failed to stream audit log [url=${url}] for org [orgId=${orgId}] [error=${(error as AxiosError).message}]` - ); - return error; - } - } - ) - ); }); return { diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 06186d54b..ece5edaf9 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -6,9 +6,9 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { ActorType } from "@app/services/auth/auth-type"; -import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { OrgPermissionAuditLogsActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; -import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { ProjectPermissionAuditLogsActions, ProjectPermissionSub } from "../permission/project-permission"; import { TAuditLogDALFactory } from "./audit-log-dal"; import { TAuditLogQueueServiceFactory } from "./audit-log-queue"; import { EventType, TAuditLogServiceFactory } from "./audit-log-types"; @@ -41,7 +41,10 @@ export const auditLogServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionAuditLogsActions.Read, + ProjectPermissionSub.AuditLogs + ); } else { // Organization-wide logs const { permission } = await permissionService.getOrgPermission( @@ -52,7 +55,10 @@ export const auditLogServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAuditLogsActions.Read, + OrgPermissionSubjects.AuditLogs + ); } // If project ID is not provided, then we need to return all the audit logs for the organization itself. diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 818bb1f99..10c84cc4a 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -161,6 +161,9 @@ export enum EventType { CREATE_IDENTITY = "create-identity", UPDATE_IDENTITY = "update-identity", DELETE_IDENTITY = "delete-identity", + MACHINE_IDENTITY_AUTH_TEMPLATE_CREATE = "machine-identity-auth-template-create", + MACHINE_IDENTITY_AUTH_TEMPLATE_UPDATE = "machine-identity-auth-template-update", + MACHINE_IDENTITY_AUTH_TEMPLATE_DELETE = "machine-identity-auth-template-delete", LOGIN_IDENTITY_UNIVERSAL_AUTH = "login-identity-universal-auth", ADD_IDENTITY_UNIVERSAL_AUTH = "add-identity-universal-auth", UPDATE_IDENTITY_UNIVERSAL_AUTH = "update-identity-universal-auth", @@ -195,6 +198,7 @@ export enum EventType { CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", + CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", @@ -278,6 +282,7 @@ export enum EventType { UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template", DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template", GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template", + GET_AZURE_AD_TEMPLATES = "get-azure-ad-templates", GET_SSH_HOST = "get-ssh-host", CREATE_SSH_HOST = "create-ssh-host", UPDATE_SSH_HOST = "update-ssh-host", @@ -830,6 +835,30 @@ interface LoginIdentityUniversalAuthEvent { }; } +interface MachineIdentityAuthTemplateCreateEvent { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_CREATE; + metadata: { + templateId: string; + name: string; + }; +} + +interface MachineIdentityAuthTemplateUpdateEvent { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_UPDATE; + metadata: { + templateId: string; + name: string; + }; +} + +interface MachineIdentityAuthTemplateDeleteEvent { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_DELETE; + metadata: { + templateId: string; + name: string; + }; +} + interface AddIdentityUniversalAuthEvent { type: EventType.ADD_IDENTITY_UNIVERSAL_AUTH; metadata: { @@ -839,6 +868,10 @@ interface AddIdentityUniversalAuthEvent { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: Array; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; } @@ -851,6 +884,10 @@ interface UpdateIdentityUniversalAuthEvent { accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; accessTokenTrustedIps?: Array; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; }; } @@ -1010,6 +1047,13 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } +interface ClearIdentityUniversalAuthLockoutsEvent { + type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS; + metadata: { + identityId: string; + }; +} + interface LoginIdentityGcpAuthEvent { type: EventType.LOGIN_IDENTITY_GCP_AUTH; metadata: { @@ -1325,6 +1369,7 @@ interface AddIdentityLdapAuthEvent { accessTokenTrustedIps?: Array; allowedFields?: TAllowedFields[]; url: string; + templateId?: string | null; }; } @@ -1338,6 +1383,7 @@ interface UpdateIdentityLdapAuthEvent { accessTokenTrustedIps?: Array; allowedFields?: TAllowedFields[]; url?: string; + templateId?: string | null; }; } @@ -2468,6 +2514,14 @@ interface CreateCertificateTemplateEstConfig { }; } +interface GetAzureAdCsTemplatesEvent { + type: EventType.GET_AZURE_AD_TEMPLATES; + metadata: { + caId: string; + amount: number; + }; +} + interface UpdateCertificateTemplateEstConfig { type: EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG; metadata: { @@ -3439,6 +3493,9 @@ export type Event = | UpdateIdentityEvent | DeleteIdentityEvent | LoginIdentityUniversalAuthEvent + | MachineIdentityAuthTemplateCreateEvent + | MachineIdentityAuthTemplateUpdateEvent + | MachineIdentityAuthTemplateDeleteEvent | AddIdentityUniversalAuthEvent | UpdateIdentityUniversalAuthEvent | DeleteIdentityUniversalAuthEvent @@ -3459,6 +3516,7 @@ export type Event = | GetIdentityUniversalAuthClientSecretsEvent | GetIdentityUniversalAuthClientSecretByIdEvent | RevokeIdentityUniversalAuthClientSecretEvent + | ClearIdentityUniversalAuthLockoutsEvent | LoginIdentityGcpAuthEvent | AddIdentityGcpAuthEvent | DeleteIdentityGcpAuthEvent @@ -3604,6 +3662,7 @@ export type Event = | CreateCertificateTemplateEstConfig | UpdateCertificateTemplateEstConfig | GetCertificateTemplateEstConfig + | GetAzureAdCsTemplatesEvent | AttemptCreateSlackIntegration | AttemptReinstallSlackIntegration | UpdateSlackIntegration 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/aws-elasticache.ts b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts index b5d94112a..4b86af323 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts @@ -15,6 +15,7 @@ import { z } from "zod"; import { CustomAWSHasher } from "@app/lib/aws/hashing"; import { crypto } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { DynamicSecretAwsElastiCacheSchema, TDynamicProviderFns } from "./models"; @@ -170,14 +171,29 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { }; const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - await ElastiCacheUserManager( - { - accessKeyId: providerInputs.accessKeyId, - secretAccessKey: providerInputs.secretAccessKey - }, - providerInputs.region - ).verifyCredentials(providerInputs.clusterName); - return true; + try { + await ElastiCacheUserManager( + { + accessKeyId: providerInputs.accessKeyId, + secretAccessKey: providerInputs.secretAccessKey + }, + providerInputs.region + ).verifyCredentials(providerInputs.clusterName); + return true; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [ + providerInputs.accessKeyId, + providerInputs.secretAccessKey, + providerInputs.clusterName, + providerInputs.region + ] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const create = async (data: { @@ -206,21 +222,37 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { const parsedStatement = CreateElastiCacheUserSchema.parse(JSON.parse(creationStatement)); - await ElastiCacheUserManager( - { - accessKeyId: providerInputs.accessKeyId, - secretAccessKey: providerInputs.secretAccessKey - }, - providerInputs.region - ).createUser(parsedStatement, providerInputs.clusterName); + try { + await ElastiCacheUserManager( + { + accessKeyId: providerInputs.accessKeyId, + secretAccessKey: providerInputs.secretAccessKey + }, + providerInputs.region + ).createUser(parsedStatement, providerInputs.clusterName); - return { - entityId: leaseUsername, - data: { - DB_USERNAME: leaseUsername, - DB_PASSWORD: leasePassword - } - }; + return { + entityId: leaseUsername, + data: { + DB_USERNAME: leaseUsername, + DB_PASSWORD: leasePassword + } + }; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [ + leaseUsername, + leasePassword, + providerInputs.accessKeyId, + providerInputs.secretAccessKey, + providerInputs.clusterName + ] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); + } }; const revoke = async (inputs: unknown, entityId: string) => { @@ -229,15 +261,25 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username: entityId }); const parsedStatement = DeleteElasticCacheUserSchema.parse(JSON.parse(revokeStatement)); - await ElastiCacheUserManager( - { - accessKeyId: providerInputs.accessKeyId, - secretAccessKey: providerInputs.secretAccessKey - }, - providerInputs.region - ).deleteUser(parsedStatement); + try { + await ElastiCacheUserManager( + { + accessKeyId: providerInputs.accessKeyId, + secretAccessKey: providerInputs.secretAccessKey + }, + providerInputs.region + ).deleteUser(parsedStatement); - return { entityId }; + return { entityId }; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [entityId, providerInputs.accessKeyId, providerInputs.secretAccessKey, providerInputs.clusterName] + }); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); + } }; const renew = async (_inputs: unknown, entityId: string) => { diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts index 7bb11b9ae..d4fceb674 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -23,6 +23,7 @@ import { CustomAWSHasher } from "@app/lib/aws/hashing"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { AwsIamAuthType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; @@ -118,22 +119,39 @@ export const AwsIamProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown, { projectId }: { projectId: string }) => { const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs, projectId); - const isConnected = await client - .send(new GetUserCommand({})) - .then(() => true) - .catch((err) => { - const message = (err as Error)?.message; - if ( - (providerInputs.method === AwsIamAuthType.AssumeRole || providerInputs.method === AwsIamAuthType.IRSA) && - // assume role will throw an error asking to provider username, but if so this has access in aws correctly - message.includes("Must specify userName when calling with non-User credentials") - ) { - return true; - } - throw err; + try { + const client = await $getClient(providerInputs, projectId); + const isConnected = await client + .send(new GetUserCommand({})) + .then(() => true) + .catch((err) => { + const message = (err as Error)?.message; + if ( + (providerInputs.method === AwsIamAuthType.AssumeRole || providerInputs.method === AwsIamAuthType.IRSA) && + // assume role will throw an error asking to provider username, but if so this has access in aws correctly + message.includes("Must specify userName when calling with non-User credentials") + ) { + return true; + } + throw err; + }); + return isConnected; + } catch (err) { + const sensitiveTokens = []; + if (providerInputs.method === AwsIamAuthType.AccessKey) { + sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey); + } + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + sensitiveTokens.push(providerInputs.roleArn); + } + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: sensitiveTokens }); - return isConnected; + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const create = async (data: { @@ -162,62 +180,81 @@ export const AwsIamProvider = (): TDynamicProviderFns => { awsTags.push(...additionalTags); } - const createUserRes = await client.send( - new CreateUserCommand({ - Path: awsPath, - PermissionsBoundary: permissionBoundaryPolicyArn || undefined, - Tags: awsTags, - UserName: username - }) - ); - - if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" }); - if (userGroups) { - await Promise.all( - userGroups - .split(",") - .filter(Boolean) - .map((group) => - client.send(new AddUserToGroupCommand({ UserName: createUserRes?.User?.UserName, GroupName: group })) - ) - ); - } - if (policyArns) { - await Promise.all( - policyArns - .split(",") - .filter(Boolean) - .map((policyArn) => - client.send(new AttachUserPolicyCommand({ UserName: createUserRes?.User?.UserName, PolicyArn: policyArn })) - ) - ); - } - if (policyDocument) { - await client.send( - new PutUserPolicyCommand({ - UserName: createUserRes.User.UserName, - PolicyName: `infisical-dynamic-policy-${alphaNumericNanoId(4)}`, - PolicyDocument: policyDocument + try { + const createUserRes = await client.send( + new CreateUserCommand({ + Path: awsPath, + PermissionsBoundary: permissionBoundaryPolicyArn || undefined, + Tags: awsTags, + UserName: username }) ); - } - const createAccessKeyRes = await client.send( - new CreateAccessKeyCommand({ - UserName: createUserRes.User.UserName - }) - ); - if (!createAccessKeyRes.AccessKey) - throw new BadRequestError({ message: "Failed to create AWS IAM User access key" }); - - return { - entityId: username, - data: { - ACCESS_KEY: createAccessKeyRes.AccessKey.AccessKeyId, - SECRET_ACCESS_KEY: createAccessKeyRes.AccessKey.SecretAccessKey, - USERNAME: username + if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" }); + if (userGroups) { + await Promise.all( + userGroups + .split(",") + .filter(Boolean) + .map((group) => + client.send(new AddUserToGroupCommand({ UserName: createUserRes?.User?.UserName, GroupName: group })) + ) + ); } - }; + if (policyArns) { + await Promise.all( + policyArns + .split(",") + .filter(Boolean) + .map((policyArn) => + client.send( + new AttachUserPolicyCommand({ UserName: createUserRes?.User?.UserName, PolicyArn: policyArn }) + ) + ) + ); + } + if (policyDocument) { + await client.send( + new PutUserPolicyCommand({ + UserName: createUserRes.User.UserName, + PolicyName: `infisical-dynamic-policy-${alphaNumericNanoId(4)}`, + PolicyDocument: policyDocument + }) + ); + } + + const createAccessKeyRes = await client.send( + new CreateAccessKeyCommand({ + UserName: createUserRes.User.UserName + }) + ); + if (!createAccessKeyRes.AccessKey) + throw new BadRequestError({ message: "Failed to create AWS IAM User access key" }); + + return { + entityId: username, + data: { + ACCESS_KEY: createAccessKeyRes.AccessKey.AccessKeyId, + SECRET_ACCESS_KEY: createAccessKeyRes.AccessKey.SecretAccessKey, + USERNAME: username + } + }; + } catch (err) { + const sensitiveTokens = [username]; + if (providerInputs.method === AwsIamAuthType.AccessKey) { + sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey); + } + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + sensitiveTokens.push(providerInputs.roleArn); + } + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: sensitiveTokens + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); + } }; const revoke = async (inputs: unknown, entityId: string, metadata: { projectId: string }) => { @@ -278,8 +315,25 @@ export const AwsIamProvider = (): TDynamicProviderFns => { ) ); - await client.send(new DeleteUserCommand({ UserName: username })); - return { entityId: username }; + try { + await client.send(new DeleteUserCommand({ UserName: username })); + return { entityId: username }; + } catch (err) { + const sensitiveTokens = [username]; + if (providerInputs.method === AwsIamAuthType.AccessKey) { + sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey); + } + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + sensitiveTokens.push(providerInputs.roleArn); + } + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: sensitiveTokens + }); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); + } }; const renew = async (_inputs: unknown, entityId: string) => { diff --git a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts index 4b2232bc8..98386c4d3 100644 --- a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts +++ b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts @@ -2,6 +2,7 @@ import axios from "axios"; import { customAlphabet } from "nanoid"; import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { AzureEntraIDSchema, TDynamicProviderFns } from "./models"; @@ -51,45 +52,82 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const data = await $getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); - return data.success; + try { + const data = await $getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); + return data.success; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.clientSecret, providerInputs.applicationId, providerInputs.tenantId] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const create = async ({ inputs }: { inputs: unknown }) => { const providerInputs = await validateProviderInputs(inputs); - const data = await $getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); - if (!data.success) { - throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" }); - } const password = generatePassword(); - - const response = await axios.patch( - `${MSFT_GRAPH_API_URL}/users/${providerInputs.userId}`, - { - passwordProfile: { - forceChangePasswordNextSignIn: false, - password - } - }, - { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${data.token}` - } + try { + const data = await $getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); + if (!data.success) { + throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" }); } - ); - if (response.status !== 204) { - throw new BadRequestError({ message: "Failed to update password" }); - } - return { entityId: providerInputs.userId, data: { email: providerInputs.email, password } }; + const response = await axios.patch( + `${MSFT_GRAPH_API_URL}/users/${providerInputs.userId}`, + { + passwordProfile: { + forceChangePasswordNextSignIn: false, + password + } + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${data.token}` + } + } + ); + if (response.status !== 204) { + throw new BadRequestError({ message: "Failed to update password" }); + } + + return { entityId: providerInputs.userId, data: { email: providerInputs.email, password } }; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [ + providerInputs.clientSecret, + providerInputs.applicationId, + providerInputs.userId, + providerInputs.email, + password + ] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); + } }; const revoke = async (inputs: unknown, entityId: string) => { - // Creates a new password - await create({ inputs }); - return { entityId }; + const providerInputs = await validateProviderInputs(inputs); + try { + // Creates a new password + await create({ inputs }); + return { entityId }; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.clientSecret, providerInputs.applicationId, entityId] + }); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); + } }; const fetchAzureEntraIdUsers = async (tenantId: string, applicationId: string, clientSecret: string) => { diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts index b939dcad6..32efd9848 100644 --- a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -3,6 +3,8 @@ import handlebars from "handlebars"; import { customAlphabet } from "nanoid"; import { z } from "zod"; +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; @@ -28,7 +30,7 @@ const generateUsername = (usernameTemplate?: string | null, identity?: { name: s export const CassandraProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretCassandraSchema.parseAsync(inputs); - const hostIps = await Promise.all( + await Promise.all( providerInputs.host .split(",") .filter(Boolean) @@ -46,10 +48,10 @@ export const CassandraProvider = (): TDynamicProviderFns => { allowedExpressions: (val) => ["username"].includes(val) }); - return { ...providerInputs, hostIps }; + return { ...providerInputs }; }; - const $getClient = async (providerInputs: z.infer & { hostIps: string[] }) => { + const $getClient = async (providerInputs: z.infer) => { const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; const client = new cassandra.Client({ sslOptions, @@ -62,7 +64,7 @@ export const CassandraProvider = (): TDynamicProviderFns => { }, keyspace: providerInputs.keyspace, localDataCenter: providerInputs?.localDataCenter, - contactPoints: providerInputs.hostIps + contactPoints: providerInputs.host.split(",") }); return client; }; @@ -71,9 +73,24 @@ export const CassandraProvider = (): TDynamicProviderFns => { const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const isConnected = await client.execute("SELECT * FROM system_schema.keyspaces").then(() => true); - await client.shutdown(); - return isConnected; + try { + const isConnected = await client.execute("SELECT * FROM system_schema.keyspaces").then(() => true); + await client.shutdown(); + return isConnected; + } catch (err) { + const tokens = [providerInputs.password, providerInputs.username]; + if (providerInputs.keyspace) { + tokens.push(providerInputs.keyspace); + } + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens + }); + await client.shutdown(); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const create = async (data: { @@ -89,23 +106,39 @@ export const CassandraProvider = (): TDynamicProviderFns => { const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); const { keyspace } = providerInputs; - const expiration = new Date(expireAt).toISOString(); - const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ - username, - password, - expiration, - keyspace - }); + try { + const expiration = new Date(expireAt).toISOString(); - const queries = creationStatement.toString().split(";").filter(Boolean); - for (const query of queries) { - // eslint-disable-next-line - await client.execute(query); + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password, + expiration, + keyspace + }); + + const queries = creationStatement.toString().split(";").filter(Boolean); + for (const query of queries) { + // eslint-disable-next-line + await client.execute(query); + } + await client.shutdown(); + + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + } catch (err) { + const tokens = [username, password]; + if (keyspace) { + tokens.push(keyspace); + } + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens + }); + await client.shutdown(); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); } - await client.shutdown(); - - return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; }; const revoke = async (inputs: unknown, entityId: string) => { @@ -115,14 +148,29 @@ export const CassandraProvider = (): TDynamicProviderFns => { const username = entityId; const { keyspace } = providerInputs; - const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, keyspace }); - const queries = revokeStatement.toString().split(";").filter(Boolean); - for (const query of queries) { - // eslint-disable-next-line - await client.execute(query); + try { + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, keyspace }); + const queries = revokeStatement.toString().split(";").filter(Boolean); + for (const query of queries) { + // eslint-disable-next-line + await client.execute(query); + } + await client.shutdown(); + return { entityId: username }; + } catch (err) { + const tokens = [username]; + if (keyspace) { + tokens.push(keyspace); + } + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens + }); + await client.shutdown(); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); } - await client.shutdown(); - return { entityId: username }; }; const renew = async (inputs: unknown, entityId: string, expireAt: number) => { @@ -130,21 +178,36 @@ export const CassandraProvider = (): TDynamicProviderFns => { if (!providerInputs.renewStatement) return { entityId }; const client = await $getClient(providerInputs); - - const expiration = new Date(expireAt).toISOString(); const { keyspace } = providerInputs; - const renewStatement = handlebars.compile(providerInputs.renewStatement)({ - username: entityId, - keyspace, - expiration - }); - const queries = renewStatement.toString().split(";").filter(Boolean); - for await (const query of queries) { - await client.execute(query); + try { + const expiration = new Date(expireAt).toISOString(); + + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ + username: entityId, + keyspace, + expiration + }); + const queries = renewStatement.toString().split(";").filter(Boolean); + for await (const query of queries) { + await client.execute(query); + } + await client.shutdown(); + return { entityId }; + } catch (err) { + const tokens = [entityId]; + if (keyspace) { + tokens.push(keyspace); + } + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens + }); + await client.shutdown(); + throw new BadRequestError({ + message: `Failed to renew lease from provider: ${sanitizedErrorMessage}` + }); } - await client.shutdown(); - return { entityId }; }; return { 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/elastic-search.ts b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts index 32d21ee76..f4d43f23f 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -2,6 +2,8 @@ import { Client as ElasticSearchClient } from "@elastic/elasticsearch"; import { customAlphabet } from "nanoid"; import { z } from "zod"; +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; @@ -26,14 +28,14 @@ const generateUsername = (usernameTemplate?: string | null, identity?: { name: s export const ElasticSearchProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretElasticSearchSchema.parseAsync(inputs); - const [hostIp] = await verifyHostInputValidity(providerInputs.host); - return { ...providerInputs, hostIp }; + await verifyHostInputValidity(providerInputs.host); + return { ...providerInputs }; }; - const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const $getClient = async (providerInputs: z.infer) => { const connection = new ElasticSearchClient({ node: { - url: new URL(`${providerInputs.hostIp}:${providerInputs.port}`), + url: new URL(`${providerInputs.host}:${providerInputs.port}`), ...(providerInputs.ca && { ssl: { rejectUnauthorized: false, @@ -63,12 +65,24 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const infoResponse = await connection - .info() - .then(() => true) - .catch(() => false); - - return infoResponse; + try { + const infoResponse = await connection.info().then(() => true); + return infoResponse; + } catch (err) { + const tokens = []; + if (providerInputs.auth.type === ElasticSearchAuthTypes.ApiKey) { + tokens.push(providerInputs.auth.apiKey, providerInputs.auth.apiKeyId); + } else { + tokens.push(providerInputs.auth.username, providerInputs.auth.password); + } + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { @@ -79,27 +93,49 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); - await connection.security.putUser({ - username, - password, - full_name: "Managed by Infisical.com", - roles: providerInputs.roles - }); + try { + await connection.security.putUser({ + username, + password, + full_name: "Managed by Infisical.com", + roles: providerInputs.roles + }); - await connection.close(); - return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + await connection.close(); + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password] + }); + await connection.close(); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); + } }; const revoke = async (inputs: unknown, entityId: string) => { const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - await connection.security.deleteUser({ - username: entityId - }); + try { + await connection.security.deleteUser({ + username: entityId + }); - await connection.close(); - return { entityId }; + await connection.close(); + return { entityId }; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [entityId] + }); + await connection.close(); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); + } }; const renew = async (_inputs: unknown, entityId: string) => { diff --git a/backend/src/ee/services/dynamic-secret/providers/gcp-iam.ts b/backend/src/ee/services/dynamic-secret/providers/gcp-iam.ts index b5d34aa49..b1459aa4e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/gcp-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/gcp-iam.ts @@ -3,6 +3,7 @@ import { GetAccessTokenResponse } from "google-auth-library/build/src/auth/oauth import { getConfig } from "@app/lib/config/env"; import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretGcpIamSchema, TDynamicProviderFns } from "./models"; @@ -65,8 +66,18 @@ export const GcpIamProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - await $getToken(providerInputs.serviceAccountEmail, 10); - return true; + try { + await $getToken(providerInputs.serviceAccountEmail, 10); + return true; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.serviceAccountEmail] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const create = async (data: { inputs: unknown; expireAt: number }) => { @@ -74,13 +85,23 @@ export const GcpIamProvider = (): TDynamicProviderFns => { const providerInputs = await validateProviderInputs(inputs); - const now = Math.floor(Date.now() / 1000); - const ttl = Math.max(Math.floor(expireAt / 1000) - now, 0); + try { + const now = Math.floor(Date.now() / 1000); + const ttl = Math.max(Math.floor(expireAt / 1000) - now, 0); - const token = await $getToken(providerInputs.serviceAccountEmail, ttl); - const entityId = alphaNumericNanoId(32); + const token = await $getToken(providerInputs.serviceAccountEmail, ttl); + const entityId = alphaNumericNanoId(32); - return { entityId, data: { SERVICE_ACCOUNT_EMAIL: providerInputs.serviceAccountEmail, TOKEN: token } }; + return { entityId, data: { SERVICE_ACCOUNT_EMAIL: providerInputs.serviceAccountEmail, TOKEN: token } }; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.serviceAccountEmail] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); + } }; const revoke = async (_inputs: unknown, entityId: string) => { @@ -89,10 +110,21 @@ export const GcpIamProvider = (): TDynamicProviderFns => { }; const renew = async (inputs: unknown, entityId: string, expireAt: number) => { - // To renew a token it must be re-created - const data = await create({ inputs, expireAt }); + try { + // To renew a token it must be re-created + const data = await create({ inputs, expireAt }); - return { ...data, entityId }; + return { ...data, entityId }; + } catch (err) { + const providerInputs = await validateProviderInputs(inputs); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.serviceAccountEmail] + }); + throw new BadRequestError({ + message: `Failed to renew lease from provider: ${sanitizedErrorMessage}` + }); + } }; return { diff --git a/backend/src/ee/services/dynamic-secret/providers/github.ts b/backend/src/ee/services/dynamic-secret/providers/github.ts index 8bd1cccf4..d3259064a 100644 --- a/backend/src/ee/services/dynamic-secret/providers/github.ts +++ b/backend/src/ee/services/dynamic-secret/providers/github.ts @@ -3,6 +3,7 @@ import jwt from "jsonwebtoken"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; @@ -89,26 +90,46 @@ export const GithubProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - await $generateGitHubInstallationAccessToken(providerInputs); - return true; + try { + await $generateGitHubInstallationAccessToken(providerInputs); + return true; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.privateKey, String(providerInputs.appId), String(providerInputs.installationId)] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const create = async (data: { inputs: unknown }) => { const { inputs } = data; const providerInputs = await validateProviderInputs(inputs); - const ghTokenData = await $generateGitHubInstallationAccessToken(providerInputs); - const entityId = alphaNumericNanoId(32); + try { + const ghTokenData = await $generateGitHubInstallationAccessToken(providerInputs); + const entityId = alphaNumericNanoId(32); - return { - entityId, - data: { - TOKEN: ghTokenData.token, - EXPIRES_AT: ghTokenData.expires_at, - PERMISSIONS: ghTokenData.permissions, - REPOSITORY_SELECTION: ghTokenData.repository_selection - } - }; + return { + entityId, + data: { + TOKEN: ghTokenData.token, + EXPIRES_AT: ghTokenData.expires_at, + PERMISSIONS: ghTokenData.permissions, + REPOSITORY_SELECTION: ghTokenData.repository_selection + } + }; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.privateKey, String(providerInputs.appId), String(providerInputs.installationId)] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); + } }; const revoke = async () => { 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/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts index afc4804b6..3d69c3282 100644 --- a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -2,7 +2,8 @@ import axios, { AxiosError } from "axios"; import handlebars from "handlebars"; import https from "https"; -import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; @@ -356,8 +357,12 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): errorMessage = (error.response?.data as { message: string }).message; } - throw new InternalServerError({ - message: `Failed to validate connection: ${errorMessage}` + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: errorMessage, + tokens: [providerInputs.clusterToken || ""] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` }); } }; @@ -602,8 +607,12 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): errorMessage = (error.response?.data as { message: string }).message; } - throw new InternalServerError({ - message: `Failed to create dynamic secret: ${errorMessage}` + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: errorMessage, + tokens: [providerInputs.clusterToken || ""] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` }); } }; @@ -683,50 +692,65 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): }; if (providerInputs.credentialType === KubernetesCredentialType.Dynamic) { - const rawUrl = - providerInputs.authMethod === KubernetesAuthMethod.Gateway - ? GATEWAY_AUTH_DEFAULT_URL - : providerInputs.url || ""; + try { + const rawUrl = + providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? GATEWAY_AUTH_DEFAULT_URL + : providerInputs.url || ""; - const url = new URL(rawUrl); - const k8sGatewayHost = url.hostname; - const k8sPort = url.port ? Number(url.port) : 443; - const k8sHost = `${url.protocol}//${url.hostname}`; + const url = new URL(rawUrl); + const k8sGatewayHost = url.hostname; + const k8sPort = url.port ? Number(url.port) : 443; + const k8sHost = `${url.protocol}//${url.hostname}`; - const httpsAgent = - providerInputs.ca && providerInputs.sslEnabled - ? new https.Agent({ - ca: providerInputs.ca, - rejectUnauthorized: true - }) - : undefined; + const httpsAgent = + providerInputs.ca && providerInputs.sslEnabled + ? new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: true + }) + : undefined; - if (providerInputs.gatewayId) { - if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { - await $gatewayProxyWrapper( - { - gatewayId: providerInputs.gatewayId, - targetHost: k8sHost, - targetPort: k8sPort, - httpsAgent, - reviewTokenThroughGateway: true - }, - serviceAccountDynamicCallback - ); + if (providerInputs.gatewayId) { + if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort, + httpsAgent, + reviewTokenThroughGateway: true + }, + serviceAccountDynamicCallback + ); + } else { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sGatewayHost, + targetPort: k8sPort, + httpsAgent, + reviewTokenThroughGateway: false + }, + serviceAccountDynamicCallback + ); + } } else { - await $gatewayProxyWrapper( - { - gatewayId: providerInputs.gatewayId, - targetHost: k8sGatewayHost, - targetPort: k8sPort, - httpsAgent, - reviewTokenThroughGateway: false - }, - serviceAccountDynamicCallback - ); + await serviceAccountDynamicCallback(k8sHost, k8sPort, httpsAgent); } - } else { - await serviceAccountDynamicCallback(k8sHost, k8sPort, httpsAgent); + } catch (error) { + let errorMessage = error instanceof Error ? error.message : "Unknown error"; + if (axios.isAxiosError(error) && (error.response?.data as { message: string })?.message) { + errorMessage = (error.response?.data as { message: string }).message; + } + + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: errorMessage, + tokens: [entityId, providerInputs.clusterToken || ""] + }); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); } } diff --git a/backend/src/ee/services/dynamic-secret/providers/ldap.ts b/backend/src/ee/services/dynamic-secret/providers/ldap.ts index 1de8aa1e6..10e0b88f4 100644 --- a/backend/src/ee/services/dynamic-secret/providers/ldap.ts +++ b/backend/src/ee/services/dynamic-secret/providers/ldap.ts @@ -6,6 +6,7 @@ import RE2 from "re2"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { LdapCredentialType, LdapSchema, TDynamicProviderFns } from "./models"; @@ -91,8 +92,18 @@ export const LdapProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs); - return client.connected; + try { + const client = await $getClient(providerInputs); + return client.connected; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.bindpass, providerInputs.binddn] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const executeLdif = async (client: ldapjs.Client, ldif_file: string) => { @@ -205,11 +216,11 @@ export const LdapProvider = (): TDynamicProviderFns => { if (providerInputs.credentialType === LdapCredentialType.Static) { const dnRegex = new RE2("^dn:\\s*(.+)", "m"); const dnMatch = dnRegex.exec(providerInputs.rotationLdif); + const username = dnMatch?.[1]; + if (!username) throw new BadRequestError({ message: "Username not found from Ldif" }); + const password = generatePassword(); if (dnMatch) { - const username = dnMatch[1]; - const password = generatePassword(); - const generatedLdif = generateLDIF({ username, password, ldifTemplate: providerInputs.rotationLdif }); try { @@ -217,7 +228,11 @@ export const LdapProvider = (): TDynamicProviderFns => { return { entityId: username, data: { DN_ARRAY: dnArray, USERNAME: username, PASSWORD: password } }; } catch (err) { - throw new BadRequestError({ message: (err as Error).message }); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, providerInputs.binddn, providerInputs.bindpass] + }); + throw new BadRequestError({ message: sanitizedErrorMessage }); } } else { throw new BadRequestError({ @@ -238,7 +253,11 @@ export const LdapProvider = (): TDynamicProviderFns => { const rollbackLdif = generateLDIF({ username, password, ldifTemplate: providerInputs.rollbackLdif }); await executeLdif(client, rollbackLdif); } - throw new BadRequestError({ message: (err as Error).message }); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, providerInputs.binddn, providerInputs.bindpass] + }); + throw new BadRequestError({ message: sanitizedErrorMessage }); } } }; @@ -262,7 +281,11 @@ export const LdapProvider = (): TDynamicProviderFns => { return { entityId: username, data: { DN_ARRAY: dnArray, USERNAME: username, PASSWORD: password } }; } catch (err) { - throw new BadRequestError({ message: (err as Error).message }); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, providerInputs.binddn, providerInputs.bindpass] + }); + throw new BadRequestError({ message: sanitizedErrorMessage }); } } else { throw new BadRequestError({ @@ -278,7 +301,7 @@ export const LdapProvider = (): TDynamicProviderFns => { return { entityId }; }; - const renew = async (inputs: unknown, entityId: string) => { + const renew = async (_inputs: unknown, entityId: string) => { // No renewal necessary return { entityId }; }; 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/dynamic-secret/providers/mongo-atlas.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts index 6da8b4b4e..7f19c2d3e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts @@ -3,6 +3,8 @@ import { customAlphabet } from "nanoid"; import { z } from "zod"; import { createDigestAuthRequestInterceptor } from "@app/lib/axios/digest-auth"; +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretMongoAtlasSchema, TDynamicProviderFns } from "./models"; @@ -49,19 +51,25 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const isConnected = await client({ - method: "GET", - url: `v2/groups/${providerInputs.groupId}/databaseUsers`, - params: { itemsPerPage: 1 } - }) - .then(() => true) - .catch((error) => { - if ((error as AxiosError).response) { - throw new Error(JSON.stringify((error as AxiosError).response?.data)); - } - throw error; + try { + const isConnected = await client({ + method: "GET", + url: `v2/groups/${providerInputs.groupId}/databaseUsers`, + params: { itemsPerPage: 1 } + }).then(() => true); + return isConnected; + } catch (error) { + const errorMessage = (error as AxiosError).response + ? JSON.stringify((error as AxiosError).response?.data) + : (error as Error)?.message; + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: errorMessage, + tokens: [providerInputs.adminPublicKey, providerInputs.adminPrivateKey, providerInputs.groupId] }); - return isConnected; + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const create = async (data: { @@ -77,25 +85,39 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); const expiration = new Date(expireAt).toISOString(); - await client({ - method: "POST", - url: `/v2/groups/${providerInputs.groupId}/databaseUsers`, - data: { - roles: providerInputs.roles, - scopes: providerInputs.scopes, - deleteAfterDate: expiration, - username, - password, - databaseName: "admin", - groupId: providerInputs.groupId - } - }).catch((error) => { - if ((error as AxiosError).response) { - throw new Error(JSON.stringify((error as AxiosError).response?.data)); - } - throw error; - }); - return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + try { + await client({ + method: "POST", + url: `/v2/groups/${providerInputs.groupId}/databaseUsers`, + data: { + roles: providerInputs.roles, + scopes: providerInputs.scopes, + deleteAfterDate: expiration, + username, + password, + databaseName: "admin", + groupId: providerInputs.groupId + } + }); + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + } catch (error) { + const errorMessage = (error as AxiosError).response + ? JSON.stringify((error as AxiosError).response?.data) + : (error as Error)?.message; + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: errorMessage, + tokens: [ + username, + password, + providerInputs.adminPublicKey, + providerInputs.adminPrivateKey, + providerInputs.groupId + ] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); + } }; const revoke = async (inputs: unknown, entityId: string) => { @@ -111,15 +133,23 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { throw err; }); if (isExisting) { - await client({ - method: "DELETE", - url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}` - }).catch((error) => { - if ((error as AxiosError).response) { - throw new Error(JSON.stringify((error as AxiosError).response?.data)); - } - throw error; - }); + try { + await client({ + method: "DELETE", + url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}` + }); + } catch (error) { + const errorMessage = (error as AxiosError).response + ? JSON.stringify((error as AxiosError).response?.data) + : (error as Error)?.message; + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: errorMessage, + tokens: [username, providerInputs.adminPublicKey, providerInputs.adminPrivateKey, providerInputs.groupId] + }); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); + } } return { entityId: username }; @@ -132,21 +162,29 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { const username = entityId; const expiration = new Date(expireAt).toISOString(); - await client({ - method: "PATCH", - url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}`, - data: { - deleteAfterDate: expiration, - databaseName: "admin", - groupId: providerInputs.groupId - } - }).catch((error) => { - if ((error as AxiosError).response) { - throw new Error(JSON.stringify((error as AxiosError).response?.data)); - } - throw error; - }); - return { entityId: username }; + try { + await client({ + method: "PATCH", + url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}`, + data: { + deleteAfterDate: expiration, + databaseName: "admin", + groupId: providerInputs.groupId + } + }); + return { entityId: username }; + } catch (error) { + const errorMessage = (error as AxiosError).response + ? JSON.stringify((error as AxiosError).response?.data) + : (error as Error)?.message; + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: errorMessage, + tokens: [username, providerInputs.adminPublicKey, providerInputs.adminPrivateKey, providerInputs.groupId] + }); + throw new BadRequestError({ + message: `Failed to renew lease from provider: ${sanitizedErrorMessage}` + }); + } }; return { diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts index 331a355a7..db1d30dfa 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -2,6 +2,8 @@ import { MongoClient } from "mongodb"; import { customAlphabet } from "nanoid"; import { z } from "zod"; +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; @@ -26,15 +28,15 @@ const generateUsername = (usernameTemplate?: string | null, identity?: { name: s export const MongoDBProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretMongoDBSchema.parseAsync(inputs); - const [hostIp] = await verifyHostInputValidity(providerInputs.host); - return { ...providerInputs, hostIp }; + await verifyHostInputValidity(providerInputs.host); + return { ...providerInputs }; }; - const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const $getClient = async (providerInputs: z.infer) => { const isSrv = !providerInputs.port; const uri = isSrv - ? `mongodb+srv://${providerInputs.hostIp}` - : `mongodb://${providerInputs.hostIp}:${providerInputs.port}`; + ? `mongodb+srv://${providerInputs.host}` + : `mongodb://${providerInputs.host}:${providerInputs.port}`; const client = new MongoClient(uri, { auth: { @@ -51,13 +53,24 @@ export const MongoDBProvider = (): TDynamicProviderFns => { const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const isConnected = await client - .db(providerInputs.database) - .command({ ping: 1 }) - .then(() => true); + try { + const isConnected = await client + .db(providerInputs.database) + .command({ ping: 1 }) + .then(() => true); - await client.close(); - return isConnected; + await client.close(); + return isConnected; + } catch (err) { + await client.close(); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.password, providerInputs.username, providerInputs.database, providerInputs.host] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { @@ -68,16 +81,27 @@ export const MongoDBProvider = (): TDynamicProviderFns => { const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); - const db = client.db(providerInputs.database); + try { + const db = client.db(providerInputs.database); - await db.command({ - createUser: username, - pwd: password, - roles: providerInputs.roles - }); - await client.close(); + await db.command({ + createUser: username, + pwd: password, + roles: providerInputs.roles + }); + await client.close(); - return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + } catch (err) { + await client.close(); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, providerInputs.password, providerInputs.username, providerInputs.database] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); + } }; const revoke = async (inputs: unknown, entityId: string) => { @@ -86,13 +110,24 @@ export const MongoDBProvider = (): TDynamicProviderFns => { const username = entityId; - const db = client.db(providerInputs.database); - await db.command({ - dropUser: username - }); - await client.close(); + try { + const db = client.db(providerInputs.database); + await db.command({ + dropUser: username + }); + await client.close(); - return { entityId: username }; + return { entityId: username }; + } catch (err) { + await client.close(); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, providerInputs.password, providerInputs.username, providerInputs.database] + }); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); + } }; const renew = async (_inputs: unknown, entityId: string) => { diff --git a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts index 76081c86c..f3a0470b0 100644 --- a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts +++ b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts @@ -3,6 +3,8 @@ import https from "https"; import { customAlphabet } from "nanoid"; import { z } from "zod"; +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; @@ -85,13 +87,13 @@ async function deleteRabbitMqUser({ axiosInstance, usernameToDelete }: TDeleteRa export const RabbitMqProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretRabbitMqSchema.parseAsync(inputs); - const [hostIp] = await verifyHostInputValidity(providerInputs.host); - return { ...providerInputs, hostIp }; + await verifyHostInputValidity(providerInputs.host); + return { ...providerInputs }; }; - const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const $getClient = async (providerInputs: z.infer) => { const axiosInstance = axios.create({ - baseURL: `${providerInputs.hostIp}:${providerInputs.port}/api`, + baseURL: `${providerInputs.host}:${providerInputs.port}/api`, auth: { username: providerInputs.username, password: providerInputs.password @@ -110,11 +112,19 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const connection = await $getClient(providerInputs); - - const infoResponse = await connection.get("/whoami").then(() => true); - - return infoResponse; + try { + const connection = await $getClient(providerInputs); + const infoResponse = await connection.get("/whoami").then(() => true); + return infoResponse; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.password, providerInputs.username, providerInputs.host] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { @@ -125,26 +135,44 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); - await createRabbitMqUser({ - axiosInstance: connection, - virtualHost: providerInputs.virtualHost, - createUser: { - password, - username, - tags: [...(providerInputs.tags ?? []), "infisical-user"] - } - }); - - return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + try { + await createRabbitMqUser({ + axiosInstance: connection, + virtualHost: providerInputs.virtualHost, + createUser: { + password, + username, + tags: [...(providerInputs.tags ?? []), "infisical-user"] + } + }); + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, providerInputs.password, providerInputs.username] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); + } }; const revoke = async (inputs: unknown, entityId: string) => { const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - await deleteRabbitMqUser({ axiosInstance: connection, usernameToDelete: entityId }); - - return { entityId }; + try { + await deleteRabbitMqUser({ axiosInstance: connection, usernameToDelete: entityId }); + return { entityId }; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [entityId, providerInputs.password, providerInputs.username] + }); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); + } }; const renew = async (_inputs: unknown, entityId: string) => { diff --git a/backend/src/ee/services/dynamic-secret/providers/redis.ts b/backend/src/ee/services/dynamic-secret/providers/redis.ts index 989ed96dc..621c7d761 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -4,6 +4,7 @@ import { customAlphabet } from "nanoid"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; @@ -112,14 +113,27 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const connection = await $getClient(providerInputs); - - const pingResponse = await connection - .ping() - .then(() => true) - .catch(() => false); - - return pingResponse; + let connection; + try { + connection = await $getClient(providerInputs); + const pingResponse = await connection.ping().then(() => true); + await connection.quit(); + return pingResponse; + } catch (err) { + if (connection) await connection.quit(); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [ + providerInputs.password || "", + providerInputs.username, + providerInputs.host, + String(providerInputs.port) + ] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const create = async (data: { @@ -144,10 +158,20 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { const queries = creationStatement.toString().split(";").filter(Boolean); - await executeTransactions(connection, queries); - - await connection.quit(); - return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + try { + await executeTransactions(connection, queries); + await connection.quit(); + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + } catch (err) { + await connection.quit(); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, providerInputs.password || "", providerInputs.username] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); + } }; const revoke = async (inputs: unknown, entityId: string) => { @@ -159,10 +183,20 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username }); const queries = revokeStatement.toString().split(";").filter(Boolean); - await executeTransactions(connection, queries); - - await connection.quit(); - return { entityId: username }; + try { + await executeTransactions(connection, queries); + await connection.quit(); + return { entityId: username }; + } catch (err) { + await connection.quit(); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, providerInputs.password || "", providerInputs.username] + }); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); + } }; const renew = async (inputs: unknown, entityId: string, expireAt: number) => { @@ -176,13 +210,23 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username, expiration }); - if (renewStatement) { - const queries = renewStatement.toString().split(";").filter(Boolean); - await executeTransactions(connection, queries); + try { + if (renewStatement) { + const queries = renewStatement.toString().split(";").filter(Boolean); + await executeTransactions(connection, queries); + } + await connection.quit(); + return { entityId: username }; + } catch (err) { + await connection.quit(); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, providerInputs.password || "", providerInputs.username] + }); + throw new BadRequestError({ + message: `Failed to renew lease from provider: ${sanitizedErrorMessage}` + }); } - - await connection.quit(); - return { entityId: username }; }; return { diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts index 9c13d3efc..34f77d465 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts @@ -4,6 +4,7 @@ import odbc from "odbc"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; @@ -35,7 +36,7 @@ export const SapAseProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretSapAseSchema.parseAsync(inputs); - const [hostIp] = await verifyHostInputValidity(providerInputs.host); + await verifyHostInputValidity(providerInputs.host); validateHandlebarTemplate("SAP ASE creation", providerInputs.creationStatement, { allowedExpressions: (val) => ["username", "password"].includes(val) }); @@ -44,16 +45,13 @@ export const SapAseProvider = (): TDynamicProviderFns => { allowedExpressions: (val) => ["username"].includes(val) }); } - return { ...providerInputs, hostIp }; + return { ...providerInputs }; }; - const $getClient = async ( - providerInputs: z.infer & { hostIp: string }, - useMaster?: boolean - ) => { + const $getClient = async (providerInputs: z.infer, useMaster?: boolean) => { const connectionString = `DRIVER={FreeTDS};` + - `SERVER=${providerInputs.hostIp};` + + `SERVER=${providerInputs.host};` + `PORT=${providerInputs.port};` + `DATABASE=${useMaster ? "master" : providerInputs.database};` + `UID=${providerInputs.username};` + @@ -67,25 +65,41 @@ export const SapAseProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const masterClient = await $getClient(providerInputs, true); - const client = await $getClient(providerInputs); + let masterClient; + let client; + try { + masterClient = await $getClient(providerInputs, true); + client = await $getClient(providerInputs); - const [resultFromMasterDatabase] = await masterClient.query<{ version: string }>("SELECT @@VERSION AS version"); - const [resultFromSelectedDatabase] = await client.query<{ version: string }>("SELECT @@VERSION AS version"); + const [resultFromMasterDatabase] = await masterClient.query<{ version: string }>("SELECT @@VERSION AS version"); + const [resultFromSelectedDatabase] = await client.query<{ version: string }>("SELECT @@VERSION AS version"); - if (!resultFromSelectedDatabase.version) { + if (!resultFromSelectedDatabase.version) { + throw new BadRequestError({ + message: "Failed to validate SAP ASE connection, version query failed" + }); + } + + if (resultFromMasterDatabase.version !== resultFromSelectedDatabase.version) { + throw new BadRequestError({ + message: "Failed to validate SAP ASE connection (master), version mismatch" + }); + } + + await masterClient.close(); + await client.close(); + return true; + } catch (err) { + if (masterClient) await masterClient.close(); + if (client) await client.close(); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.password, providerInputs.username, providerInputs.host, providerInputs.database] + }); throw new BadRequestError({ - message: "Failed to validate SAP ASE connection, version query failed" + message: `Failed to connect with provider: ${sanitizedErrorMessage}` }); } - - if (resultFromMasterDatabase.version !== resultFromSelectedDatabase.version) { - throw new BadRequestError({ - message: "Failed to validate SAP ASE connection (master), version mismatch" - }); - } - - return true; }; const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { @@ -105,16 +119,26 @@ export const SapAseProvider = (): TDynamicProviderFns => { const queries = creationStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); - for await (const query of queries) { - // If it's an adduser query, we need to first call sp_addlogin on the MASTER database. - // If not done, then the newly created user won't be able to authenticate. - await (query.startsWith(SapCommands.CreateLogin) ? masterClient : client).query(query); + try { + for await (const query of queries) { + // If it's an adduser query, we need to first call sp_addlogin on the MASTER database. + // If not done, then the newly created user won't be able to authenticate. + await (query.startsWith(SapCommands.CreateLogin) ? masterClient : client).query(query); + } + await masterClient.close(); + await client.close(); + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + } catch (err) { + await masterClient.close(); + await client.close(); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, providerInputs.password, providerInputs.username, providerInputs.database] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); } - - await masterClient.close(); - await client.close(); - - return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; }; const revoke = async (inputs: unknown, username: string) => { @@ -140,14 +164,24 @@ export const SapAseProvider = (): TDynamicProviderFns => { } } - for await (const query of queries) { - await (query.startsWith(SapCommands.DropLogin) ? masterClient : client).query(query); + try { + for await (const query of queries) { + await (query.startsWith(SapCommands.DropLogin) ? masterClient : client).query(query); + } + await masterClient.close(); + await client.close(); + return { entityId: username }; + } catch (err) { + await masterClient.close(); + await client.close(); + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, providerInputs.password, providerInputs.username, providerInputs.database] + }); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); } - - await masterClient.close(); - await client.close(); - - return { entityId: username }; }; const renew = async (_: unknown, username: string) => { diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts index 5c8a75555..bc7400a36 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts @@ -10,6 +10,7 @@ import { customAlphabet } from "nanoid"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; @@ -36,7 +37,7 @@ export const SapHanaProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretSapHanaSchema.parseAsync(inputs); - const [hostIp] = await verifyHostInputValidity(providerInputs.host); + await verifyHostInputValidity(providerInputs.host); validateHandlebarTemplate("SAP Hana creation", providerInputs.creationStatement, { allowedExpressions: (val) => ["username", "password", "expiration"].includes(val) }); @@ -48,12 +49,12 @@ export const SapHanaProvider = (): TDynamicProviderFns => { validateHandlebarTemplate("SAP Hana revoke", providerInputs.revocationStatement, { allowedExpressions: (val) => ["username"].includes(val) }); - return { ...providerInputs, hostIp }; + return { ...providerInputs }; }; - const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const $getClient = async (providerInputs: z.infer) => { const client = hdb.createClient({ - host: providerInputs.hostIp, + host: providerInputs.host, port: providerInputs.port, user: providerInputs.username, password: providerInputs.password, @@ -83,19 +84,26 @@ export const SapHanaProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs); - - const testResult = await new Promise((resolve, reject) => { - client.exec("SELECT 1 FROM DUMMY;", (err: any) => { - if (err) { - reject(); - } - - resolve(true); + try { + const client = await $getClient(providerInputs); + const testResult = await new Promise((resolve, reject) => { + client.exec("SELECT 1 FROM DUMMY;", (err: any) => { + if (err) { + return reject(err); + } + resolve(true); + }); }); - }); - - return testResult; + return testResult; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.password, providerInputs.username, providerInputs.host] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; const create = async (data: { @@ -119,18 +127,22 @@ export const SapHanaProvider = (): TDynamicProviderFns => { }); const queries = creationStatement.toString().split(";").filter(Boolean); - for await (const query of queries) { - await new Promise((resolve, reject) => { - client.exec(query, (err: any) => { - if (err) { - reject( - new BadRequestError({ - message: err.message - }) - ); - } - resolve(true); + try { + for await (const query of queries) { + await new Promise((resolve, reject) => { + client.exec(query, (err: any) => { + if (err) return reject(err); + resolve(true); + }); }); + } + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, providerInputs.password, providerInputs.username] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` }); } @@ -142,18 +154,24 @@ export const SapHanaProvider = (): TDynamicProviderFns => { const client = await $getClient(providerInputs); const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username }); const queries = revokeStatement.toString().split(";").filter(Boolean); - for await (const query of queries) { - await new Promise((resolve, reject) => { - client.exec(query, (err: any) => { - if (err) { - reject( - new BadRequestError({ - message: err.message - }) - ); - } - resolve(true); + try { + for await (const query of queries) { + await new Promise((resolve, reject) => { + client.exec(query, (err: any) => { + if (err) { + reject(err); + } + resolve(true); + }); }); + } + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, providerInputs.password, providerInputs.username] + }); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` }); } @@ -174,16 +192,20 @@ export const SapHanaProvider = (): TDynamicProviderFns => { await new Promise((resolve, reject) => { client.exec(query, (err: any) => { if (err) { - reject( - new BadRequestError({ - message: err.message - }) - ); + reject(err); } resolve(true); }); }); } + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [entityId, providerInputs.password, providerInputs.username] + }); + throw new BadRequestError({ + message: `Failed to renew lease from provider: ${sanitizedErrorMessage}` + }); } finally { client.disconnect(); } diff --git a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts index 9e97ecd30..a335c427b 100644 --- a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts +++ b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts @@ -4,6 +4,7 @@ import snowflake from "snowflake-sdk"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; @@ -69,12 +70,10 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs); - - let isValidConnection: boolean; - + let client; try { - isValidConnection = await Promise.race([ + client = await $getClient(providerInputs); + const isValidConnection = await Promise.race([ client.isValidAsync(), new Promise((resolve) => { setTimeout(resolve, 10000); @@ -82,11 +81,18 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { throw new BadRequestError({ message: "Unable to establish connection - verify credentials" }); }) ]); + return isValidConnection; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.password, providerInputs.username, providerInputs.accountId, providerInputs.orgId] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); } finally { - client.destroy(noop); + if (client) client.destroy(noop); } - - return isValidConnection; }; const create = async (data: { @@ -116,13 +122,19 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { sqlText: creationStatement, complete(err) { if (err) { - return reject(new BadRequestError({ name: "CreateLease", message: err.message })); + return reject(err); } return resolve(true); } }); }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error).message, + tokens: [username, password, providerInputs.password, providerInputs.username] + }); + throw new BadRequestError({ message: `Failed to create lease from provider: ${sanitizedErrorMessage}` }); } finally { client.destroy(noop); } @@ -143,13 +155,19 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { sqlText: revokeStatement, complete(err) { if (err) { - return reject(new BadRequestError({ name: "RevokeLease", message: err.message })); + return reject(err); } return resolve(true); } }); }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error).message, + tokens: [username, providerInputs.password, providerInputs.username] + }); + throw new BadRequestError({ message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` }); } finally { client.destroy(noop); } @@ -175,13 +193,19 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { sqlText: renewStatement, complete(err) { if (err) { - return reject(new BadRequestError({ name: "RenewLease", message: err.message })); + return reject(err); } return resolve(true); } }); }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error).message, + tokens: [entityId, providerInputs.password, providerInputs.username] + }); + throw new BadRequestError({ message: `Failed to renew lease from provider: ${sanitizedErrorMessage}` }); } finally { client.destroy(noop); } diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index c86e3aff5..e68e41c6b 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -3,6 +3,8 @@ import knex from "knex"; import { z } from "zod"; import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; @@ -148,8 +150,10 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) return { ...providerInputs, hostIp }; }; - const $getClient = async (providerInputs: z.infer) => { - const ssl = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; + const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const ssl = providerInputs.ca + ? { rejectUnauthorized: false, ca: providerInputs.ca, servername: providerInputs.host } + : undefined; const isMsSQLClient = providerInputs.client === SqlProviders.MsSQL; const db = knex({ @@ -157,7 +161,10 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) connection: { database: providerInputs.database, port: providerInputs.port, - host: providerInputs.host, + host: + providerInputs.client === SqlProviders.Postgres && !providerInputs.gatewayId + ? providerInputs.hostIp + : providerInputs.host, user: providerInputs.username, password: providerInputs.password, ssl, @@ -207,13 +214,24 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); let isConnected = false; - const gatewayCallback = async (host = providerInputs.hostIp, port = providerInputs.port) => { - const db = await $getClient({ ...providerInputs, port, host }); + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const db = await $getClient({ ...providerInputs, port, host, hostIp: providerInputs.hostIp }); // oracle needs from keyword const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1"; - isConnected = await db.raw(testStatement).then(() => true); - await db.destroy(); + try { + isConnected = await db.raw(testStatement).then(() => true); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.username] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } finally { + await db.destroy(); + } }; if (providerInputs.gatewayId) { @@ -233,13 +251,13 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const { inputs, expireAt, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); + const { database } = providerInputs; const username = generateUsername(providerInputs.client, usernameTemplate, identity); const password = generatePassword(providerInputs.client, providerInputs.passwordRequirements); const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { const db = await $getClient({ ...providerInputs, port, host }); try { - const { database } = providerInputs; const expiration = new Date(expireAt).toISOString(); const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ @@ -256,6 +274,14 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await tx.raw(query); } }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, database] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); } finally { await db.destroy(); } @@ -283,6 +309,14 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await tx.raw(query); } }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, database] + }); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); } finally { await db.destroy(); } @@ -319,6 +353,14 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) } }); } + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [database] + }); + throw new BadRequestError({ + message: `Failed to renew lease from provider: ${sanitizedErrorMessage}` + }); } finally { await db.destroy(); } diff --git a/backend/src/ee/services/dynamic-secret/providers/totp.ts b/backend/src/ee/services/dynamic-secret/providers/totp.ts index d16b82306..4e1b504f0 100644 --- a/backend/src/ee/services/dynamic-secret/providers/totp.ts +++ b/backend/src/ee/services/dynamic-secret/providers/totp.ts @@ -1,6 +1,8 @@ import { authenticator } from "otplib"; import { HashAlgorithms } from "otplib/core"; +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretTotpSchema, TDynamicProviderFns, TotpConfigType } from "./models"; @@ -12,62 +14,84 @@ export const TotpProvider = (): TDynamicProviderFns => { return providerInputs; }; - const validateConnection = async () => { - return true; + const validateConnection = async (inputs: unknown) => { + try { + await validateProviderInputs(inputs); + return true; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } }; - const create = async (inputs: unknown) => { - const providerInputs = await validateProviderInputs(inputs); + const create = async (data: { inputs: unknown }) => { + const { inputs } = data; + try { + const providerInputs = await validateProviderInputs(inputs); - const entityId = alphaNumericNanoId(32); - const authenticatorInstance = authenticator.clone(); + const entityId = alphaNumericNanoId(32); + const authenticatorInstance = authenticator.clone(); - let secret: string; - let period: number | null | undefined; - let digits: number | null | undefined; - let algorithm: HashAlgorithms | null | undefined; + let secret: string; + let period: number | null | undefined; + let digits: number | null | undefined; + let algorithm: HashAlgorithms | null | undefined; - if (providerInputs.configType === TotpConfigType.URL) { - const urlObj = new URL(providerInputs.url); - secret = urlObj.searchParams.get("secret") as string; - const periodFromUrl = urlObj.searchParams.get("period"); - const digitsFromUrl = urlObj.searchParams.get("digits"); - const algorithmFromUrl = urlObj.searchParams.get("algorithm"); + if (providerInputs.configType === TotpConfigType.URL) { + const urlObj = new URL(providerInputs.url); + secret = urlObj.searchParams.get("secret") as string; + const periodFromUrl = urlObj.searchParams.get("period"); + const digitsFromUrl = urlObj.searchParams.get("digits"); + const algorithmFromUrl = urlObj.searchParams.get("algorithm"); - if (periodFromUrl) { - period = +periodFromUrl; + if (periodFromUrl) { + period = +periodFromUrl; + } + + if (digitsFromUrl) { + digits = +digitsFromUrl; + } + + if (algorithmFromUrl) { + algorithm = algorithmFromUrl.toLowerCase() as HashAlgorithms; + } + } else { + secret = providerInputs.secret; + period = providerInputs.period; + digits = providerInputs.digits; + algorithm = providerInputs.algorithm as unknown as HashAlgorithms; } - if (digitsFromUrl) { - digits = +digitsFromUrl; + if (digits) { + authenticatorInstance.options = { digits }; } - if (algorithmFromUrl) { - algorithm = algorithmFromUrl.toLowerCase() as HashAlgorithms; + if (algorithm) { + authenticatorInstance.options = { algorithm }; } - } else { - secret = providerInputs.secret; - period = providerInputs.period; - digits = providerInputs.digits; - algorithm = providerInputs.algorithm as unknown as HashAlgorithms; - } - if (digits) { - authenticatorInstance.options = { digits }; - } + if (period) { + authenticatorInstance.options = { step: period }; + } - if (algorithm) { - authenticatorInstance.options = { algorithm }; + return { + entityId, + data: { TOTP: authenticatorInstance.generate(secret), TIME_REMAINING: authenticatorInstance.timeRemaining() } + }; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); } - - if (period) { - authenticatorInstance.options = { step: period }; - } - - return { - entityId, - data: { TOTP: authenticatorInstance.generate(secret), TIME_REMAINING: authenticatorInstance.timeRemaining() } - }; }; const revoke = async (_inputs: unknown, entityId: string) => { diff --git a/backend/src/ee/services/dynamic-secret/providers/vertica.ts b/backend/src/ee/services/dynamic-secret/providers/vertica.ts index 0e60cddb3..8fa332bd5 100644 --- a/backend/src/ee/services/dynamic-secret/providers/vertica.ts +++ b/backend/src/ee/services/dynamic-secret/providers/vertica.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; @@ -275,6 +276,14 @@ export const VerticaProvider = ({ gatewayService }: TVerticaProviderDTO): TDynam await client.raw(trimmedQuery); } } + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, providerInputs.username, providerInputs.password] + }); + throw new BadRequestError({ + message: `Failed to create lease from provider: ${sanitizedErrorMessage}` + }); } finally { if (client) await client.destroy(); } @@ -339,6 +348,14 @@ export const VerticaProvider = ({ gatewayService }: TVerticaProviderDTO): TDynam await client.raw(trimmedQuery); } } + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, providerInputs.username, providerInputs.password] + }); + throw new BadRequestError({ + message: `Failed to revoke lease from provider: ${sanitizedErrorMessage}` + }); } finally { if (client) await client.destroy(); } diff --git a/backend/src/ee/services/event/event-bus-service.ts b/backend/src/ee/services/event/event-bus-service.ts new file mode 100644 index 000000000..00fd14e9f --- /dev/null +++ b/backend/src/ee/services/event/event-bus-service.ts @@ -0,0 +1,83 @@ +import { Cluster, Redis } from "ioredis"; +import { z } from "zod"; + +import { logger } from "@app/lib/logger"; + +import { BusEventSchema, TopicName } from "./types"; + +export const eventBusFactory = (redis: Redis | Cluster) => { + const publisher = redis.duplicate(); + // Duplicate the publisher to create a subscriber. + // This is necessary because Redis does not allow a single connection to both publish and subscribe. + const subscriber = publisher.duplicate(); + + const init = async (topics: TopicName[] = Object.values(TopicName)) => { + subscriber.on("error", (e) => { + logger.error(e, "Event Bus subscriber error"); + }); + + publisher.on("error", (e) => { + logger.error(e, "Event Bus publisher error"); + }); + + await subscriber.subscribe(...topics); + }; + + /** + * Publishes an event to the specified topic. + * @param topic - The topic to publish the event to. + * @param event - The event data to publish. + */ + const publish = async >(topic: TopicName, event: T) => { + const json = JSON.stringify(event); + + return publisher.publish(topic, json, (err) => { + if (err) { + return logger.error(err, `Error publishing to channel ${topic}`); + } + }); + }; + + /** + * @param fn - The function to call when a message is received. + * It should accept the parsed event data as an argument. + * @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) => { + // Not using async await cause redis client's `on` method does not expect async listeners. + const listener = (channel: string, message: string) => { + try { + const parsed = JSON.parse(message) as T; + const thenable = fn(parsed); + + // If the function returns a Promise, catch any errors that occur during processing. + if (thenable instanceof Promise) { + thenable.catch((error) => { + logger.error(error, `Error processing message from channel ${channel}`); + }); + } + } catch (error) { + logger.error(error, `Error parsing message data from channel ${channel}`); + } + }; + subscriber.on("message", listener); + + return () => { + subscriber.off("message", listener); + }; + }; + + const close = async () => { + try { + await publisher.quit(); + await subscriber.quit(); + } catch (error) { + logger.error(error, "Error closing event bus connections"); + } + }; + + return { init, publish, subscribe, close }; +}; + +export type TEventBusService = ReturnType; diff --git a/backend/src/ee/services/event/event-sse-service.ts b/backend/src/ee/services/event/event-sse-service.ts new file mode 100644 index 000000000..147af8e3c --- /dev/null +++ b/backend/src/ee/services/event/event-sse-service.ts @@ -0,0 +1,162 @@ +/* eslint-disable no-continue */ +import { subject } from "@casl/ability"; +import { Cluster, Redis } from "ioredis"; + +import { KeyStorePrefixes } from "@app/keystore/keystore"; +import { logger } from "@app/lib/logger"; + +import { TEventBusService } from "./event-bus-service"; +import { createEventStreamClient, EventStreamClient, IEventStreamClientOpts } from "./event-sse-stream"; +import { BusEvent, RegisteredEvent } from "./types"; + +const AUTH_REFRESH_INTERVAL = 60 * 1000; +const HEART_BEAT_INTERVAL = 15 * 1000; + +export const sseServiceFactory = (bus: TEventBusService, redis: Redis | Cluster) => { + const clients = new Set(); + + const heartbeatInterval = setInterval(() => { + for (const client of clients) { + if (client.stream.closed) continue; + void client.ping(); + } + }, HEART_BEAT_INTERVAL); + + const refreshInterval = setInterval(() => { + for (const client of clients) { + if (client.stream.closed) continue; + void client.refresh(); + } + }, AUTH_REFRESH_INTERVAL); + + const removeActiveConnection = async (projectId: string, identityId: string, connectionId: string) => { + const set = KeyStorePrefixes.ActiveSSEConnectionsSet(projectId, identityId); + const key = KeyStorePrefixes.ActiveSSEConnections(projectId, identityId, connectionId); + + await Promise.all([redis.lrem(set, 0, connectionId), redis.del(key)]); + }; + + const getActiveConnectionsCount = async (projectId: string, identityId: string) => { + const set = KeyStorePrefixes.ActiveSSEConnectionsSet(projectId, identityId); + const connections = await redis.lrange(set, 0, -1); + + if (connections.length === 0) { + return 0; // No active connections + } + + const keys = connections.map((c) => KeyStorePrefixes.ActiveSSEConnections(projectId, identityId, c)); + + const values = await redis.mget(...keys); + + // eslint-disable-next-line no-plusplus + for (let i = 0; i < values.length; i++) { + if (values[i] === null) { + // eslint-disable-next-line no-await-in-loop + await removeActiveConnection(projectId, identityId, connections[i]); + } + } + + return redis.llen(set); + }; + + const onDisconnect = async (client: EventStreamClient) => { + try { + client.close(); + clients.delete(client); + await removeActiveConnection(client.auth.projectId, client.auth.actorId, client.id); + } catch (error) { + logger.error(error, "Error during SSE stream disconnection"); + } + }; + + function filterEventsForClient(client: EventStreamClient, event: BusEvent, registered: RegisteredEvent[]) { + const eventType = event.data.event; + const match = registered.find((r) => r.event === eventType); + if (!match) return; + + const item = event.data.payload; + + if (Array.isArray(item)) { + if (item.length === 0) return; + + const baseSubject = { + eventType, + environment: undefined as string | undefined, + secretPath: undefined as string | undefined + }; + + const filtered = item.filter((ev) => { + baseSubject.secretPath = ev.secretPath ?? "/"; + baseSubject.environment = ev.environment; + + return client.matcher.can("subscribe", subject(event.type, baseSubject)); + }); + + if (filtered.length === 0) return; + + return client.send({ + ...event, + data: { + ...event.data, + payload: filtered + } + }); + } + + // For single item + const baseSubject = { + eventType, + secretPath: item.secretPath ?? "/", + environment: item.environment + }; + + if (client.matcher.can("subscribe", subject(event.type, baseSubject))) { + client.send(event); + } + } + + const subscribe = async ( + opts: IEventStreamClientOpts & { + onClose?: () => void; + } + ) => { + const client = createEventStreamClient(redis, opts); + + // Set up event listener on event bus + const unsubscribe = bus.subscribe((event) => { + if (event.type !== opts.type) return; + filterEventsForClient(client, event, opts.registered); + }); + + client.stream.on("close", () => { + unsubscribe(); + void onDisconnect(client); // This will never throw + }); + + await client.open(); + + clients.add(client); + + return client; + }; + + const close = () => { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + } + + if (refreshInterval) { + clearInterval(refreshInterval); + } + + for (const client of clients) { + client.close(); + } + + clients.clear(); + }; + + return { subscribe, close, getActiveConnectionsCount }; +}; + +export type TServerSentEventsService = ReturnType; diff --git a/backend/src/ee/services/event/event-sse-stream.ts b/backend/src/ee/services/event/event-sse-stream.ts new file mode 100644 index 000000000..9ed5e677c --- /dev/null +++ b/backend/src/ee/services/event/event-sse-stream.ts @@ -0,0 +1,184 @@ +/* eslint-disable no-underscore-dangle */ +import { Readable } from "node:stream"; + +import { MongoAbility, PureAbility } from "@casl/ability"; +import { MongoQuery } from "@ucast/mongo2js"; +import { Cluster, Redis } from "ioredis"; +import { nanoid } from "nanoid"; + +import { ProjectType } from "@app/db/schemas"; +import { ProjectPermissionSet } from "@app/ee/services/permission/project-permission"; +import { KeyStorePrefixes } from "@app/keystore/keystore"; +import { conditionsMatcher } from "@app/lib/casl"; +import { logger } from "@app/lib/logger"; + +import { BusEvent, RegisteredEvent } from "./types"; + +export const getServerSentEventsHeaders = () => + ({ + "Cache-Control": "no-cache", + "Content-Type": "text/event-stream", + Connection: "keep-alive", + "X-Accel-Buffering": "no" + }) as const; + +type TAuthInfo = { + actorId: string; + projectId: string; + permission: MongoAbility; +}; + +export interface IEventStreamClientOpts { + type: ProjectType; + registered: RegisteredEvent[]; + onAuthRefresh: (info: TAuthInfo) => Promise | void; + getAuthInfo: () => Promise | TAuthInfo; +} + +interface EventMessage { + time?: string | number; + type: string; + data?: unknown; +} + +function serializeSseEvent(chunk: EventMessage): string { + let payload = ""; + + if (chunk.time) payload += `id: ${chunk.time}\n`; + if (chunk.type) payload += `event: ${chunk.type}\n`; + if (chunk.data) payload += `data: ${JSON.stringify(chunk)}\n`; + + return `${payload}\n`; +} + +export type EventStreamClient = { + id: string; + stream: Readable; + open: () => Promise; + send: (data: EventMessage | BusEvent) => void; + ping: () => Promise; + refresh: () => Promise; + close: () => void; + get auth(): TAuthInfo; + signal: AbortSignal; + abort: () => void; + matcher: PureAbility; +}; + +export function createEventStreamClient(redis: Redis | Cluster, options: IEventStreamClientOpts): EventStreamClient { + const rules = options.registered.map((r) => { + const secretPath = r.conditions?.secretPath; + const hasConditions = r.conditions?.environmentSlug || r.conditions?.secretPath; + + return { + subject: options.type, + action: "subscribe", + conditions: hasConditions + ? { + environment: r.conditions?.environmentSlug ?? "", + secretPath: { $glob: secretPath } + } + : undefined + }; + }); + + const id = `sse-${nanoid()}`; + const control = new AbortController(); + const matcher = new PureAbility(rules, { conditionsMatcher }); + + let auth: TAuthInfo | undefined; + + const stream = new Readable({ + objectMode: true + }); + + // We will manually push data to the stream + stream._read = () => {}; + + const send = (data: EventMessage | BusEvent) => { + const chunk = serializeSseEvent(data); + if (!stream.push(chunk)) { + logger.debug("Backpressure detected: dropped manual event"); + } + }; + + stream.on("error", (error: Error) => stream.destroy(error)); + + const open = async () => { + auth = await options.getAuthInfo(); + await options.onAuthRefresh(auth); + + const { actorId, projectId } = auth; + const set = KeyStorePrefixes.ActiveSSEConnectionsSet(projectId, actorId); + const key = KeyStorePrefixes.ActiveSSEConnections(projectId, actorId, id); + + await Promise.all([redis.rpush(set, id), redis.set(key, "1", "EX", 60)]); + }; + + const ping = async () => { + if (!auth) return; // Avoid race condition if ping is called before open + + const { actorId, projectId } = auth; + const key = KeyStorePrefixes.ActiveSSEConnections(projectId, actorId, id); + + await redis.set(key, "1", "EX", 60); + + send({ type: "ping" }); + }; + + const close = () => { + if (stream.closed) return; + stream.push(null); + stream.destroy(); + }; + + /** + * Refreshes the connection's auth permissions + * Must be called atleast once when connection is opened + */ + const refresh = async () => { + try { + auth = await options.getAuthInfo(); + await options.onAuthRefresh(auth); + } catch (error) { + if (error instanceof Error) { + send({ + type: "error", + data: { + ...error + } + }); + return close(); + } + stream.emit("error", error); + } + }; + + const abort = () => { + try { + control.abort(); + } catch (error) { + logger.debug(error, "Error aborting SSE stream"); + } + }; + + return { + id, + stream, + open, + send, + ping, + refresh, + close, + signal: control.signal, + abort, + matcher, + get auth() { + if (!auth) { + throw new Error("Auth info not set"); + } + + return auth; + } + }; +} diff --git a/backend/src/ee/services/event/types.ts b/backend/src/ee/services/event/types.ts new file mode 100644 index 000000000..5cbaf4c14 --- /dev/null +++ b/backend/src/ee/services/event/types.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; + +import { ProjectType } from "@app/db/schemas"; + +import { ProjectPermissionSecretEventActions } from "../permission/project-permission"; + +export enum TopicName { + CoreServers = "infisical::core-servers" +} + +export enum BusEventName { + CreateSecret = "secret:create", + UpdateSecret = "secret:update", + DeleteSecret = "secret:delete", + ImportMutation = "secret:import-mutation" +} + +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"); + } + } +}; + +export const EventName = z.nativeEnum(BusEventName); + +const EventSecretPayload = z.object({ + 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 BusEventSchema = z.object({ + datacontenttype: z.literal("application/json").optional().default("application/json"), + type: z.nativeEnum(ProjectType), + source: z.string(), + time: z + .string() + .optional() + .default(() => new Date().toISOString()), + data: z.discriminatedUnion("event", [ + z.object({ + specversion: z.number().optional().default(1), + event: z.enum([BusEventName.CreateSecret, BusEventName.DeleteSecret, BusEventName.UpdateSecret]), + payload: z.union([EventSecretPayload, EventSecretPayload.array()]) + }), + z.object({ + specversion: z.number().optional().default(1), + event: z.enum([BusEventName.ImportMutation]), + payload: z.union([EventImportMutationPayload, EventImportMutationPayload.array()]) + }) + // Add more event types as needed + ]) +}); + +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: z.nativeEnum(BusEventName), + conditions: z + .object({ + secretPath: z.string().optional().default("/"), + environmentSlug: z.string() + }) + .optional() +}); + +export type RegisteredEvent = z.infer; diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts index 37d1bd398..7c4ad15eb 100644 --- a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts +++ b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts @@ -1,14 +1,19 @@ +/* eslint-disable @typescript-eslint/return-await */ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; import { Octokit } from "@octokit/core"; import { paginateGraphql } from "@octokit/plugin-paginate-graphql"; import { Octokit as OctokitRest } from "@octokit/rest"; +import RE2 from "re2"; import { OrgMembershipRole } from "@app/db/schemas"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; +import { retryWithBackoff } from "@app/lib/retry"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TGroupDALFactory } from "../group/group-dal"; import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal"; @@ -16,20 +21,67 @@ import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TGithubOrgSyncDALFactory } from "./github-org-sync-dal"; -import { TCreateGithubOrgSyncDTO, TDeleteGithubOrgSyncDTO, TUpdateGithubOrgSyncDTO } from "./github-org-sync-types"; +import { + TCreateGithubOrgSyncDTO, + TDeleteGithubOrgSyncDTO, + TSyncAllTeamsDTO, + TSyncResult, + TUpdateGithubOrgSyncDTO, + TValidateGithubTokenDTO +} from "./github-org-sync-types"; const OctokitWithPlugin = Octokit.plugin(paginateGraphql); +// Type definitions for GitHub API errors +interface GitHubApiError extends Error { + status?: number; + response?: { + status?: number; + headers?: { + "x-ratelimit-reset"?: string; + }; + }; +} + +interface OrgMembershipWithUser { + id: string; + orgId: string; + role: string; + status: string; + isActive: boolean; + inviteEmail: string | null; + user: { + id: string; + email: string; + username: string | null; + firstName: string | null; + lastName: string | null; + } | null; +} + +interface GroupMembership { + id: string; + groupId: string; + groupName: string; + orgMembershipId: string; + firstName: string | null; + lastName: string | null; +} + type TGithubOrgSyncServiceFactoryDep = { githubOrgSyncDAL: TGithubOrgSyncDALFactory; permissionService: Pick; kmsService: Pick; userGroupMembershipDAL: Pick< TUserGroupMembershipDALFactory, - "findGroupMembershipsByUserIdInOrg" | "insertMany" | "delete" + "findGroupMembershipsByUserIdInOrg" | "findGroupMembershipsByGroupIdInOrg" | "insertMany" | "delete" >; groupDAL: Pick; licenseService: Pick; + orgMembershipDAL: Pick< + TOrgMembershipDALFactory, + "find" | "findOrgMembershipById" | "findOrgMembershipsWithUsersByOrgId" + >; }; export type TGithubOrgSyncServiceFactory = ReturnType; @@ -40,7 +92,8 @@ export const githubOrgSyncServiceFactory = ({ kmsService, userGroupMembershipDAL, groupDAL, - licenseService + licenseService, + orgMembershipDAL }: TGithubOrgSyncServiceFactoryDep) => { const createGithubOrgSync = async ({ githubOrgName, @@ -304,8 +357,8 @@ export const githubOrgSyncServiceFactory = ({ const removeFromTeams = infisicalUserGroups.filter((el) => !githubUserTeamSet.has(el.groupName)); if (newTeams.length || updateTeams.length || removeFromTeams.length) { - await groupDAL.transaction(async (tx) => { - if (newTeams.length) { + if (newTeams.length) { + await groupDAL.transaction(async (tx) => { const newGroups = await groupDAL.insertMany( newTeams.map((newGroupName) => ({ name: newGroupName, @@ -322,9 +375,11 @@ export const githubOrgSyncServiceFactory = ({ })), tx ); - } + }); + } - if (updateTeams.length) { + if (updateTeams.length) { + await groupDAL.transaction(async (tx) => { await userGroupMembershipDAL.insertMany( updateTeams.map((el) => ({ groupId: githubUserTeamOnInfisicalGroupByName[el][0].id, @@ -332,16 +387,433 @@ export const githubOrgSyncServiceFactory = ({ })), tx ); - } + }); + } - if (removeFromTeams.length) { + if (removeFromTeams.length) { + await groupDAL.transaction(async (tx) => { await userGroupMembershipDAL.delete( { userId, $in: { groupId: removeFromTeams.map((el) => el.groupId) } }, tx ); - } + }); + } + } + }; + + const validateGithubToken = async ({ orgPermission, githubOrgAccessToken }: TValidateGithubTokenDTO) => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync); + + const plan = await licenseService.getPlan(orgPermission.orgId); + if (!plan.githubOrgSync) { + throw new BadRequestError({ + message: + "Failed to validate GitHub token due to plan restriction. Upgrade plan to use GitHub organization sync." }); } + + const config = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId }); + if (!config) { + throw new BadRequestError({ message: "GitHub organization sync is not configured" }); + } + + try { + const testOctokit = new OctokitRest({ + auth: githubOrgAccessToken, + request: { + signal: AbortSignal.timeout(10000) + } + }); + + const { data: org } = await testOctokit.rest.orgs.get({ + org: config.githubOrgName + }); + + const octokitGraphQL = new OctokitWithPlugin({ + auth: githubOrgAccessToken, + request: { + signal: AbortSignal.timeout(10000) + } + }); + + await octokitGraphQL.graphql(`query($org: String!) { organization(login: $org) { id name } }`, { + org: config.githubOrgName + }); + + return { + valid: true, + organizationInfo: { + id: org.id, + login: org.login, + name: org.name || org.login, + publicRepos: org.public_repos, + privateRepos: org.owned_private_repos || 0 + } + }; + } catch (error) { + logger.error(error, `GitHub token validation failed for org ${config.githubOrgName}`); + + const gitHubError = error as GitHubApiError; + const statusCode = gitHubError.status || gitHubError.response?.status; + if (statusCode) { + if (statusCode === 401) { + throw new BadRequestError({ + message: "GitHub access token is invalid or expired." + }); + } + if (statusCode === 403) { + throw new BadRequestError({ + message: + "GitHub access token lacks required permissions. Required: 1) 'read:org' scope for organization teams, 2) Token owner must be an organization member with team visibility access, 3) Organization settings must allow team visibility. Check GitHub token scopes and organization member permissions." + }); + } + if (statusCode === 404) { + throw new BadRequestError({ + message: `Organization '${config.githubOrgName}' not found or access token does not have access to it.` + }); + } + } + + throw new BadRequestError({ + message: `GitHub token validation failed: ${(error as Error).message}` + }); + } + }; + + const syncAllTeams = async ({ orgPermission }: TSyncAllTeamsDTO): Promise => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.GithubOrgSyncManual + ); + + const plan = await licenseService.getPlan(orgPermission.orgId); + if (!plan.githubOrgSync) { + throw new BadRequestError({ + message: + "Failed to sync all GitHub teams due to plan restriction. Upgrade plan to use GitHub organization sync." + }); + } + + const config = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId }); + if (!config || !config?.isActive) { + throw new BadRequestError({ message: "GitHub organization sync is not configured or not active" }); + } + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: orgPermission.orgId + }); + + if (!config.encryptedGithubOrgAccessToken) { + throw new BadRequestError({ + message: "GitHub organization access token is required. Please set a token first." + }); + } + + const orgAccessToken = decryptor({ cipherTextBlob: config.encryptedGithubOrgAccessToken }).toString(); + + try { + const testOctokit = new OctokitRest({ + auth: orgAccessToken, + request: { + signal: AbortSignal.timeout(10000) + } + }); + + await testOctokit.rest.orgs.get({ + org: config.githubOrgName + }); + + await testOctokit.rest.users.getAuthenticated(); + } catch (error) { + throw new BadRequestError({ + message: "Stored GitHub access token is invalid or expired. Please set a new token." + }); + } + + const allMembers = await orgMembershipDAL.findOrgMembershipsWithUsersByOrgId(orgPermission.orgId); + const activeMembers = allMembers.filter( + (member) => member.status === "accepted" && member.isActive + ) as OrgMembershipWithUser[]; + + const startTime = Date.now(); + const syncErrors: string[] = []; + + const octokit = new OctokitWithPlugin({ + auth: orgAccessToken, + request: { + signal: AbortSignal.timeout(30000) + } + }); + + const data = await retryWithBackoff(async () => { + return octokit.graphql + .paginate<{ + organization: { + teams: { + totalCount: number; + edges: { + node: { + name: string; + description: string; + members: { + edges: { + node: { + login: string; + }; + }[]; + }; + }; + }[]; + }; + }; + }>( + ` + query orgTeams($cursor: String, $org: String!) { + organization(login: $org) { + teams(first: 100, after: $cursor) { + totalCount + edges { + node { + name + description + members(first: 100) { + edges { + node { + login + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `, + { + org: config.githubOrgName + } + ) + .catch((err) => { + logger.error(err, "GitHub GraphQL error for batched team sync"); + + const gitHubError = err as GitHubApiError; + const statusCode = gitHubError.status || gitHubError.response?.status; + if (statusCode) { + if (statusCode === 401) { + throw new BadRequestError({ + message: "GitHub access token is invalid or expired. Please provide a new token." + }); + } + if (statusCode === 403) { + throw new BadRequestError({ + message: + "GitHub access token lacks required permissions for organization team sync. Required: 1) 'admin:org' scope, 2) Token owner must be organization owner or have team read permissions, 3) Organization settings must allow team visibility. Check token scopes and user role." + }); + } + if (statusCode === 404) { + throw new BadRequestError({ + message: `Organization ${config.githubOrgName} not found or access token does not have sufficient permissions to read it.` + }); + } + } + + if ((err as Error)?.message?.includes("Although you appear to have the correct authorization credential")) { + throw new BadRequestError({ + message: + "Organization has restricted OAuth app access. Please check that: 1) Your organization has approved the Infisical OAuth application, 2) The token owner has sufficient organization permissions." + }); + } + throw new BadRequestError({ message: `GitHub GraphQL query failed: ${(err as Error)?.message}` }); + }); + }); + + const { + organization: { teams } + } = data; + + const userTeamMap = new Map(); + const allGithubUsernamesInTeams = new Set(); + + teams?.edges?.forEach((teamEdge) => { + const teamName = teamEdge.node.name.toLowerCase(); + + teamEdge.node.members.edges.forEach((memberEdge) => { + const username = memberEdge.node.login.toLowerCase(); + allGithubUsernamesInTeams.add(username); + + if (!userTeamMap.has(username)) { + userTeamMap.set(username, []); + } + userTeamMap.get(username)!.push(teamName); + }); + }); + + const allGithubTeamNames = Array.from(new Set(teams?.edges?.map((edge) => edge.node.name.toLowerCase()) || [])); + + const existingTeamsOnInfisical = await groupDAL.find({ + orgId: orgPermission.orgId, + $in: { name: allGithubTeamNames } + }); + const existingTeamsMap = groupBy(existingTeamsOnInfisical, (i) => i.name); + + const teamsToCreate = allGithubTeamNames.filter((teamName) => !(teamName in existingTeamsMap)); + const createdTeams = new Set(); + const updatedTeams = new Set(); + const totalRemovedMemberships = 0; + + await groupDAL.transaction(async (tx) => { + if (teamsToCreate.length > 0) { + const newGroups = await groupDAL.insertMany( + teamsToCreate.map((teamName) => ({ + name: teamName, + role: OrgMembershipRole.Member, + slug: teamName, + orgId: orgPermission.orgId + })), + tx + ); + + newGroups.forEach((group) => { + if (!existingTeamsMap[group.name]) { + existingTeamsMap[group.name] = []; + } + existingTeamsMap[group.name].push(group); + createdTeams.add(group.name); + }); + } + + const allTeams = [...Object.values(existingTeamsMap).flat()]; + + for (const team of allTeams) { + const teamName = team.name.toLowerCase(); + + const currentMemberships = (await userGroupMembershipDAL.findGroupMembershipsByGroupIdInOrg( + team.id, + orgPermission.orgId + )) as GroupMembership[]; + + const expectedUserIds = new Set(); + teams?.edges?.forEach((teamEdge) => { + if (teamEdge.node.name.toLowerCase() === teamName) { + teamEdge.node.members.edges.forEach((memberEdge) => { + const githubUsername = memberEdge.node.login.toLowerCase(); + + const matchingMember = activeMembers.find((member) => { + const email = member.user?.email || member.inviteEmail; + if (!email) return false; + + const emailPrefix = email.split("@")[0].toLowerCase(); + const emailDomain = email.split("@")[1].toLowerCase(); + + if (emailPrefix === githubUsername) { + return true; + } + const domainName = emailDomain.split(".")[0]; + if (githubUsername.endsWith(domainName) && githubUsername.length > domainName.length) { + const baseUsername = githubUsername.slice(0, -domainName.length); + if (emailPrefix === baseUsername) { + return true; + } + } + const emailSplitRegex = new RE2(/[._-]/); + const emailParts = emailPrefix.split(emailSplitRegex); + const longestEmailPart = emailParts.reduce((a, b) => (a.length > b.length ? a : b), ""); + if (longestEmailPart.length >= 4 && githubUsername.includes(longestEmailPart)) { + return true; + } + return false; + }); + + if (matchingMember?.user?.id) { + expectedUserIds.add(matchingMember.user.id); + logger.info( + `Matched GitHub user ${githubUsername} to email ${matchingMember.user?.email || matchingMember.inviteEmail}` + ); + } + }); + } + }); + + const currentUserIds = new Set(); + currentMemberships.forEach((membership) => { + const activeMember = activeMembers.find((am) => am.id === membership.orgMembershipId); + if (activeMember?.user?.id) { + currentUserIds.add(activeMember.user.id); + } + }); + + const usersToAdd = Array.from(expectedUserIds).filter((userId) => !currentUserIds.has(userId)); + + const membershipsToRemove = currentMemberships.filter((membership) => { + const activeMember = activeMembers.find((am) => am.id === membership.orgMembershipId); + return activeMember?.user?.id && !expectedUserIds.has(activeMember.user.id); + }); + + if (usersToAdd.length > 0) { + await userGroupMembershipDAL.insertMany( + usersToAdd.map((userId) => ({ + userId, + groupId: team.id + })), + tx + ); + updatedTeams.add(teamName); + } + + if (membershipsToRemove.length > 0) { + await userGroupMembershipDAL.delete( + { + $in: { + id: membershipsToRemove.map((m) => m.id) + } + }, + tx + ); + updatedTeams.add(teamName); + } + } + }); + + const syncDuration = Date.now() - startTime; + + logger.info( + { + orgId: orgPermission.orgId, + createdTeams: createdTeams.size, + syncDuration + }, + "GitHub team sync completed" + ); + + return { + totalUsers: activeMembers.length, + errors: syncErrors, + createdTeams: Array.from(createdTeams), + updatedTeams: Array.from(updatedTeams), + removedMemberships: totalRemovedMemberships, + syncDuration + }; }; return { @@ -349,6 +821,8 @@ export const githubOrgSyncServiceFactory = ({ updateGithubOrgSync, deleteGithubOrgSync, getGithubOrgSync, - syncUserGroups + syncUserGroups, + syncAllTeams, + validateGithubToken }; }; diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-types.ts b/backend/src/ee/services/github-org-sync/github-org-sync-types.ts index e1df71e82..909414c2f 100644 --- a/backend/src/ee/services/github-org-sync/github-org-sync-types.ts +++ b/backend/src/ee/services/github-org-sync/github-org-sync-types.ts @@ -21,3 +21,21 @@ export interface TDeleteGithubOrgSyncDTO { export interface TGetGithubOrgSyncDTO { orgPermission: OrgServiceActor; } + +export interface TSyncAllTeamsDTO { + orgPermission: OrgServiceActor; +} + +export interface TSyncResult { + totalUsers: number; + errors: string[]; + createdTeams: string[]; + updatedTeams: string[]; + removedMemberships: number; + syncDuration: number; +} + +export interface TValidateGithubTokenDTO { + orgPermission: OrgServiceActor; + githubOrgAccessToken: string; +} diff --git a/backend/src/ee/services/group/group-fns.ts b/backend/src/ee/services/group/group-fns.ts index 436b0b79e..56f8df6c0 100644 --- a/backend/src/ee/services/group/group-fns.ts +++ b/backend/src/ee/services/group/group-fns.ts @@ -1,6 +1,6 @@ import { Knex } from "knex"; -import { SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; +import { ProjectVersion, SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError, ScimRequestError } from "@app/lib/errors"; @@ -65,6 +65,18 @@ const addAcceptedUsersToGroup = async ({ const userKeysSet = new Set(keys.map((k) => `${k.projectId}-${k.receiverId}`)); for await (const projectId of projectIds) { + const project = await projectDAL.findById(projectId, tx); + if (!project) { + throw new NotFoundError({ + message: `Failed to find project with ID '${projectId}'` + }); + } + + if (project.version !== ProjectVersion.V1 && project.version !== ProjectVersion.V2) { + // eslint-disable-next-line no-continue + continue; + } + const usersToAddProjectKeyFor = users.filter((u) => !userKeysSet.has(`${projectId}-${u.userId}`)); if (usersToAddProjectKeyFor.length) { @@ -86,6 +98,12 @@ const addAcceptedUsersToGroup = async ({ }); } + if (!ghostUserLatestKey.sender.publicKey) { + throw new NotFoundError({ + message: `Failed to find project owner's public key in project with ID '${projectId}'` + }); + } + const bot = await projectBotDAL.findOne({ projectId }, tx); if (!bot) { @@ -112,6 +130,12 @@ const addAcceptedUsersToGroup = async ({ }); const projectKeysToAdd = usersToAddProjectKeyFor.map((user) => { + if (!user.publicKey) { + throw new NotFoundError({ + message: `Failed to find user's public key in project with ID '${projectId}'` + }); + } + const { ciphertext: encryptedKey, nonce } = crypto .encryption() .asymmetric() diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 55665c146..46c9831b0 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -41,7 +41,7 @@ type TGroupServiceFactoryDep = { TUserGroupMembershipDALFactory, "findOne" | "delete" | "filterProjectsByUserMembership" | "transaction" | "insertMany" | "find" >; - projectDAL: Pick; + projectDAL: Pick; projectBotDAL: Pick; projectKeyDAL: Pick; permissionService: Pick; diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index 1d7c5fc71..e91f7a47b 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -65,7 +65,7 @@ export type TAddUsersToGroup = { userGroupMembershipDAL: Pick; groupProjectDAL: Pick; projectKeyDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; projectBotDAL: Pick; tx: Knex; }; @@ -78,7 +78,7 @@ export type TAddUsersToGroupByUserIds = { orgDAL: Pick; groupProjectDAL: Pick; projectKeyDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; projectBotDAL: Pick; tx?: Knex; }; @@ -102,7 +102,7 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = { >; groupProjectDAL: Pick; projectKeyDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; projectBotDAL: Pick; tx?: Knex; }; diff --git a/backend/src/ee/services/identity-auth-template/identity-auth-template-dal.ts b/backend/src/ee/services/identity-auth-template/identity-auth-template-dal.ts new file mode 100644 index 000000000..736f0f033 --- /dev/null +++ b/backend/src/ee/services/identity-auth-template/identity-auth-template-dal.ts @@ -0,0 +1,83 @@ +/* eslint-disable no-case-declarations */ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { buildFindFilter, ormify } from "@app/lib/knex"; + +import { IdentityAuthTemplateMethod } from "./identity-auth-template-enums"; + +export type TIdentityAuthTemplateDALFactory = ReturnType; + +export const identityAuthTemplateDALFactory = (db: TDbClient) => { + const identityAuthTemplateOrm = ormify(db, TableName.IdentityAuthTemplate); + + const findByOrgId = async ( + orgId: string, + { limit, offset, search, tx }: { limit?: number; offset?: number; search?: string; tx?: Knex } = {} + ) => { + let query = (tx || db.replicaNode())(TableName.IdentityAuthTemplate).where({ orgId }); + let countQuery = (tx || db.replicaNode())(TableName.IdentityAuthTemplate).where({ orgId }); + + if (search) { + const searchFilter = `%${search.toLowerCase()}%`; + query = query.whereRaw("LOWER(name) LIKE ?", [searchFilter]); + countQuery = countQuery.whereRaw("LOWER(name) LIKE ?", [searchFilter]); + } + + query = query.orderBy("createdAt", "desc"); + + if (limit !== undefined) { + query = query.limit(limit); + } + if (offset !== undefined) { + query = query.offset(offset); + } + + const docs = await query; + + const [{ count }] = (await countQuery.count("* as count")) as [{ count: string | number }]; + + return { docs, totalCount: Number(count) }; + }; + + const findByAuthMethod = async (authMethod: string, orgId: string, tx?: Knex) => { + const query = (tx || db.replicaNode())(TableName.IdentityAuthTemplate) + .where({ authMethod, orgId }) + .orderBy("createdAt", "desc"); + const docs = await query; + return docs; + }; + + const findTemplateUsages = async (templateId: string, authMethod: string, tx?: Knex) => { + switch (authMethod) { + case IdentityAuthTemplateMethod.LDAP: + const query = (tx || db.replicaNode())(TableName.IdentityLdapAuth) + .join(TableName.Identity, `${TableName.IdentityLdapAuth}.identityId`, `${TableName.Identity}.id`) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ templateId }, TableName.IdentityLdapAuth)) + .select( + db.ref("identityId").withSchema(TableName.IdentityLdapAuth), + db.ref("name").withSchema(TableName.Identity).as("identityName") + ); + const docs = await query; + return docs; + default: + return []; + } + }; + + const findByIdAndOrgId = async (id: string, orgId: string, tx?: Knex) => { + const query = (tx || db.replicaNode())(TableName.IdentityAuthTemplate).where({ id, orgId }); + const doc = await query; + return doc?.[0]; + }; + + return { + ...identityAuthTemplateOrm, + findByOrgId, + findByAuthMethod, + findTemplateUsages, + findByIdAndOrgId + }; +}; diff --git a/backend/src/ee/services/identity-auth-template/identity-auth-template-enums.ts b/backend/src/ee/services/identity-auth-template/identity-auth-template-enums.ts new file mode 100644 index 000000000..c5b47b158 --- /dev/null +++ b/backend/src/ee/services/identity-auth-template/identity-auth-template-enums.ts @@ -0,0 +1,22 @@ +export enum IdentityAuthTemplateMethod { + LDAP = "ldap" +} + +export const TEMPLATE_VALIDATION_MESSAGES = { + TEMPLATE_NAME_REQUIRED: "Template name is required", + TEMPLATE_NAME_MAX_LENGTH: "Template name must be at most 64 characters long", + AUTH_METHOD_REQUIRED: "Auth method is required", + TEMPLATE_ID_REQUIRED: "Template ID is required", + LDAP: { + URL_REQUIRED: "LDAP URL is required", + BIND_DN_REQUIRED: "Bind DN is required", + BIND_PASSWORD_REQUIRED: "Bind password is required", + SEARCH_BASE_REQUIRED: "Search base is required" + } +} as const; + +export const TEMPLATE_SUCCESS_MESSAGES = { + CREATED: "Template created successfully", + UPDATED: "Template updated successfully", + DELETED: "Template deleted successfully" +} as const; diff --git a/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts b/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts new file mode 100644 index 000000000..ef071742d --- /dev/null +++ b/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts @@ -0,0 +1,454 @@ +import { ForbiddenError } from "@casl/ability"; + +import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { + OrgPermissionMachineIdentityAuthTemplateActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TOrgPermission } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TIdentityLdapAuthDALFactory } from "@app/services/identity-ldap-auth/identity-ldap-auth-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TIdentityAuthTemplateDALFactory } from "./identity-auth-template-dal"; +import { IdentityAuthTemplateMethod } from "./identity-auth-template-enums"; +import { + TDeleteIdentityAuthTemplateDTO, + TFindTemplateUsagesDTO, + TGetIdentityAuthTemplateDTO, + TGetTemplatesByAuthMethodDTO, + TLdapTemplateFields, + TListIdentityAuthTemplatesDTO, + TUnlinkTemplateUsageDTO +} from "./identity-auth-template-types"; + +type TIdentityAuthTemplateServiceFactoryDep = { + identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; + identityLdapAuthDAL: TIdentityLdapAuthDALFactory; + permissionService: Pick; + kmsService: Pick; + licenseService: Pick; + auditLogService: Pick; +}; + +export type TIdentityAuthTemplateServiceFactory = ReturnType; + +export const identityAuthTemplateServiceFactory = ({ + identityAuthTemplateDAL, + identityLdapAuthDAL, + permissionService, + kmsService, + licenseService, + auditLogService +}: TIdentityAuthTemplateServiceFactoryDep) => { + // Plan check + const $checkPlan = async (orgId: string) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.machineIdentityAuthTemplates) + throw new BadRequestError({ + message: + "Failed to use identity auth template due to plan restriction. Upgrade plan to access machine identity auth templates." + }); + }; + const createTemplate = async ({ + name, + authMethod, + templateFields, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: { + name: string; + authMethod: string; + templateFields: Record; + } & Omit) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + const template = await identityAuthTemplateDAL.create({ + name, + authMethod, + templateFields: encryptor({ plainText: Buffer.from(JSON.stringify(templateFields)) }).cipherTextBlob, + orgId: actorOrgId + }); + + return { ...template, templateFields }; + }; + + const updateTemplate = async ({ + templateId, + name, + templateFields, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: { + templateId: string; + name?: string; + templateFields?: Record; + } & Omit) => { + await $checkPlan(actorOrgId); + const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + template.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: template.orgId + }); + + let finalTemplateFields: Record = {}; + + const updatedTemplate = await identityAuthTemplateDAL.transaction(async (tx) => { + const authTemplate = await identityAuthTemplateDAL.updateById( + templateId, + { + name, + ...(templateFields && { + templateFields: encryptor({ plainText: Buffer.from(JSON.stringify(templateFields)) }).cipherTextBlob + }) + }, + tx + ); + + if (templateFields && template.authMethod === IdentityAuthTemplateMethod.LDAP) { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: template.orgId + }); + + const currentTemplateFields = JSON.parse( + decryptor({ cipherTextBlob: template.templateFields }).toString() + ) as TLdapTemplateFields; + + const mergedTemplateFields: TLdapTemplateFields = { ...currentTemplateFields, ...templateFields }; + finalTemplateFields = mergedTemplateFields; + const ldapUpdateData: { + url?: string; + searchBase?: string; + encryptedBindDN?: Buffer; + encryptedBindPass?: Buffer; + encryptedLdapCaCertificate?: Buffer; + } = {}; + + if ("url" in templateFields) { + ldapUpdateData.url = mergedTemplateFields.url; + } + if ("searchBase" in templateFields) { + ldapUpdateData.searchBase = mergedTemplateFields.searchBase; + } + if ("bindDN" in templateFields) { + ldapUpdateData.encryptedBindDN = encryptor({ + plainText: Buffer.from(mergedTemplateFields.bindDN) + }).cipherTextBlob; + } + if ("bindPass" in templateFields) { + ldapUpdateData.encryptedBindPass = encryptor({ + plainText: Buffer.from(mergedTemplateFields.bindPass) + }).cipherTextBlob; + } + if ("ldapCaCertificate" in templateFields) { + ldapUpdateData.encryptedLdapCaCertificate = encryptor({ + plainText: Buffer.from(mergedTemplateFields.ldapCaCertificate || "") + }).cipherTextBlob; + } + + if (Object.keys(ldapUpdateData).length > 0) { + const updatedLdapAuths = await identityLdapAuthDAL.update({ templateId }, ldapUpdateData, tx); + await Promise.all( + updatedLdapAuths.map(async (updatedLdapAuth) => { + await auditLogService.createAuditLog({ + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + orgId: actorOrgId, + event: { + type: EventType.UPDATE_IDENTITY_LDAP_AUTH, + metadata: { + identityId: updatedLdapAuth.identityId, + templateId: template.id + } + } + }); + }) + ); + } + } + return authTemplate; + }); + + return { ...updatedTemplate, templateFields: finalTemplateFields }; + }; + + const deleteTemplate = async ({ + templateId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TDeleteIdentityAuthTemplateDTO) => { + await $checkPlan(actorOrgId); + const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + template.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const deletedTemplate = await identityAuthTemplateDAL.transaction(async (tx) => { + // Remove template reference from identityLdapAuth records + const updatedLdapAuths = await identityLdapAuthDAL.update({ templateId }, { templateId: null }, tx); + await Promise.all( + updatedLdapAuths.map(async (updatedLdapAuth) => { + await auditLogService.createAuditLog({ + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + orgId: actorOrgId, + event: { + type: EventType.UPDATE_IDENTITY_LDAP_AUTH, + metadata: { + identityId: updatedLdapAuth.identityId, + templateId: template.id + } + } + }); + }) + ); + + // Delete the template + const [deletedTpl] = await identityAuthTemplateDAL.delete({ id: templateId }, tx); + return deletedTpl; + }); + + return deletedTemplate; + }; + + const getTemplate = async ({ + templateId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetIdentityAuthTemplateDTO) => { + await $checkPlan(actorOrgId); + const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + template.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: template.orgId + }); + const decryptedTemplateFields = decryptor({ cipherTextBlob: template.templateFields }).toString(); + return { + ...template, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + templateFields: JSON.parse(decryptedTemplateFields) + }; + }; + + const listTemplates = async ({ + limit, + offset, + search, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListIdentityAuthTemplatesDTO) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const { docs, totalCount } = await identityAuthTemplateDAL.findByOrgId(actorOrgId, { limit, offset, search }); + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + return { + totalCount, + templates: docs.map((doc) => ({ + ...doc, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + templateFields: JSON.parse(decryptor({ cipherTextBlob: doc.templateFields }).toString()) + })) + }; + }; + + const getTemplatesByAuthMethod = async ({ + authMethod, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetTemplatesByAuthMethodDTO) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const docs = await identityAuthTemplateDAL.findByAuthMethod(authMethod, actorOrgId); + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + return docs.map((doc) => ({ + ...doc, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + templateFields: JSON.parse(decryptor({ cipherTextBlob: doc.templateFields }).toString()) + })); + }; + + const findTemplateUsages = async ({ + templateId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TFindTemplateUsagesDTO) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + const docs = await identityAuthTemplateDAL.findTemplateUsages(templateId, template.authMethod); + return docs; + }; + + const unlinkTemplateUsage = async ({ + templateId, + identityIds, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUnlinkTemplateUsageDTO) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + switch (template.authMethod) { + case IdentityAuthTemplateMethod.LDAP: + await identityLdapAuthDAL.update({ $in: { identityId: identityIds }, templateId }, { templateId: null }); + break; + default: + break; + } + }; + + return { + createTemplate, + updateTemplate, + deleteTemplate, + getTemplate, + listTemplates, + getTemplatesByAuthMethod, + findTemplateUsages, + unlinkTemplateUsage + }; +}; diff --git a/backend/src/ee/services/identity-auth-template/identity-auth-template-types.ts b/backend/src/ee/services/identity-auth-template/identity-auth-template-types.ts new file mode 100644 index 000000000..8039e41c2 --- /dev/null +++ b/backend/src/ee/services/identity-auth-template/identity-auth-template-types.ts @@ -0,0 +1,61 @@ +import { TProjectPermission } from "@app/lib/types"; + +import { IdentityAuthTemplateMethod } from "./identity-auth-template-enums"; + +// Method-specific template field types +export type TLdapTemplateFields = { + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + ldapCaCertificate?: string; +}; + +// Union type for all template field types +export type TTemplateFieldsByMethod = { + [IdentityAuthTemplateMethod.LDAP]: TLdapTemplateFields; +}; + +// Generic base types that use conditional types for type safety +export type TCreateIdentityAuthTemplateDTO = { + name: string; + authMethod: IdentityAuthTemplateMethod; + templateFields: TTemplateFieldsByMethod[IdentityAuthTemplateMethod]; +} & Omit; + +export type TUpdateIdentityAuthTemplateDTO = { + templateId: string; + name?: string; + templateFields?: Partial; +} & Omit; + +export type TDeleteIdentityAuthTemplateDTO = { + templateId: string; +} & Omit; + +export type TGetIdentityAuthTemplateDTO = { + templateId: string; +} & Omit; + +export type TListIdentityAuthTemplatesDTO = { + limit?: number; + offset?: number; + search?: string; +} & Omit; + +export type TGetTemplatesByAuthMethodDTO = { + authMethod: string; +} & Omit; + +export type TFindTemplateUsagesDTO = { + templateId: string; +} & Omit; + +export type TUnlinkTemplateUsageDTO = { + templateId: string; + identityIds: string[]; +} & Omit; + +// Specific LDAP types for convenience +export type TCreateLdapTemplateDTO = TCreateIdentityAuthTemplateDTO; +export type TUpdateLdapTemplateDTO = TUpdateIdentityAuthTemplateDTO; diff --git a/backend/src/ee/services/identity-auth-template/index.ts b/backend/src/ee/services/identity-auth-template/index.ts new file mode 100644 index 000000000..4358d1a27 --- /dev/null +++ b/backend/src/ee/services/identity-auth-template/index.ts @@ -0,0 +1,6 @@ +export type { TIdentityAuthTemplateDALFactory } from "./identity-auth-template-dal"; +export { identityAuthTemplateDALFactory } from "./identity-auth-template-dal"; +export * from "./identity-auth-template-enums"; +export type { TIdentityAuthTemplateServiceFactory } from "./identity-auth-template-service"; +export { identityAuthTemplateServiceFactory } from "./identity-auth-template-service"; +export type * from "./identity-auth-template-types"; diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index f3aac5b92..4592cf003 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { Knex } from "knex"; import { OrgMembershipStatus, TableName, TLdapConfigsUpdate, TUsers } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; @@ -45,7 +46,7 @@ import { searchGroups, testLDAPConfig } from "./ldap-fns"; import { TLdapGroupMapDALFactory } from "./ldap-group-map-dal"; type TLdapConfigServiceFactoryDep = { - ldapConfigDAL: Pick; + ldapConfigDAL: Pick; ldapGroupMapDAL: Pick; orgMembershipDAL: Pick; orgDAL: Pick< @@ -55,7 +56,7 @@ type TLdapConfigServiceFactoryDep = { groupDAL: Pick; groupProjectDAL: Pick; projectKeyDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; projectBotDAL: Pick; userGroupMembershipDAL: Pick< TUserGroupMembershipDALFactory, @@ -126,11 +127,38 @@ export const ldapConfigServiceFactory = ({ message: "Failed to create LDAP configuration due to plan restriction. Upgrade plan to create LDAP configuration." }); + + const org = await orgDAL.findOrgById(orgId); + + if (!org) { + throw new NotFoundError({ message: `Could not find organization with ID "${orgId}"` }); + } + + if (org.googleSsoAuthEnforced && isActive) { + throw new BadRequestError({ + message: + "You cannot enable LDAP SSO while Google OAuth is enforced. Disable Google OAuth enforcement to enable LDAP SSO." + }); + } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId }); + const isConnected = await testLDAPConfig({ + bindDN, + bindPass, + caCert, + url + }); + + if (!isConnected) { + throw new BadRequestError({ + message: "Failed to establish connection to LDAP directory. Please verify that your credentials are correct." + }); + } + const ldapConfig = await ldapConfigDAL.create({ orgId, isActive, @@ -148,67 +176,8 @@ export const ldapConfigServiceFactory = ({ return ldapConfig; }; - const updateLdapCfg = async ({ - actor, - actorId, - orgId, - actorOrgId, - isActive, - actorAuthMethod, - url, - bindDN, - bindPass, - uniqueUserAttribute, - searchBase, - searchFilter, - groupSearchBase, - groupSearchFilter, - caCert - }: TUpdateLdapCfgDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); - - const plan = await licenseService.getPlan(orgId); - if (!plan.ldap) - throw new BadRequestError({ - message: - "Failed to update LDAP configuration due to plan restriction. Upgrade plan to update LDAP configuration." - }); - - const updateQuery: TLdapConfigsUpdate = { - isActive, - url, - searchBase, - searchFilter, - groupSearchBase, - groupSearchFilter, - uniqueUserAttribute - }; - - const { encryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId - }); - - if (bindDN !== undefined) { - updateQuery.encryptedLdapBindDN = encryptor({ plainText: Buffer.from(bindDN) }).cipherTextBlob; - } - - if (bindPass !== undefined) { - updateQuery.encryptedLdapBindPass = encryptor({ plainText: Buffer.from(bindPass) }).cipherTextBlob; - } - - if (caCert !== undefined) { - updateQuery.encryptedLdapCaCertificate = encryptor({ plainText: Buffer.from(caCert) }).cipherTextBlob; - } - - const [ldapConfig] = await ldapConfigDAL.update({ orgId }, updateQuery); - - return ldapConfig; - }; - - const getLdapCfg = async (filter: { orgId: string; isActive?: boolean; id?: string }) => { - const ldapConfig = await ldapConfigDAL.findOne(filter); + const getLdapCfg = async (filter: { orgId: string; isActive?: boolean; id?: string }, tx?: Knex) => { + const ldapConfig = await ldapConfigDAL.findOne(filter, tx); if (!ldapConfig) { throw new NotFoundError({ message: `Failed to find organization LDAP data in organization with ID '${filter.orgId}'` @@ -251,6 +220,94 @@ export const ldapConfigServiceFactory = ({ }; }; + const updateLdapCfg = async ({ + actor, + actorId, + orgId, + actorOrgId, + isActive, + actorAuthMethod, + url, + bindDN, + bindPass, + uniqueUserAttribute, + searchBase, + searchFilter, + groupSearchBase, + groupSearchFilter, + caCert + }: TUpdateLdapCfgDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); + + const plan = await licenseService.getPlan(orgId); + if (!plan.ldap) + throw new BadRequestError({ + message: + "Failed to update LDAP configuration due to plan restriction. Upgrade plan to update LDAP configuration." + }); + + const org = await orgDAL.findOrgById(orgId); + + if (!org) { + throw new NotFoundError({ message: `Could not find organization with ID "${orgId}"` }); + } + + if (org.googleSsoAuthEnforced && isActive) { + throw new BadRequestError({ + message: + "You cannot enable LDAP SSO while Google OAuth is enforced. Disable Google OAuth enforcement to enable LDAP SSO." + }); + } + + const updateQuery: TLdapConfigsUpdate = { + isActive, + url, + searchBase, + searchFilter, + groupSearchBase, + groupSearchFilter, + uniqueUserAttribute + }; + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + if (bindDN !== undefined) { + updateQuery.encryptedLdapBindDN = encryptor({ plainText: Buffer.from(bindDN) }).cipherTextBlob; + } + + if (bindPass !== undefined) { + updateQuery.encryptedLdapBindPass = encryptor({ plainText: Buffer.from(bindPass) }).cipherTextBlob; + } + + if (caCert !== undefined) { + updateQuery.encryptedLdapCaCertificate = encryptor({ plainText: Buffer.from(caCert) }).cipherTextBlob; + } + + const config = await ldapConfigDAL.transaction(async (tx) => { + const [updatedLdapCfg] = await ldapConfigDAL.update({ orgId }, updateQuery, tx); + const decryptedLdapCfg = await getLdapCfg({ orgId }, tx); + + const isSoftDeletion = !decryptedLdapCfg.url && !decryptedLdapCfg.bindDN && !decryptedLdapCfg.bindPass; + if (!isSoftDeletion) { + const isConnected = await testLDAPConfig(decryptedLdapCfg); + if (!isConnected) { + throw new BadRequestError({ + message: + "Failed to establish connection to LDAP directory. Please verify that your credentials are correct." + }); + } + } + + return updatedLdapCfg; + }); + + return config; + }; + const getLdapCfgWithPermissionCheck = async ({ actor, actorId, @@ -370,15 +427,13 @@ export const ldapConfigServiceFactory = ({ userAlias = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; - if (serverCfg.trustLdapEmails) { - newUser = await userDAL.findOne( - { - email: email.toLowerCase(), - isEmailVerified: true - }, - tx - ); - } + newUser = await userDAL.findOne( + { + email: email.toLowerCase(), + isEmailVerified: true + }, + tx + ); if (!newUser) { const uniqueUsername = await normalizeUsername(username, userDAL); @@ -403,7 +458,8 @@ export const ldapConfigServiceFactory = ({ aliasType: UserAliasType.LDAP, externalId, emails: [email], - orgId + orgId, + isEmailVerified: serverCfg.trustLdapEmails }, tx ); @@ -526,16 +582,14 @@ export const ldapConfigServiceFactory = ({ return newUser; }); - const isUserCompleted = Boolean(user.isAccepted); - const userEnc = await userDAL.findUserEncKeyByUserId(user.id); - + const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - hasExchangedPrivateKey: Boolean(userEnc?.serverEncryptedPrivateKey), - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + hasExchangedPrivateKey: true, + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, @@ -543,6 +597,7 @@ export const ldapConfigServiceFactory = ({ organizationSlug: organization.slug, authMethod: AuthMethod.LDAP, authType: UserAliasType.LDAP, + aliasId: userAlias.id, isUserCompleted, ...(relayState ? { @@ -556,10 +611,11 @@ export const ldapConfigServiceFactory = ({ } ); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService.sendMail({ @@ -694,7 +750,17 @@ export const ldapConfigServiceFactory = ({ return deletedGroupMap; }; - const testLDAPConnection = async ({ actor, actorId, orgId, actorAuthMethod, actorOrgId }: TTestLdapConnectionDTO) => { + const testLDAPConnection = async ({ + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + bindDN, + bindPass, + caCert, + url + }: TTestLdapConnectionDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); @@ -704,11 +770,12 @@ export const ldapConfigServiceFactory = ({ message: "Failed to test LDAP connection due to plan restriction. Upgrade plan to test the LDAP connection." }); - const ldapConfig = await getLdapCfg({ - orgId + return testLDAPConfig({ + bindDN, + bindPass, + caCert, + url }); - - return testLDAPConfig(ldapConfig); }; return { diff --git a/backend/src/ee/services/ldap-config/ldap-config-types.ts b/backend/src/ee/services/ldap-config/ldap-config-types.ts index 941335fa4..effee5dac 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-types.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-types.ts @@ -83,6 +83,4 @@ export type TDeleteLdapGroupMapDTO = { ldapGroupMapId: string; } & TOrgPermission; -export type TTestLdapConnectionDTO = { - ldapConfigId: string; -} & TOrgPermission; +export type TTestLdapConnectionDTO = TOrgPermission & TTestLDAPConfigDTO; diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index 5259d4616..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,8 @@ 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 5b755567b..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, @@ -59,7 +60,9 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ secretScanning: false, enterpriseSecretSyncs: false, enterpriseAppConnections: false, - fips: false + fips: false, + eventSubscriptions: false, + machineIdentityAuthTemplates: false }); export const setupLicenseRequestWithStore = ( diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index bb208ab2e..23d8b0646 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -5,13 +5,14 @@ // TODO(akhilmhdh): With tony find out the api structure and fill it here import { ForbiddenError } from "@casl/ability"; +import { AxiosError } from "axios"; import { CronJob } from "cron"; import { Knex } from "knex"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { verifyOfflineLicense } from "@app/lib/crypto"; -import { NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TIdentityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; @@ -333,6 +334,8 @@ export const licenseServiceFactory = ({ }); } + await updateSubscriptionOrgMemberCount(orgId); + const { data: { url } } = await licenseServerCloudApi.request.post( @@ -614,10 +617,22 @@ export const licenseServiceFactory = ({ }); } - const { data } = await licenseServerCloudApi.request.delete( - `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods/${pmtMethodId}` - ); - return data; + try { + const { data } = await licenseServerCloudApi.request.delete( + `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods/${pmtMethodId}` + ); + return data; + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message: `Failed to remove payment method: ${error.response?.data?.message}` + }); + } + throw new BadRequestError({ + message: "Unable to remove payment method" + }); + } }; const getOrgTaxIds = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgTaxIdDTO) => { @@ -720,6 +735,16 @@ export const licenseServiceFactory = ({ await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); }; + const getCustomerId = () => { + if (!selfHostedLicense) return "unknown"; + return selfHostedLicense?.customerId; + }; + + const getLicenseId = () => { + if (!selfHostedLicense) return "unknown"; + return selfHostedLicense?.licenseId; + }; + return { generateOrgCustomerId, removeOrgCustomer, @@ -734,6 +759,8 @@ export const licenseServiceFactory = ({ return onPremFeatures; }, getPlan, + getCustomerId, + getLicenseId, invalidateGetPlan, updateSubscriptionOrgMemberCount, refreshPlan, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index ea94f6172..2ccd3ac8f 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; @@ -75,7 +76,9 @@ export type TFeatureSet = { secretScanning: false; enterpriseSecretSyncs: false; enterpriseAppConnections: false; + machineIdentityAuthTemplates: false; fips: false; + eventSubscriptions: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index ca6f67235..fb1e70a85 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -79,7 +79,7 @@ type TOidcConfigServiceFactoryDep = { >; groupProjectDAL: Pick; projectKeyDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; projectBotDAL: Pick; auditLogService: Pick; kmsService: Pick; @@ -180,7 +180,7 @@ export const oidcConfigServiceFactory = ({ } const appCfg = getConfig(); - const userAlias = await userAliasDAL.findOne({ + let userAlias = await userAliasDAL.findOne({ externalId, orgId, aliasType: UserAliasType.OIDC @@ -231,32 +231,29 @@ export const oidcConfigServiceFactory = ({ } else { user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; + // we prioritize getting the most complete user to create the new alias under + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); - if (serverCfg.trustOidcEmails) { - // we prioritize getting the most complete user to create the new alias under + if (!newUser) { + // this fetches user entries created via invites newUser = await userDAL.findOne( { - email, - isEmailVerified: true + username: email }, tx ); - if (!newUser) { - // this fetches user entries created via invites - newUser = await userDAL.findOne( - { - username: email - }, - tx - ); - - if (newUser && !newUser.isEmailVerified) { - // we automatically mark it as email-verified because we've configured trust for OIDC emails - newUser = await userDAL.updateById(newUser.id, { - isEmailVerified: true - }); - } + if (newUser && !newUser.isEmailVerified) { + // we automatically mark it as email-verified because we've configured trust for OIDC emails + newUser = await userDAL.updateById(newUser.id, { + isEmailVerified: serverCfg.trustOidcEmails + }); } } @@ -276,13 +273,14 @@ export const oidcConfigServiceFactory = ({ ); } - await userAliasDAL.create( + userAlias = await userAliasDAL.create( { userId: newUser.id, aliasType: UserAliasType.OIDC, externalId, emails: email ? [email] : [], - orgId + orgId, + isEmailVerified: serverCfg.trustOidcEmails }, tx ); @@ -404,20 +402,20 @@ export const oidcConfigServiceFactory = ({ await licenseService.updateSubscriptionOrgMemberCount(organization.id); - const userEnc = await userDAL.findUserEncKeyByUserId(user.id); - const isUserCompleted = Boolean(user.isAccepted); + const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, organizationId: organization.id, organizationSlug: organization.slug, - hasExchangedPrivateKey: Boolean(userEnc?.serverEncryptedPrivateKey), + hasExchangedPrivateKey: true, + aliasId: userAlias.id, authMethod: AuthMethod.OIDC, authType: UserAliasType.OIDC, isUserCompleted, @@ -431,10 +429,11 @@ export const oidcConfigServiceFactory = ({ await oidcConfigDAL.update({ orgId }, { lastUsed: new Date() }); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService @@ -500,6 +499,13 @@ export const oidcConfigServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + if (org.googleSsoAuthEnforced && isActive) { + throw new BadRequestError({ + message: + "You cannot enable OIDC SSO while Google OAuth is enforced. Disable Google OAuth enforcement to enable OIDC SSO." + }); + } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: org.id @@ -587,6 +593,13 @@ export const oidcConfigServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); + if (org.googleSsoAuthEnforced && isActive) { + throw new BadRequestError({ + message: + "You cannot enable OIDC SSO while Google OAuth is enforced. Disable Google OAuth enforcement to enable OIDC SSO." + }); + } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: org.id diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index cca4efaf2..9329c3c7f 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -2,6 +2,7 @@ import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability" import { ProjectPermissionActions, + ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionCommitsActions, @@ -13,6 +14,7 @@ import { ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, + ProjectPermissionSecretEventActions, ProjectPermissionSecretRotationActions, ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningDataSourceActions, @@ -252,6 +254,16 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretScanningConfigs ); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; @@ -383,7 +395,7 @@ const buildMemberPermissionRules = () => { ); can([ProjectPermissionActions.Read], ProjectPermissionSub.Role); - can([ProjectPermissionActions.Read], ProjectPermissionSub.AuditLogs); + can([ProjectPermissionAuditLogsActions.Read], ProjectPermissionSub.AuditLogs); can([ProjectPermissionActions.Read], ProjectPermissionSub.IpAllowList); // double check if all CRUD are needed for CA and Certificates @@ -455,6 +467,16 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; @@ -481,7 +503,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); - can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionAuditLogsActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates); @@ -505,6 +527,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/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index f0fe73d71..d4155d02c 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -23,11 +23,24 @@ export enum OrgPermissionAppConnectionActions { Connect = "connect" } +export enum OrgPermissionAuditLogsActions { + Read = "read" +} + export enum OrgPermissionKmipActions { Proxy = "proxy", Setup = "setup" } +export enum OrgPermissionMachineIdentityAuthTemplateActions { + ListTemplates = "list-templates", + EditTemplates = "edit-templates", + CreateTemplates = "create-templates", + DeleteTemplates = "delete-templates", + UnlinkTemplates = "unlink-templates", + AttachTemplates = "attach-templates" +} + export enum OrgPermissionAdminConsoleAction { AccessAllProjects = "access-all-projects" } @@ -81,6 +94,7 @@ export enum OrgPermissionSubjects { Sso = "sso", Scim = "scim", GithubOrgSync = "github-org-sync", + GithubOrgSyncManual = "github-org-sync-manual", Ldap = "ldap", Groups = "groups", Billing = "billing", @@ -88,6 +102,7 @@ export enum OrgPermissionSubjects { Identity = "identity", Kms = "kms", AdminConsole = "organization-admin-console", + MachineIdentityAuthTemplate = "machine-identity-auth-template", AuditLogs = "audit-logs", ProjectTemplates = "project-templates", AppConnections = "app-connections", @@ -109,13 +124,14 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync] + | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSyncManual] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionBillingActions, OrgPermissionSubjects.Billing] | [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity] | [OrgPermissionActions, OrgPermissionSubjects.Kms] - | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] + | [OrgPermissionAuditLogsActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionGatewayActions, OrgPermissionSubjects.Gateway] | [ @@ -126,6 +142,7 @@ export type OrgPermissionSet = ) ] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] + | [OrgPermissionMachineIdentityAuthTemplateActions, OrgPermissionSubjects.MachineIdentityAuthTemplate] | [OrgPermissionKmipActions, OrgPermissionSubjects.Kmip] | [OrgPermissionSecretShareAction, OrgPermissionSubjects.SecretShare]; @@ -177,6 +194,10 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ subject: z.literal(OrgPermissionSubjects.GithubOrgSync).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") }), + z.object({ + subject: z.literal(OrgPermissionSubjects.GithubOrgSyncManual).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), z.object({ subject: z.literal(OrgPermissionSubjects.Ldap).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") @@ -203,7 +224,9 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ }), z.object({ subject: z.literal(OrgPermissionSubjects.AuditLogs).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionAuditLogsActions).describe( + "Describe what action an entity can take." + ) }), z.object({ subject: z.literal(OrgPermissionSubjects.ProjectTemplates).describe("The entity this permission pertains to."), @@ -237,6 +260,14 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ "Describe what action an entity can take." ) }), + z.object({ + subject: z + .literal(OrgPermissionSubjects.MachineIdentityAuthTemplate) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionMachineIdentityAuthTemplateActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(OrgPermissionSubjects.Gateway).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionGatewayActions).describe( @@ -290,6 +321,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSync); can(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSync); + can(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Create, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); @@ -321,10 +357,7 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms); - can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); - can(OrgPermissionActions.Create, OrgPermissionSubjects.AuditLogs); - can(OrgPermissionActions.Edit, OrgPermissionSubjects.AuditLogs); - can(OrgPermissionActions.Delete, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionAuditLogsActions.Read, OrgPermissionSubjects.AuditLogs); can(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); can(OrgPermissionActions.Create, OrgPermissionSubjects.ProjectTemplates); @@ -350,6 +383,25 @@ const buildAdminPermission = () => { // the proxy assignment is temporary in order to prevent "more privilege" error during role assignment to MI can(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); + can(OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate); + can(OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate); + can( + OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can( + OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can( + OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can( + OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can(OrgPermissionSecretShareAction.ManageSettings, OrgPermissionSubjects.SecretShare); return rules; @@ -378,13 +430,23 @@ const buildMemberPermission = () => { can(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); can(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); - can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionAuditLogsActions.Read, OrgPermissionSubjects.AuditLogs); can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections); can(OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate); + can( + OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can( + OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + 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 966146c27..20b4344d3 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -157,6 +157,17 @@ 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 ProjectPermissionAuditLogsActions { + Read = "read" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -196,7 +207,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 = { @@ -206,6 +218,13 @@ export type SecretSubjectFields = { secretTags?: string[]; }; +export type SecretEventSubjectFields = { + environment: string; + secretPath: string; + secretName?: string; + secretTags?: string[]; +}; + export type SecretFolderSubjectFields = { environment: string; secretPath: string; @@ -289,7 +308,7 @@ export type ProjectPermissionSet = | [ProjectPermissionGroupActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] - | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] + | [ProjectPermissionAuditLogsActions, ProjectPermissionSub.AuditLogs] | [ProjectPermissionActions, ProjectPermissionSub.Environments] | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] @@ -342,7 +361,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([ @@ -483,7 +506,17 @@ const SecretConditionV2Schema = z .object({ [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] }) - .partial() + .partial(), + eventType: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]) }) .partial(); @@ -616,7 +649,7 @@ const GeneralPermissionSchema = [ }), z.object({ subject: z.literal(ProjectPermissionSub.AuditLogs).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionAuditLogsActions).describe( "Describe what action an entity can take." ) }), @@ -865,7 +898,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/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 0a46597b8..1cbe6a930 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -82,6 +82,19 @@ export const samlConfigServiceFactory = ({ "Failed to create SAML SSO configuration due to plan restriction. Upgrade plan to create SSO configuration." }); + const org = await orgDAL.findOrgById(orgId); + + if (!org) { + throw new NotFoundError({ message: `Could not find organization with ID "${orgId}"` }); + } + + if (org.googleSsoAuthEnforced && isActive) { + throw new BadRequestError({ + message: + "You cannot enable SAML SSO while Google OAuth is enforced. Disable Google OAuth enforcement to enable SAML SSO." + }); + } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId @@ -120,6 +133,19 @@ export const samlConfigServiceFactory = ({ "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." }); + const org = await orgDAL.findOrgById(orgId); + + if (!org) { + throw new NotFoundError({ message: `Could not find organization with ID "${orgId}"` }); + } + + if (org.googleSsoAuthEnforced && isActive) { + throw new BadRequestError({ + message: + "Cannot enable SAML SSO while Google OAuth is enforced. Disable Google OAuth enforcement to enable SAML SSO." + }); + } + const updateQuery: TSamlConfigsUpdate = { authProvider, isActive, lastUsed: null }; const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -246,7 +272,7 @@ export const samlConfigServiceFactory = ({ }); } - const userAlias = await userAliasDAL.findOne({ + let userAlias = await userAliasDAL.findOne({ externalId, orgId, aliasType: UserAliasType.SAML @@ -320,15 +346,13 @@ export const samlConfigServiceFactory = ({ user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; - if (serverCfg.trustSamlEmails) { - newUser = await userDAL.findOne( - { - email, - isEmailVerified: true - }, - tx - ); - } + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); if (!newUser) { const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); @@ -346,13 +370,14 @@ export const samlConfigServiceFactory = ({ ); } - await userAliasDAL.create( + userAlias = await userAliasDAL.create( { userId: newUser.id, aliasType: UserAliasType.SAML, externalId, emails: email ? [email] : [], - orgId + orgId, + isEmailVerified: serverCfg.trustSamlEmails }, tx ); @@ -410,21 +435,21 @@ export const samlConfigServiceFactory = ({ } await licenseService.updateSubscriptionOrgMemberCount(organization.id); - const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified); - const userEnc = await userDAL.findUserEncKeyByUserId(user.id); + const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified && userAlias.isEmailVerified); const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, organizationId: organization.id, organizationSlug: organization.slug, authMethod: authProvider, - hasExchangedPrivateKey: Boolean(userEnc?.serverEncryptedPrivateKey), + hasExchangedPrivateKey: true, + aliasId: userAlias.id, authType: UserAliasType.SAML, isUserCompleted, ...(relayState @@ -441,10 +466,11 @@ export const samlConfigServiceFactory = ({ await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService.sendMail({ diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index a87d67b94..9cc6e134d 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -59,7 +59,7 @@ type TScimServiceFactoryDep = { TOrgMembershipDALFactory, "find" | "findOne" | "create" | "updateById" | "findById" | "update" >; - projectDAL: Pick; + projectDAL: Pick; projectMembershipDAL: Pick; groupDAL: Pick< TGroupDALFactory, 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..fe4ca94e1 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), @@ -157,7 +180,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), tx.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"), tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), - tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt") + tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt"), + tx + .ref("shouldCheckSecretPermission") + .withSchema(TableName.SecretApprovalPolicy) + .as("policySecretReadAccessCompat") ); const findById = async (id: string, tx?: Knex) => { @@ -197,7 +224,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { enforcementLevel: el.policyEnforcementLevel, envId: el.policyEnvId, deletedAt: el.policyDeletedAt, - allowedSelfApprovals: el.policyAllowedSelfApprovals + allowedSelfApprovals: el.policyAllowedSelfApprovals, + shouldCheckSecretPermission: el.policySecretReadAccessCompat } }), childrenMapper: [ @@ -211,9 +239,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 +263,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 +282,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 }) }, { @@ -653,14 +697,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), db.ref("lastName").withSchema("committerUser").as("committerUserLastName") ) - .distinctOn(`${TableName.SecretApprovalRequest}.id`) .as("inner"); - const query = (tx || db) - .select("*") + const countQuery = (await (tx || db) .select(db.raw("count(*) OVER() as total_count")) - .from(innerQuery) - .orderBy("createdAt", "desc") as typeof innerQuery; + .from(innerQuery.clone().distinctOn(`${TableName.SecretApprovalRequest}.id`))) as Array<{ + total_count: number; + }>; + + const query = (tx || db).select("*").from(innerQuery).orderBy("createdAt", "desc") as typeof innerQuery; if (search) { void query.where((qb) => { @@ -686,8 +731,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .where("w.rank", ">=", rankOffset) .andWhere("w.rank", "<", rankOffset + limit); - // @ts-expect-error knex does not infer - const totalCount = Number(docs[0]?.total_count || 0); + const totalCount = Number(countQuery[0]?.total_count || 0); const formattedDoc = sqlNestRelationships({ data: docs, 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 d1eefe7e3..d485f7ea0 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 @@ -65,7 +65,10 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; -import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns"; +import { + hasSecretReadValueOrDescribePermission, + throwIfMissingSecretReadValueOrDescribePermission +} from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission"; import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; @@ -255,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` }); @@ -277,13 +281,28 @@ export const secretApprovalRequestServiceFactory = ({ ) { throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); } + const getHasSecretReadAccess = ( + shouldCheckSecretPermission: boolean | null | undefined, + environment: string, + tags: { slug: string }[], + secretPath?: string + ) => { + if (shouldCheckSecretPermission) { + const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath: secretPath || "/", + secretTags: tags.map((i) => i.slug) + }); + return canRead; + } - const hasSecretReadAccess = permission.can( - ProjectPermissionSecretActions.DescribeAndReadValue, - ProjectPermissionSub.Secrets - ); + return true; + }; let secrets; + const secretPath = await folderDAL.findSecretPathByFolderIds(secretApprovalRequest.projectId, [ + secretApprovalRequest.folderId + ]); if (shouldUseSecretV2Bridge) { const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -299,8 +318,18 @@ export const secretApprovalRequestServiceFactory = ({ version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, isRotatedSecret: el.secret?.isRotatedSecret ?? false, - secretValueHidden: !hasSecretReadAccess, - secretValue: !hasSecretReadAccess + secretValueHidden: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ), + secretValue: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secret && el.secret.isRotatedSecret ? undefined @@ -315,8 +344,18 @@ export const secretApprovalRequestServiceFactory = ({ secretKey: el.secret.key, id: el.secret.id, version: el.secret.version, - secretValueHidden: !hasSecretReadAccess, - secretValue: !hasSecretReadAccess + secretValueHidden: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ), + secretValue: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secret.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString() @@ -331,8 +370,18 @@ export const secretApprovalRequestServiceFactory = ({ secretKey: el.secretVersion.key, id: el.secretVersion.id, version: el.secretVersion.version, - secretValueHidden: !hasSecretReadAccess, - secretValue: !hasSecretReadAccess + secretValueHidden: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ), + secretValue: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secretVersion.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString() @@ -350,7 +399,12 @@ export const secretApprovalRequestServiceFactory = ({ const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); secrets = encryptedSecrets.map((el) => ({ ...el, - secretValueHidden: !hasSecretReadAccess, + secretValueHidden: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ), ...decryptSecretWithBot(el, botKey), secret: el.secret ? { @@ -370,9 +424,6 @@ export const secretApprovalRequestServiceFactory = ({ : undefined })); } - const secretPath = await folderDAL.findSecretPathByFolderIds(secretApprovalRequest.projectId, [ - secretApprovalRequest.folderId - ]); return { ...secretApprovalRequest, secretPath: secretPath?.[0]?.path || "/", commits: secrets }; }; @@ -938,13 +989,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) { @@ -1407,6 +1484,7 @@ export const secretApprovalRequestServiceFactory = ({ const commits: Omit[] = []; const commitTagIds: Record = {}; + const existingTagIds: Record = {}; const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -1472,6 +1550,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(",")}` @@ -1515,7 +1598,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-replication/secret-replication-service.ts b/backend/src/ee/services/secret-replication/secret-replication-service.ts index 4a5558f46..db41d00f7 100644 --- a/backend/src/ee/services/secret-replication/secret-replication-service.ts +++ b/backend/src/ee/services/secret-replication/secret-replication-service.ts @@ -59,7 +59,7 @@ type TSecretReplicationServiceFactoryDep = { TSecretVersionV2DALFactory, "find" | "insertMany" | "update" | "findLatestVersionMany" >; - secretImportDAL: Pick; + secretImportDAL: Pick; folderDAL: Pick< TSecretFolderDALFactory, "findSecretPathByFolderIds" | "findBySecretPath" | "create" | "findOne" | "findByManySecretPath" diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts index e2969ac1d..84fcfdd90 100644 --- a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts @@ -21,6 +21,8 @@ const GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"; type AzureErrorResponse = { error: { message: string } }; +const EXPIRY_PADDING_IN_DAYS = 3; + const sleep = async () => new Promise((resolve) => { setTimeout(resolve, 1000); @@ -33,7 +35,8 @@ export const azureClientSecretRotationFactory: TRotationFactory< const { connection, parameters: { objectId, clientId: clientIdParam }, - secretsMapping + secretsMapping, + rotationInterval } = secretRotation; /** @@ -50,7 +53,7 @@ export const azureClientSecretRotationFactory: TRotationFactory< )}-${now.getFullYear()}`; const endDateTime = new Date(); - endDateTime.setFullYear(now.getFullYear() + 5); + endDateTime.setDate(now.getDate() + rotationInterval * 2 + EXPIRY_PADDING_IN_DAYS); // give 72 hour buffer try { const { data } = await request.post( @@ -195,6 +198,12 @@ export const azureClientSecretRotationFactory: TRotationFactory< callback ) => { const credentials = await $rotateClientSecret(); + + // 2.5 years as expiry is set to x2 interval for the inactive period of credential + if (rotationInterval > Math.floor(365 * 2.5) - EXPIRY_PADDING_IN_DAYS) { + throw new BadRequestError({ message: "Azure does not support token duration over 5 years" }); + } + return callback(credentials); }; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts index c3e7a12fb..787c07bae 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts @@ -51,6 +51,7 @@ const baseSecretRotationV2Query = ({ db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"), db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("connectionGatewayId"), db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), db @@ -104,6 +105,7 @@ const expandSecretRotation = ; + 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/bitbucket/bitbucket-secret-scanning-factory.ts b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory.ts index f51c75daa..f4b4c03a9 100644 --- a/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory.ts +++ b/backend/src/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory.ts @@ -18,7 +18,8 @@ import { TSecretScanningFactoryInitialize, TSecretScanningFactoryListRawResources, TSecretScanningFactoryPostInitialization, - TSecretScanningFactoryTeardown + TSecretScanningFactoryTeardown, + TSecretScanningFactoryValidateConfigUpdate } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; @@ -302,6 +303,13 @@ export const BitbucketSecretScanningFactory = () => { ); }; + const validateConfigUpdate: TSecretScanningFactoryValidateConfigUpdate< + TBitbucketDataSourceInput["config"], + TBitbucketDataSourceWithConnection + > = async () => { + // no validation required + }; + return { initialize, postInitialization, @@ -309,6 +317,7 @@ export const BitbucketSecretScanningFactory = () => { getFullScanPath, getDiffScanResourcePayload, getDiffScanFindingsPayload, - teardown + teardown, + validateConfigUpdate }; }; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts index e09b8f88b..175e5d8cc 100644 --- a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts @@ -20,7 +20,8 @@ import { TSecretScanningFactoryInitialize, TSecretScanningFactoryListRawResources, TSecretScanningFactoryPostInitialization, - TSecretScanningFactoryTeardown + TSecretScanningFactoryTeardown, + TSecretScanningFactoryValidateConfigUpdate } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; @@ -64,7 +65,14 @@ export const GitHubSecretScanningFactory = () => { }; const teardown: TSecretScanningFactoryTeardown = async () => { - // no termination required + // no teardown required + }; + + const validateConfigUpdate: TSecretScanningFactoryValidateConfigUpdate< + TGitHubDataSourceInput["config"], + TGitHubDataSourceWithConnection + > = async () => { + // no validation required }; const listRawResources: TSecretScanningFactoryListRawResources = async ( @@ -238,6 +246,7 @@ export const GitHubSecretScanningFactory = () => { getFullScanPath, getDiffScanResourcePayload, getDiffScanFindingsPayload, - teardown + teardown, + validateConfigUpdate }; }; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-constants.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-constants.ts new file mode 100644 index 000000000..b472a2f3f --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-constants.ts @@ -0,0 +1,9 @@ +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TSecretScanningDataSourceListItem } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const GITLAB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION: TSecretScanningDataSourceListItem = { + name: "GitLab", + type: SecretScanningDataSource.GitLab, + connection: AppConnection.GitLab +}; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums.ts new file mode 100644 index 000000000..89caff802 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums.ts @@ -0,0 +1,8 @@ +export enum GitLabDataSourceScope { + Project = "project", + Group = "group" +} + +export enum GitLabWebHookEvent { + Push = "Push Hook" +} diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-factory.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-factory.ts new file mode 100644 index 000000000..6ea294c1f --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-factory.ts @@ -0,0 +1,409 @@ +import { Camelize, GitbeakerRequestError, GroupHookSchema, ProjectHookSchema } from "@gitbeaker/rest"; +import { join } from "path"; + +import { scanContentAndGetFindings } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { SecretMatch } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { + SecretScanningFindingSeverity, + SecretScanningResource +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + cloneRepository, + convertPatchLineToFileLineNumber, + replaceNonChangesWithNewlines +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { + TSecretScanningFactoryGetDiffScanFindingsPayload, + TSecretScanningFactoryGetDiffScanResourcePayload, + TSecretScanningFactoryGetFullScanPath, + TSecretScanningFactoryInitialize, + TSecretScanningFactoryListRawResources, + TSecretScanningFactoryParams, + TSecretScanningFactoryPostInitialization, + TSecretScanningFactoryTeardown, + TSecretScanningFactoryValidateConfigUpdate +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { titleCaseToCamelCase } from "@app/lib/fn"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { GitLabProjectRegex } from "@app/lib/regex"; +import { + getGitLabConnectionClient, + getGitLabInstanceUrl, + TGitLabConnection +} from "@app/services/app-connection/gitlab"; + +import { GitLabDataSourceScope } from "./gitlab-secret-scanning-enums"; +import { + TGitLabDataSourceCredentials, + TGitLabDataSourceInput, + TGitLabDataSourceWithConnection, + TQueueGitLabResourceDiffScan +} from "./gitlab-secret-scanning-types"; + +const getMainDomain = (instanceUrl: string) => { + const url = new URL(instanceUrl); + const { hostname } = url; + const parts = hostname.split("."); + + if (parts.length >= 2) { + return parts.slice(-2).join("."); + } + + return hostname; +}; + +export const GitLabSecretScanningFactory = ({ appConnectionDAL, kmsService }: TSecretScanningFactoryParams) => { + const initialize: TSecretScanningFactoryInitialize< + TGitLabDataSourceInput, + TGitLabConnection, + TGitLabDataSourceCredentials + > = async ({ payload: { config, name }, connection }, callback) => { + const token = alphaNumericNanoId(64); + + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + const appCfg = getConfig(); + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectId } = config; + const project = await client.Projects.show(projectId); + + if (!project) { + throw new BadRequestError({ message: `Could not find project with ID ${projectId}.` }); + } + + let hook: Camelize; + try { + hook = await client.ProjectHooks.add(projectId, `${appCfg.SITE_URL}/secret-scanning/webhooks/gitlab`, { + token, + pushEvents: true, + enableSslVerification: true, + // @ts-expect-error gitbeaker is outdated, and the types don't support this field yet + name: `Infisical Secret Scanning - ${name}` + }); + } catch (error) { + if (error instanceof GitbeakerRequestError) { + throw new BadRequestError({ message: `${error.message}: ${error.cause?.description ?? "Unknown Error"}` }); + } + + throw error; + } + + try { + return await callback({ + credentials: { + token, + hookId: hook.id + } + }); + } catch (error) { + try { + await client.ProjectHooks.remove(projectId, hook.id); + } catch { + // do nothing, just try to clean up webhook + } + + throw error; + } + } + + // group scope + const { groupId } = config; + + const group = await client.Groups.show(groupId); + + if (!group) { + throw new BadRequestError({ message: `Could not find group with ID ${groupId}.` }); + } + + let hook: Camelize; + try { + hook = await client.GroupHooks.add(groupId, `${appCfg.SITE_URL}/secret-scanning/webhooks/gitlab`, { + token, + pushEvents: true, + enableSslVerification: true, + // @ts-expect-error gitbeaker is outdated, and the types don't support this field yet + name: `Infisical Secret Scanning - ${name}` + }); + } catch (error) { + if (error instanceof GitbeakerRequestError) { + throw new BadRequestError({ message: `${error.message}: ${error.cause?.description ?? "Unknown Error"}` }); + } + + throw error; + } + + try { + return await callback({ + credentials: { + token, + hookId: hook.id + } + }); + } catch (error) { + try { + await client.GroupHooks.remove(groupId, hook.id); + } catch { + // do nothing, just try to clean up webhook + } + + throw error; + } + }; + + const postInitialization: TSecretScanningFactoryPostInitialization< + TGitLabDataSourceInput, + TGitLabConnection, + TGitLabDataSourceCredentials + > = async ({ connection, dataSourceId, credentials, payload: { config } }) => { + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + const appCfg = getConfig(); + + const hookUrl = `${appCfg.SITE_URL}/secret-scanning/webhooks/gitlab`; + const { hookId } = credentials; + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectId } = config; + + try { + await client.ProjectHooks.edit(projectId, hookId, hookUrl, { + // @ts-expect-error gitbeaker is outdated, and the types don't support this field yet + name: `Infisical Secret Scanning - ${dataSourceId}`, + custom_headers: [{ key: "x-data-source-id", value: dataSourceId }] + }); + } catch (error) { + try { + await client.ProjectHooks.remove(projectId, hookId); + } catch { + // do nothing, just try to clean up webhook + } + + throw error; + } + + return; + } + + // group-scope + const { groupId } = config; + + try { + await client.GroupHooks.edit(groupId, hookId, hookUrl, { + // @ts-expect-error gitbeaker is outdated, and the types don't support this field yet + name: `Infisical Secret Scanning - ${dataSourceId}`, + custom_headers: [{ key: "x-data-source-id", value: dataSourceId }] + }); + } catch (error) { + try { + await client.GroupHooks.remove(groupId, hookId); + } catch { + // do nothing, just try to clean up webhook + } + + throw error; + } + }; + + const listRawResources: TSecretScanningFactoryListRawResources = async ( + dataSource + ) => { + const { connection, config } = dataSource; + + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectId } = config; + + const project = await client.Projects.show(projectId); + + if (!project) { + throw new BadRequestError({ message: `Could not find project with ID ${projectId}.` }); + } + + // scott: even though we have this data we want to get potentially updated name + return [ + { + name: project.pathWithNamespace, + externalId: project.id.toString(), + type: SecretScanningResource.Project + } + ]; + } + + // group-scope + + const { groupId, includeProjects } = config; + + const projects = await client.Groups.allProjects(groupId, { + archived: false + }); + + const filteredProjects: typeof projects = []; + if (!includeProjects || includeProjects.includes("*")) { + filteredProjects.push(...projects); + } else { + filteredProjects.push(...projects.filter((project) => includeProjects.includes(project.pathWithNamespace))); + } + + return filteredProjects.map(({ id, pathWithNamespace }) => ({ + name: pathWithNamespace, + externalId: id.toString(), + type: SecretScanningResource.Project + })); + }; + + const getFullScanPath: TSecretScanningFactoryGetFullScanPath = async ({ + dataSource, + resourceName, + tempFolder + }) => { + const { connection } = dataSource; + + const instanceUrl = await getGitLabInstanceUrl(connection.credentials.instanceUrl); + + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + + const user = await client.Users.showCurrentUser(); + + const repoPath = join(tempFolder, "repo.git"); + + if (!GitLabProjectRegex.test(resourceName)) { + throw new Error("Invalid GitLab project name"); + } + + await cloneRepository({ + cloneUrl: `https://${user.username}:${connection.credentials.accessToken}@${getMainDomain(instanceUrl)}/${resourceName}.git`, + repoPath + }); + + return repoPath; + }; + + const teardown: TSecretScanningFactoryTeardown< + TGitLabDataSourceWithConnection, + TGitLabDataSourceCredentials + > = async ({ dataSource: { connection, config }, credentials: { hookId } }) => { + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectId } = config; + try { + await client.ProjectHooks.remove(projectId, hookId); + } catch (error) { + // do nothing, just try to clean up webhook + } + return; + } + + const { groupId } = config; + try { + await client.GroupHooks.remove(groupId, hookId); + } catch (error) { + // do nothing, just try to clean up webhook + } + }; + + const getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload< + TQueueGitLabResourceDiffScan["payload"] + > = ({ project }) => { + return { + name: project.path_with_namespace, + externalId: project.id.toString(), + type: SecretScanningResource.Project + }; + }; + + const getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload< + TGitLabDataSourceWithConnection, + TQueueGitLabResourceDiffScan["payload"] + > = async ({ dataSource, payload, resourceName, configPath }) => { + const { connection } = dataSource; + + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + + const { commits, project } = payload; + + const allFindings: SecretMatch[] = []; + + for (const commit of commits) { + // eslint-disable-next-line no-await-in-loop + const commitDiffs = await client.Commits.showDiff(project.id, commit.id); + + for (const commitDiff of commitDiffs) { + // eslint-disable-next-line no-continue + if (commitDiff.deletedFile) continue; + + // eslint-disable-next-line no-await-in-loop + const findings = await scanContentAndGetFindings( + replaceNonChangesWithNewlines(`\n${commitDiff.diff}`), + configPath + ); + + const adjustedFindings = findings.map((finding) => { + const startLine = convertPatchLineToFileLineNumber(commitDiff.diff, finding.StartLine); + const endLine = + finding.StartLine === finding.EndLine + ? startLine + : convertPatchLineToFileLineNumber(commitDiff.diff, finding.EndLine); + const startColumn = finding.StartColumn - 1; // subtract 1 for + + const endColumn = finding.EndColumn - 1; // subtract 1 for + + const authorName = commit.author.name; + const authorEmail = commit.author.email; + + return { + ...finding, + StartLine: startLine, + EndLine: endLine, + StartColumn: startColumn, + EndColumn: endColumn, + File: commitDiff.newPath, + Commit: commit.id, + Author: authorName, + Email: authorEmail, + Message: commit.message, + Fingerprint: `${commit.id}:${commitDiff.newPath}:${finding.RuleID}:${startLine}:${startColumn}`, + Date: commit.timestamp, + Link: `https://gitlab.com/${resourceName}/blob/${commit.id}/${commitDiff.newPath}#L${startLine}` + }; + }); + + allFindings.push(...adjustedFindings); + } + } + + return allFindings.map( + ({ + // discard match and secret as we don't want to store + Match, + Secret, + ...finding + }) => ({ + details: titleCaseToCamelCase(finding), + fingerprint: finding.Fingerprint, + severity: SecretScanningFindingSeverity.High, + rule: finding.RuleID + }) + ); + }; + + const validateConfigUpdate: TSecretScanningFactoryValidateConfigUpdate< + TGitLabDataSourceInput["config"], + TGitLabDataSourceWithConnection + > = async ({ config, dataSource }) => { + if (dataSource.config.scope !== config.scope) { + throw new BadRequestError({ message: "Cannot change Data Source scope after creation." }); + } + }; + + return { + listRawResources, + getFullScanPath, + initialize, + postInitialization, + teardown, + getDiffScanResourcePayload, + getDiffScanFindingsPayload, + validateConfigUpdate + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-schemas.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-schemas.ts new file mode 100644 index 000000000..e70e63c11 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-schemas.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; + +import { GitLabDataSourceScope } from "@app/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums"; +import { + SecretScanningDataSource, + SecretScanningResource +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + BaseCreateSecretScanningDataSourceSchema, + BaseSecretScanningDataSourceSchema, + BaseSecretScanningFindingSchema, + BaseUpdateSecretScanningDataSourceSchema, + GitRepositoryScanFindingDetailsSchema +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-schemas"; +import { SecretScanningDataSources } from "@app/lib/api-docs"; +import { GitLabProjectRegex } from "@app/lib/regex"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const GitLabDataSourceConfigSchema = z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(GitLabDataSourceScope.Group).describe(SecretScanningDataSources.CONFIG.GITLAB.scope), + groupId: z.number().describe(SecretScanningDataSources.CONFIG.GITLAB.groupId), + groupName: z.string().trim().max(256).optional().describe(SecretScanningDataSources.CONFIG.GITLAB.groupName), + includeProjects: z + .array( + z + .string() + .min(1) + .max(256) + .refine((value) => value === "*" || GitLabProjectRegex.test(value), "Invalid project name format") + ) + .nonempty("One or more projects required") + .max(100, "Cannot configure more than 100 projects") + .default(["*"]) + .describe(SecretScanningDataSources.CONFIG.GITLAB.includeProjects) + }), + z.object({ + scope: z.literal(GitLabDataSourceScope.Project).describe(SecretScanningDataSources.CONFIG.GITLAB.scope), + projectName: z.string().trim().max(256).optional().describe(SecretScanningDataSources.CONFIG.GITLAB.projectName), + projectId: z.number().describe(SecretScanningDataSources.CONFIG.GITLAB.projectId) + }) +]); + +export const GitLabDataSourceSchema = BaseSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.GitLab, + isConnectionRequired: true +}) + .extend({ + config: GitLabDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "GitLab" + }) + ); + +export const CreateGitLabDataSourceSchema = BaseCreateSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.GitLab, + isConnectionRequired: true +}) + .extend({ + config: GitLabDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "GitLab" + }) + ); + +export const UpdateGitLabDataSourceSchema = BaseUpdateSecretScanningDataSourceSchema(SecretScanningDataSource.GitLab) + .extend({ + config: GitLabDataSourceConfigSchema.optional() + }) + .describe( + JSON.stringify({ + title: "GitLab" + }) + ); + +export const GitLabDataSourceListItemSchema = z + .object({ + name: z.literal("GitLab"), + connection: z.literal(AppConnection.GitLab), + type: z.literal(SecretScanningDataSource.GitLab) + }) + .describe( + JSON.stringify({ + title: "GitLab" + }) + ); + +export const GitLabFindingSchema = BaseSecretScanningFindingSchema.extend({ + resourceType: z.literal(SecretScanningResource.Project), + dataSourceType: z.literal(SecretScanningDataSource.GitLab), + details: GitRepositoryScanFindingDetailsSchema +}); + +export const GitLabDataSourceCredentialsSchema = z.object({ + token: z.string(), + hookId: z.number() +}); diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-service.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-service.ts new file mode 100644 index 000000000..bc669a2f5 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-service.ts @@ -0,0 +1,94 @@ +import { GitLabDataSourceScope } from "@app/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums"; +import { TSecretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TSecretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue"; +import { logger } from "@app/lib/logger"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { + TGitLabDataSource, + TGitLabDataSourceCredentials, + THandleGitLabPushEvent +} from "./gitlab-secret-scanning-types"; + +export const gitlabSecretScanningService = ( + secretScanningV2DAL: TSecretScanningV2DALFactory, + secretScanningV2Queue: Pick, + kmsService: Pick +) => { + const handlePushEvent = async ({ payload, token, dataSourceId }: THandleGitLabPushEvent) => { + if (!payload.total_commits_count || !payload.project) { + logger.warn( + `secretScanningV2PushEvent: GitLab - Insufficient data [changes=${ + payload.total_commits_count ?? 0 + }] [projectName=${payload.project?.path_with_namespace ?? "unknown"}] [projectId=${payload.project?.id ?? "unknown"}]` + ); + return; + } + + const dataSource = (await secretScanningV2DAL.dataSources.findOne({ + id: dataSourceId, + type: SecretScanningDataSource.GitLab + })) as TGitLabDataSource | undefined; + + if (!dataSource) { + logger.error( + `secretScanningV2PushEvent: GitLab - Could not find data source [dataSourceId=${dataSourceId}] [projectId=${payload.project.id}]` + ); + return; + } + + const { isAutoScanEnabled, config, encryptedCredentials, projectId } = dataSource; + + if (!encryptedCredentials) { + logger.info( + `secretScanningV2PushEvent: GitLab - Could not find encrypted credentials [dataSourceId=${dataSource.id}] [projectId=${payload.project.id}]` + ); + return; + } + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedCredentials = decryptor({ cipherTextBlob: encryptedCredentials }); + + const credentials = JSON.parse(decryptedCredentials.toString()) as TGitLabDataSourceCredentials; + + if (token !== credentials.token) { + logger.error( + `secretScanningV2PushEvent: GitLab - Invalid webhook token [dataSourceId=${dataSource.id}] [projectId=${payload.project.id}]` + ); + return; + } + + if (!isAutoScanEnabled) { + logger.info( + `secretScanningV2PushEvent: GitLab - ignoring due to auto scan disabled [dataSourceId=${dataSource.id}] [projectId=${payload.project.id}]` + ); + return; + } + + if ( + config.scope === GitLabDataSourceScope.Project + ? config.projectId.toString() === payload.project_id.toString() + : config.includeProjects.includes("*") || config.includeProjects.includes(payload.project.path_with_namespace) + ) { + await secretScanningV2Queue.queueResourceDiffScan({ + dataSourceType: SecretScanningDataSource.GitLab, + payload, + dataSourceId: dataSource.id + }); + } else { + logger.info( + `secretScanningV2PushEvent: GitLab - ignoring due to repository not being present in config [dataSourceId=${dataSource.id}] [projectId=${payload.project.id}]` + ); + } + }; + + return { + handlePushEvent + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-types.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-types.ts new file mode 100644 index 000000000..105757e38 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-types.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TGitLabConnection } from "@app/services/app-connection/gitlab"; + +import { + CreateGitLabDataSourceSchema, + GitLabDataSourceCredentialsSchema, + GitLabDataSourceListItemSchema, + GitLabDataSourceSchema, + GitLabFindingSchema +} from "./gitlab-secret-scanning-schemas"; + +export type TGitLabDataSource = z.infer; + +export type TGitLabDataSourceInput = z.infer; + +export type TGitLabDataSourceListItem = z.infer; + +export type TGitLabFinding = z.infer; + +export type TGitLabDataSourceWithConnection = TGitLabDataSource & { + connection: TGitLabConnection; +}; + +export type TGitLabDataSourceCredentials = z.infer; + +export type TGitLabDataSourcePushEventPayload = { + object_kind: "push"; + event_name: "push"; + before: string; + after: string; + ref: string; + ref_protected: boolean; + checkout_sha: string; + user_id: number; + user_name: string; + user_username: string; + user_email: string; + user_avatar: string; + project_id: number; + project: { + id: number; + name: string; + description: string; + web_url: string; + avatar_url: string | null; + git_ssh_url: string; + git_http_url: string; + namespace: string; + visibility_level: number; + path_with_namespace: string; + default_branch: string; + homepage: string; + url: string; + ssh_url: string; + http_url: string; + }; + repository: { + name: string; + url: string; + description: string; + homepage: string; + git_http_url: string; + git_ssh_url: string; + visibility_level: number; + }; + commits: { + id: string; + message: string; + title: string; + timestamp: string; + url: string; + author: { + name: string; + email: string; + }; + added: string[]; + modified: string[]; + removed: string[]; + }[]; + total_commits_count: number; +}; + +export type THandleGitLabPushEvent = { + payload: TGitLabDataSourcePushEventPayload; + dataSourceId: string; + token: string; +}; + +export type TQueueGitLabResourceDiffScan = { + dataSourceType: SecretScanningDataSource.GitLab; + payload: TGitLabDataSourcePushEventPayload; + dataSourceId: string; + resourceId: string; + scanId: string; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/index.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/index.ts new file mode 100644 index 000000000..00009ca3b --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/index.ts @@ -0,0 +1,3 @@ +export * from "./gitlab-secret-scanning-constants"; +export * from "./gitlab-secret-scanning-schemas"; +export * from "./gitlab-secret-scanning-types"; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts index 447ffc22a..c6ca50c5e 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts @@ -49,6 +49,7 @@ const baseSecretScanningDataSourceQuery = ({ db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"), db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("connectionGatewayId"), db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), db @@ -82,6 +83,7 @@ const expandSecretScanningDataSource = < connectionUpdatedAt, connectionVersion, connectionIsPlatformManagedCredentials, + connectionGatewayId, ...el } = dataSource; @@ -100,7 +102,8 @@ const expandSecretScanningDataSource = < createdAt: connectionCreatedAt, updatedAt: connectionUpdatedAt, version: connectionVersion, - isPlatformManagedCredentials: connectionIsPlatformManagedCredentials + isPlatformManagedCredentials: connectionIsPlatformManagedCredentials, + gatewayId: connectionGatewayId } : undefined }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts index 40e5ea7dd..a4fd7bd34 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts @@ -1,6 +1,7 @@ export enum SecretScanningDataSource { GitHub = "github", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + GitLab = "gitlab" } export enum SecretScanningScanStatus { diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts index 2ca1f4882..40e664d01 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts @@ -1,5 +1,6 @@ import { BitbucketSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory"; import { GitHubSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/github/github-secret-scanning-factory"; +import { GitLabSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-factory"; import { SecretScanningDataSource } from "./secret-scanning-v2-enums"; import { @@ -19,5 +20,6 @@ type TSecretScanningFactoryImplementation = TSecretScanningFactory< export const SECRET_SCANNING_FACTORY_MAP: Record = { [SecretScanningDataSource.GitHub]: GitHubSecretScanningFactory as TSecretScanningFactoryImplementation, - [SecretScanningDataSource.Bitbucket]: BitbucketSecretScanningFactory as TSecretScanningFactoryImplementation + [SecretScanningDataSource.Bitbucket]: BitbucketSecretScanningFactory as TSecretScanningFactoryImplementation, + [SecretScanningDataSource.GitLab]: GitLabSecretScanningFactory as TSecretScanningFactoryImplementation }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts index bdda63f7a..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 @@ -13,6 +13,7 @@ import { import { SecretMatch } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; import { BITBUCKET_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/bitbucket"; import { GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/github"; +import { GITLAB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/gitlab"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; @@ -23,7 +24,8 @@ import { TCloneRepository, TGetFindingsPayload, TSecretScanningDataSourceListIte const SECRET_SCANNING_SOURCE_LIST_OPTIONS: Record = { [SecretScanningDataSource.GitHub]: GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION, - [SecretScanningDataSource.Bitbucket]: BITBUCKET_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION + [SecretScanningDataSource.Bitbucket]: BITBUCKET_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION, + [SecretScanningDataSource.GitLab]: GITLAB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION }; export const listSecretScanningDataSourceOptions = () => { @@ -56,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); @@ -164,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, @@ -186,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/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts index c84d6056a..fd29e0b74 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts @@ -3,15 +3,18 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums export const SECRET_SCANNING_DATA_SOURCE_NAME_MAP: Record = { [SecretScanningDataSource.GitHub]: "GitHub", - [SecretScanningDataSource.Bitbucket]: "Bitbucket" + [SecretScanningDataSource.Bitbucket]: "Bitbucket", + [SecretScanningDataSource.GitLab]: "GitLab" }; export const SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP: Record = { [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar, - [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket + [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket, + [SecretScanningDataSource.GitLab]: AppConnection.GitLab }; export const AUTO_SYNC_DESCRIPTION_HELPER: Record = { [SecretScanningDataSource.GitHub]: { verb: "push", noun: "repositories" }, - [SecretScanningDataSource.Bitbucket]: { verb: "push", noun: "repositories" } + [SecretScanningDataSource.Bitbucket]: { verb: "push", noun: "repositories" }, + [SecretScanningDataSource.GitLab]: { verb: "push", noun: "projects" } }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts index 7e670af96..8621b039b 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts @@ -16,6 +16,7 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, InternalServerError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; import { TAppConnection } from "@app/services/app-connection/app-connection-types"; import { ActorType } from "@app/services/auth/auth-type"; @@ -48,6 +49,7 @@ type TSecretRotationV2QueueServiceFactoryDep = { projectMembershipDAL: Pick; projectDAL: Pick; kmsService: Pick; + appConnectionDAL: Pick; auditLogService: Pick; keyStore: Pick; }; @@ -62,7 +64,8 @@ export const secretScanningV2QueueServiceFactory = async ({ smtpService, kmsService, auditLogService, - keyStore + keyStore, + appConnectionDAL }: TSecretRotationV2QueueServiceFactoryDep) => { const queueDataSourceFullScan = async ( dataSource: TSecretScanningDataSourceWithConnection, @@ -71,7 +74,10 @@ export const secretScanningV2QueueServiceFactory = async ({ try { const { type } = dataSource; - const factory = SECRET_SCANNING_FACTORY_MAP[type](); + const factory = SECRET_SCANNING_FACTORY_MAP[type]({ + kmsService, + appConnectionDAL + }); const rawResources = await factory.listRawResources(dataSource); @@ -171,7 +177,10 @@ export const secretScanningV2QueueServiceFactory = async ({ let connection: TAppConnection | null = null; if (dataSource.connection) connection = await decryptAppConnection(dataSource.connection, kmsService); - const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource](); + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource]({ + kmsService, + appConnectionDAL + }); const findingsPath = join(tempFolder, "findings.json"); @@ -329,7 +338,10 @@ export const secretScanningV2QueueServiceFactory = async ({ dataSourceId, dataSourceType }: Pick) => { - const factory = SECRET_SCANNING_FACTORY_MAP[dataSourceType as SecretScanningDataSource](); + const factory = SECRET_SCANNING_FACTORY_MAP[dataSourceType as SecretScanningDataSource]({ + kmsService, + appConnectionDAL + }); const resourcePayload = factory.getDiffScanResourcePayload(payload); @@ -391,7 +403,10 @@ export const secretScanningV2QueueServiceFactory = async ({ if (!resource) throw new Error(`Resource with ID "${resourceId}" not found`); - const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource](); + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource]({ + kmsService, + appConnectionDAL + }); const tempFolder = await createTempFolder(); diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts index 41da217d8..6bef41e10 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts @@ -46,6 +46,7 @@ import { import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAppConnection } from "@app/services/app-connection/app-connection-types"; @@ -53,12 +54,14 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { bitbucketSecretScanningService } from "./bitbucket/bitbucket-secret-scanning-service"; +import { gitlabSecretScanningService } from "./gitlab/gitlab-secret-scanning-service"; import { TSecretScanningV2DALFactory } from "./secret-scanning-v2-dal"; import { TSecretScanningV2QueueServiceFactory } from "./secret-scanning-v2-queue"; export type TSecretScanningV2ServiceFactoryDep = { secretScanningV2DAL: TSecretScanningV2DALFactory; appConnectionService: Pick; + appConnectionDAL: Pick; permissionService: Pick; licenseService: Pick; secretScanningV2Queue: Pick< @@ -76,6 +79,7 @@ export const secretScanningV2ServiceFactory = ({ appConnectionService, licenseService, secretScanningV2Queue, + appConnectionDAL, kmsService }: TSecretScanningV2ServiceFactoryDep) => { const $checkListSecretScanningDataSourcesByProjectIdPermissions = async ( @@ -255,7 +259,10 @@ export const secretScanningV2ServiceFactory = ({ ); } - const factory = SECRET_SCANNING_FACTORY_MAP[payload.type](); + const factory = SECRET_SCANNING_FACTORY_MAP[payload.type]({ + appConnectionDAL, + kmsService + }); try { const createdDataSource = await factory.initialize( @@ -363,6 +370,31 @@ export const secretScanningV2ServiceFactory = ({ message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` }); + let connection: TAppConnection | null = null; + if (dataSource.connectionId) { + // validates permission to connect and app is valid for data source + connection = await appConnectionService.connectAppConnectionById( + SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[dataSource.type], + dataSource.connectionId, + actor + ); + } + + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type]({ + appConnectionDAL, + kmsService + }); + + if (payload.config) { + await factory.validateConfigUpdate({ + dataSource: { + ...dataSource, + connection + } as TSecretScanningDataSourceWithConnection, + config: payload.config as TSecretScanningDataSourceWithConnection["config"] + }); + } + try { const updatedDataSource = await secretScanningV2DAL.dataSources.updateById(dataSourceId, payload); @@ -416,7 +448,10 @@ export const secretScanningV2ServiceFactory = ({ message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` }); - const factory = SECRET_SCANNING_FACTORY_MAP[type](); + const factory = SECRET_SCANNING_FACTORY_MAP[type]({ + appConnectionDAL, + kmsService + }); let connection: TAppConnection | null = null; if (dataSource.connection) { @@ -903,6 +938,7 @@ export const secretScanningV2ServiceFactory = ({ findSecretScanningConfigByProjectId, upsertSecretScanningConfig, github: githubSecretScanningService(secretScanningV2DAL, secretScanningV2Queue), - bitbucket: bitbucketSecretScanningService(secretScanningV2DAL, secretScanningV2Queue, kmsService) + bitbucket: bitbucketSecretScanningService(secretScanningV2DAL, secretScanningV2Queue, kmsService), + gitlab: gitlabSecretScanningService(secretScanningV2DAL, secretScanningV2Queue, kmsService) }; }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts index 6bd8251b6..6c5d86d16 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts @@ -21,14 +21,25 @@ import { TGitHubFinding, TQueueGitHubResourceDiffScan } from "@app/ee/services/secret-scanning-v2/github"; +import { + TGitLabDataSource, + TGitLabDataSourceCredentials, + TGitLabDataSourceInput, + TGitLabDataSourceListItem, + TGitLabDataSourceWithConnection, + TGitLabFinding, + TQueueGitLabResourceDiffScan +} from "@app/ee/services/secret-scanning-v2/gitlab"; import { TSecretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; import { SecretScanningDataSource, SecretScanningFindingStatus, SecretScanningScanStatus } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; -export type TSecretScanningDataSource = TGitHubDataSource | TBitbucketDataSource; +export type TSecretScanningDataSource = TGitHubDataSource | TBitbucketDataSource | TGitLabDataSource; export type TSecretScanningDataSourceWithDetails = TSecretScanningDataSource & { lastScannedAt?: Date | null; @@ -52,15 +63,25 @@ export type TSecretScanningScanWithDetails = TSecretScanningScans & { export type TSecretScanningDataSourceWithConnection = | TGitHubDataSourceWithConnection - | TBitbucketDataSourceWithConnection; + | TBitbucketDataSourceWithConnection + | TGitLabDataSourceWithConnection; -export type TSecretScanningDataSourceInput = TGitHubDataSourceInput | TBitbucketDataSourceInput; +export type TSecretScanningDataSourceInput = + | TGitHubDataSourceInput + | TBitbucketDataSourceInput + | TGitLabDataSourceInput; -export type TSecretScanningDataSourceListItem = TGitHubDataSourceListItem | TBitbucketDataSourceListItem; +export type TSecretScanningDataSourceListItem = + | TGitHubDataSourceListItem + | TBitbucketDataSourceListItem + | TGitLabDataSourceListItem; -export type TSecretScanningDataSourceCredentials = TBitbucketDataSourceCredentials | undefined; +export type TSecretScanningDataSourceCredentials = + | TBitbucketDataSourceCredentials + | TGitLabDataSourceCredentials + | undefined; -export type TSecretScanningFinding = TGitHubFinding | TBitbucketFinding; +export type TSecretScanningFinding = TGitHubFinding | TBitbucketFinding | TGitLabFinding; export type TListSecretScanningDataSourcesByProjectId = { projectId: string; @@ -112,7 +133,10 @@ export type TQueueSecretScanningDataSourceFullScan = { scanId: string; }; -export type TQueueSecretScanningResourceDiffScan = TQueueGitHubResourceDiffScan | TQueueBitbucketResourceDiffScan; +export type TQueueSecretScanningResourceDiffScan = + | TQueueGitHubResourceDiffScan + | TQueueBitbucketResourceDiffScan + | TQueueGitLabResourceDiffScan; export type TQueueSecretScanningSendNotification = { dataSource: TSecretScanningDataSources; @@ -170,6 +194,11 @@ export type TSecretScanningFactoryInitialize< callback: (parameters: { credentials?: C; externalId?: string }) => Promise ) => Promise; +export type TSecretScanningFactoryValidateConfigUpdate< + C extends TSecretScanningDataSourceInput["config"], + T extends TSecretScanningDataSourceWithConnection +> = (params: { config: C; dataSource: T }) => Promise; + export type TSecretScanningFactoryPostInitialization< P extends TSecretScanningDataSourceInput, T extends TSecretScanningDataSourceWithConnection["connection"] | undefined = undefined, @@ -181,17 +210,23 @@ export type TSecretScanningFactoryTeardown< C extends TSecretScanningDataSourceCredentials = undefined > = (params: { dataSource: T; credentials: C }) => Promise; +export type TSecretScanningFactoryParams = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + export type TSecretScanningFactory< T extends TSecretScanningDataSourceWithConnection, P extends TQueueSecretScanningResourceDiffScan["payload"], I extends TSecretScanningDataSourceInput, C extends TSecretScanningDataSourceCredentials | undefined = undefined -> = () => { +> = (params: TSecretScanningFactoryParams) => { listRawResources: TSecretScanningFactoryListRawResources; getFullScanPath: TSecretScanningFactoryGetFullScanPath; initialize: TSecretScanningFactoryInitialize; postInitialization: TSecretScanningFactoryPostInitialization; teardown: TSecretScanningFactoryTeardown; + validateConfigUpdate: TSecretScanningFactoryValidateConfigUpdate; getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload

; getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload; }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts index 671d4e16b..392a088a6 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { BitbucketDataSourceSchema, BitbucketFindingSchema } from "@app/ee/services/secret-scanning-v2/bitbucket"; import { GitHubDataSourceSchema, GitHubFindingSchema } from "@app/ee/services/secret-scanning-v2/github"; +import { GitLabDataSourceSchema, GitLabFindingSchema } from "@app/ee/services/secret-scanning-v2/gitlab"; export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [ GitHubDataSourceSchema, - BitbucketDataSourceSchema + BitbucketDataSourceSchema, + GitLabDataSourceSchema ]); export const SecretScanningFindingSchema = z.discriminatedUnion("dataSourceType", [ @@ -18,5 +20,10 @@ export const SecretScanningFindingSchema = z.discriminatedUnion("dataSourceType" JSON.stringify({ title: "Bitbucket" }) + ), + GitLabFindingSchema.describe( + JSON.stringify({ + title: "GitLab" + }) ) ]); diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 79679d256..a6135e0ca 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -1,3 +1,5 @@ +import { Cluster, Redis } from "ioredis"; + import { buildRedisFromConfig, TRedisConfigKeys } from "@app/lib/config/redis"; import { pgAdvisoryLockHashText } from "@app/lib/crypto/hashtext"; import { applyJitter } from "@app/lib/dates"; @@ -13,7 +15,8 @@ export const PgSqlLock = { SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`), CreateProject: (orgId: string) => pgAdvisoryLockHashText(`create-project:${orgId}`), CreateFolder: (envId: string, projectId: string) => pgAdvisoryLockHashText(`create-folder:${envId}-${projectId}`), - SshInit: (projectId: string) => pgAdvisoryLockHashText(`ssh-bootstrap:${projectId}`) + SshInit: (projectId: string) => pgAdvisoryLockHashText(`ssh-bootstrap:${projectId}`), + IdentityLogin: (identityId: string, nonce: string) => pgAdvisoryLockHashText(`identity-login:${identityId}:${nonce}`) } as const; // all the key prefixes used must be set here to avoid conflict @@ -37,16 +40,22 @@ export const KeyStorePrefixes = { SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, + AppConnectionConcurrentJobs: (connectionId: string) => `app-connection-concurrency-${connectionId}` as const, SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => `secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const, + IdentityLockoutLock: (lockoutKey: string) => `identity-lockout-lock-${lockoutKey}` as const, CaOrderCertificateForSubscriberLock: (subscriberId: string) => `ca-order-certificate-for-subscriber-lock-${subscriberId}` as const, SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const, IdentityAccessTokenStatusUpdate: (identityAccessTokenId: string) => `identity-access-token-status:${identityAccessTokenId}`, ServiceTokenStatusUpdate: (serviceTokenId: string) => `service-token-status:${serviceTokenId}`, - GatewayIdentityCredential: (identityId: string) => `gateway-credentials:${identityId}` + GatewayIdentityCredential: (identityId: string) => `gateway-credentials:${identityId}`, + ActiveSSEConnectionsSet: (projectId: string, identityId: string) => + `sse-connections:${projectId}:${identityId}` as const, + ActiveSSEConnections: (projectId: string, identityId: string, connectionId: string) => + `sse-connections:${projectId}:${identityId}:${connectionId}` as const }; export const KeyStoreTtls = { @@ -95,30 +104,57 @@ export type TKeyStoreFactory = { getKeysByPattern: (pattern: string, limit?: number) => Promise; }; -export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFactory => { - const redis = buildRedisFromConfig(redisConfigKeys); - const redisLock = new Redlock([redis], { retryCount: 2, retryDelay: 200 }); +const pickPrimaryOrSecondaryRedis = (primary: Redis | Cluster, secondaries?: Array) => { + if (!secondaries || !secondaries.length) return primary; + const selectedReplica = secondaries[Math.floor(Math.random() * secondaries.length)]; + return selectedReplica; +}; + +interface TKeyStoreFactoryDTO extends TRedisConfigKeys { + REDIS_READ_REPLICAS?: { host: string; port: number }[]; +} + +export const keyStoreFactory = (redisConfigKeys: TKeyStoreFactoryDTO): TKeyStoreFactory => { + const primaryRedis = buildRedisFromConfig(redisConfigKeys); + const redisReadReplicas = redisConfigKeys.REDIS_READ_REPLICAS?.map((el) => { + if (redisConfigKeys.REDIS_URL) { + const primaryNode = new URL(redisConfigKeys?.REDIS_URL); + primaryNode.hostname = el.host; + primaryNode.port = String(el.port); + return buildRedisFromConfig({ ...redisConfigKeys, REDIS_URL: primaryNode.toString() }); + } + + if (redisConfigKeys.REDIS_SENTINEL_HOSTS) { + return buildRedisFromConfig({ ...redisConfigKeys, REDIS_SENTINEL_HOSTS: [el] }); + } + + return buildRedisFromConfig({ ...redisConfigKeys, REDIS_CLUSTER_HOSTS: [el] }); + }); + const redisLock = new Redlock([primaryRedis], { retryCount: 2, retryDelay: 200 }); const setItem = async (key: string, value: string | number | Buffer, prefix?: string) => - redis.set(prefix ? `${prefix}:${key}` : key, value); + primaryRedis.set(prefix ? `${prefix}:${key}` : key, value); - const getItem = async (key: string, prefix?: string) => redis.get(prefix ? `${prefix}:${key}` : key); + const getItem = async (key: string, prefix?: string) => + pickPrimaryOrSecondaryRedis(primaryRedis, redisReadReplicas).get(prefix ? `${prefix}:${key}` : key); const getItems = async (keys: string[], prefix?: string) => - redis.mget(keys.map((key) => (prefix ? `${prefix}:${key}` : key))); + pickPrimaryOrSecondaryRedis(primaryRedis, redisReadReplicas).mget( + keys.map((key) => (prefix ? `${prefix}:${key}` : key)) + ); const setItemWithExpiry = async ( key: string, expiryInSeconds: number | string, value: string | number | Buffer, prefix?: string - ) => redis.set(prefix ? `${prefix}:${key}` : key, value, "EX", expiryInSeconds); + ) => primaryRedis.set(prefix ? `${prefix}:${key}` : key, value, "EX", expiryInSeconds); - const deleteItem = async (key: string) => redis.del(key); + const deleteItem = async (key: string) => primaryRedis.del(key); const deleteItemsByKeyIn = async (keys: string[]) => { if (keys.length === 0) return 0; - return redis.del(keys); + return primaryRedis.del(keys); }; const deleteItems = async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }: TDeleteItems) => { @@ -128,12 +164,12 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac do { // Await in loop is needed so that Redis is not overwhelmed // eslint-disable-next-line no-await-in-loop - const [nextCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000); // Count should be 1000 - 5000 for prod loads + const [nextCursor, keys] = await primaryRedis.scan(cursor, "MATCH", pattern, "COUNT", 1000); // Count should be 1000 - 5000 for prod loads cursor = nextCursor; for (let i = 0; i < keys.length; i += batchSize) { const batch = keys.slice(i, i + batchSize); - const pipeline = redis.pipeline(); + const pipeline = primaryRedis.pipeline(); for (const key of batch) { pipeline.unlink(key); } @@ -149,9 +185,9 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac return totalDeleted; }; - const incrementBy = async (key: string, value: number) => redis.incrby(key, value); + const incrementBy = async (key: string, value: number) => primaryRedis.incrby(key, value); - const setExpiry = async (key: string, expiryInSeconds: number) => redis.expire(key, expiryInSeconds); + const setExpiry = async (key: string, expiryInSeconds: number) => primaryRedis.expire(key, expiryInSeconds); const waitTillReady = async ({ key, @@ -182,7 +218,13 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac do { // eslint-disable-next-line no-await-in-loop - const [nextCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000); + const [nextCursor, keys] = await pickPrimaryOrSecondaryRedis(primaryRedis, redisReadReplicas).scan( + cursor, + "MATCH", + pattern, + "COUNT", + 1000 + ); cursor = nextCursor; allKeys.push(...keys); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 71b6afb6b..d61694d8f 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -18,6 +18,7 @@ import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "@app/services/ export enum ApiDocsTags { Identities = "Identities", + IdentityTemplates = "Identity Templates", TokenAuth = "Token Auth", UniversalAuth = "Universal Auth", GcpAuth = "GCP Auth", @@ -69,7 +70,8 @@ export enum ApiDocsTags { SecretScanning = "Secret Scanning", OidcSso = "OIDC SSO", SamlSso = "SAML SSO", - LdapSso = "LDAP SSO" + LdapSso = "LDAP SSO", + Events = "Event Subscriptions" } export const GROUPS = { @@ -164,7 +166,12 @@ export const UNIVERSAL_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses.", accessTokenPeriod: - "The period for an access token in seconds. This value will be referenced at renewal time. Default value is 0." + "The period for an access token in seconds. This value will be referenced at renewal time. Default value is 0.", + lockoutEnabled: "Whether the lockout feature is enabled.", + lockoutThreshold: "The amount of times login must fail before locking the identity auth method.", + lockoutDurationSeconds: "How long an identity auth method lockout lasts.", + lockoutCounterResetSeconds: + "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { identityId: "The ID of the identity to retrieve the auth method for." @@ -179,7 +186,12 @@ export const UNIVERSAL_AUTH = { accessTokenTTL: "The new lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.", - accessTokenPeriod: "The new period for an access token in seconds." + accessTokenPeriod: "The new period for an access token in seconds.", + lockoutEnabled: "Whether the lockout feature is enabled.", + lockoutThreshold: "The amount of times login must fail before locking the identity auth method.", + lockoutDurationSeconds: "How long an identity auth method lockout lasts.", + lockoutCounterResetSeconds: + "How long to wait from the most recent failed login until resetting the lockout counter." }, CREATE_CLIENT_SECRET: { identityId: "The ID of the identity to create a client secret for.", @@ -199,6 +211,9 @@ export const UNIVERSAL_AUTH = { identityId: "The ID of the identity to revoke the client secret from.", clientSecretId: "The ID of the client secret to revoke." }, + CLEAR_CLIENT_LOCKOUTS: { + identityId: "The ID of the identity to clear the client lockouts from." + }, RENEW_ACCESS_TOKEN: { accessToken: "The access token to renew." }, @@ -214,6 +229,7 @@ export const LDAP_AUTH = { password: "The password of the LDAP user to login." }, ATTACH: { + templateId: "The ID of the identity auth template to attach the configuration onto.", identityId: "The ID of the identity to attach the configuration onto.", url: "The URL of the LDAP server.", allowedFields: @@ -240,7 +256,8 @@ export const LDAP_AUTH = { accessTokenTTL: "The new lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.", - accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." + accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", + templateId: "The ID of the identity auth template to update the configuration to." }, RETRIEVE: { identityId: "The ID of the identity to retrieve the configuration for." @@ -664,6 +681,10 @@ export const ORGANIZATIONS = { organizationId: "The ID of the organization to delete the membership from.", membershipId: "The ID of the membership to delete." }, + BULK_DELETE_USER_MEMBERSHIPS: { + organizationId: "The ID of the organization to delete the memberships from.", + membershipIds: "The IDs of the memberships to delete." + }, LIST_IDENTITY_MEMBERSHIPS: { orgId: "The ID of the organization to get identity memberships from.", offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.", @@ -2140,7 +2161,9 @@ export const CertificateAuthorities = { directoryUrl: `The directory URL for the ACME Certificate Authority.`, accountEmail: `The email address for the ACME Certificate Authority.`, provider: `The DNS provider for the ACME Certificate Authority.`, - hostedZoneId: `The hosted zone ID for the ACME Certificate Authority.` + hostedZoneId: `The hosted zone ID for the ACME Certificate Authority.`, + eabKid: `The External Account Binding (EAB) Key ID for the ACME Certificate Authority. Required if the ACME provider uses EAB.`, + eabHmacKey: `The External Account Binding (EAB) HMAC key for the ACME Certificate Authority. Required if the ACME provider uses EAB.` }, INTERNAL: { type: "The type of CA to create.", @@ -2253,7 +2276,9 @@ export const AppConnections = { AZURE_DEVOPS: { code: "The OAuth code to use to connect with Azure DevOps.", tenantId: "The Tenant ID to use to connect with Azure DevOps.", - orgName: "The Organization name to use to connect with Azure DevOps." + orgName: "The Organization name to use to connect with Azure DevOps.", + clientId: "The Client ID to use to connect with Azure Client Secrets.", + clientSecret: "The Client Secret to use to connect with Azure Client Secrets." }, OCI: { userOcid: "The OCID (Oracle Cloud Identifier) of the user making the request.", @@ -2296,9 +2321,21 @@ export const AppConnections = { DIGITAL_OCEAN_APP_PLATFORM: { apiToken: "The API token used to authenticate with Digital Ocean App Platform." }, + NETLIFY: { + accessToken: "The Access token used to authenticate with Netlify." + }, OKTA: { instanceUrl: "The URL used to access your Okta organization.", apiToken: "The API token used to authenticate with Okta." + }, + AZURE_ADCS: { + adcsUrl: + "The HTTPS URL of the Azure ADCS instance to connect with (e.g., 'https://adcs.yourdomain.com/certsrv').", + username: "The username used to access Azure ADCS (format: 'DOMAIN\\username' or 'username@domain.com').", + password: "The password used to access Azure ADCS.", + sslRejectUnauthorized: + "Whether or not to reject unauthorized SSL certificates (true/false). Set to false only in test environments with self-signed certificates.", + sslCertificate: "The SSL certificate (PEM format) to use for secure connection." } } }; @@ -2400,12 +2437,18 @@ export const SecretSyncs = { env: "The name of the GitHub environment." }, AZURE_KEY_VAULT: { - vaultBaseUrl: "The base URL of the Azure Key Vault to sync secrets to. Example: https://example.vault.azure.net/" + vaultBaseUrl: "The base URL of the Azure Key Vault to sync secrets to. Example: https://example.vault.azure.net/", + tenantId: "The Tenant ID to use to connect with Azure Client Secrets.", + clientId: "The Client ID to use to connect with Azure Client Secrets.", + clientSecret: "The Client Secret to use to connect with Azure Client Secrets." }, AZURE_APP_CONFIGURATION: { configurationUrl: "The URL of the Azure App Configuration to sync secrets to. Example: https://example.azconfig.io/", - label: "An optional label to assign to secrets created in Azure App Configuration." + label: "An optional label to assign to secrets created in Azure App Configuration.", + tenantId: "The Tenant ID to use to connect with Azure Client Secrets.", + clientId: "The Client ID to use to connect with Azure Client Secrets.", + clientSecret: "The Client Secret to use to connect with Azure Client Secrets." }, AZURE_DEVOPS: { devopsProjectId: "The ID of the Azure DevOps project to sync secrets to.", @@ -2472,6 +2515,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." }, @@ -2521,6 +2565,13 @@ export const SecretSyncs = { workspaceSlug: "The Bitbucket Workspace slug to sync secrets to.", repositorySlug: "The Bitbucket Repository slug to sync secrets to.", environmentId: "The Bitbucket Deployment Environment uuid to sync secrets to." + }, + NETLIFY: { + accountId: "The ID of the Netlify account to sync secrets to.", + accountName: "The name of the Netlify account to sync secrets to.", + siteName: "The name of the Netlify site to sync secrets to.", + siteId: "The ID of the Netlify site to sync secrets to.", + context: "The Netlify context to sync secrets to." } } }; @@ -2702,6 +2753,14 @@ export const SecretScanningDataSources = { GITHUB: { includeRepos: 'The repositories to include when scanning. Defaults to all repositories (["*"]).' }, + GITLAB: { + includeProjects: 'The projects to include when scanning. Defaults to all projects (["*"]).', + scope: "The GitLab scope scanning should occur at (project or group level).", + projectId: "The ID of the project to scan.", + projectName: "The name of the project to scan.", + groupId: "The ID of the group to scan projects from.", + groupName: "The name of the group to scan projects from." + }, BITBUCKET: { workspaceSlug: "The workspace to scan.", includeRepos: 'The repositories to include when scanning. Defaults to all repositories (["*"]).' @@ -2839,3 +2898,10 @@ export const LdapSso = { caCert: "The CA certificate to use when verifying the LDAP server certificate." } }; + +export const EventSubscriptions = { + SUBSCRIBE_PROJECT_EVENTS: { + projectId: "The ID of the project to subscribe to events for.", + register: "List of events you want to subscribe to" + } +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 3fa2c0f82..c4170aac3 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -37,6 +37,8 @@ const envSchema = z .default("false") .transform((el) => el === "true"), REDIS_URL: zpStr(z.string().optional()), + REDIS_USERNAME: zpStr(z.string().optional()), + REDIS_PASSWORD: zpStr(z.string().optional()), REDIS_SENTINEL_HOSTS: zpStr( z .string() @@ -49,6 +51,28 @@ const envSchema = z REDIS_SENTINEL_ENABLE_TLS: zodStrBool.optional().describe("Whether to use TLS/SSL for Redis Sentinel connection"), REDIS_SENTINEL_USERNAME: zpStr(z.string().optional().describe("Authentication username for Redis Sentinel")), REDIS_SENTINEL_PASSWORD: zpStr(z.string().optional().describe("Authentication password for Redis Sentinel")), + REDIS_CLUSTER_HOSTS: zpStr( + z + .string() + .optional() + .describe("Comma-separated list of Redis Cluster host:port pairs. Eg: 192.168.65.254:6379,192.168.65.254:6380") + ), + REDIS_READ_REPLICAS: zpStr( + z + .string() + .optional() + .describe( + "Comma-separated list of Redis read replicas host:port pairs. Eg: 192.168.65.254:6379,192.168.65.254:6380" + ) + ), + REDIS_CLUSTER_ENABLE_TLS: z + .enum(["true", "false"]) + .default("false") + .transform((el) => el === "true"), + REDIS_CLUSTER_AWS_ELASTICACHE_DNS_LOOKUP_MODE: z + .enum(["true", "false"]) + .default("false") + .transform((el) => el === "true"), HOST: zpStr(z.string().default("localhost")), DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default( `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}` @@ -59,6 +83,7 @@ const envSchema = z AUDIT_LOGS_DB_ROOT_CERT: zpStr( z.string().describe("Postgres database base64-encoded CA cert for Audit logs").optional() ), + DISABLE_AUDIT_LOG_STORAGE: zodStrBool.default("false").optional().describe("Disable audit log storage"), MAX_LEASE_LIMIT: z.coerce.number().default(10000), DB_ROOT_CERT: zpStr(z.string().describe("Postgres database base64-encoded CA cert").optional()), DB_HOST: zpStr(z.string().describe("Postgres database host").optional()), @@ -78,6 +103,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"), @@ -214,6 +240,9 @@ const envSchema = z return JSON.parse(val) as { secretPath: string; projectId: string }[]; }) ), + PARAMS_FOLDER_SECRET_DETECTION_ENTROPY: z.coerce.number().optional().default(3.7), + + INFISICAL_PRIMARY_INSTANCE_URL: zpStr(z.string().optional()), // HSM HSM_LIB_PATH: zpStr(z.string().optional()), @@ -332,8 +361,8 @@ const envSchema = z "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." ) .refine( - (data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS), - "Either REDIS_URL or REDIS_SENTINEL_HOSTS must be defined." + (data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS) || Boolean(data.REDIS_CLUSTER_HOSTS), + "Either REDIS_URL, REDIS_SENTINEL_HOSTS or REDIS_CLUSTER_HOSTS must be defined." ) .transform((data) => ({ ...data, @@ -343,9 +372,13 @@ const envSchema = z : undefined, isCloud: Boolean(data.LICENSE_SERVER_KEY), isSmtpConfigured: Boolean(data.SMTP_HOST), - isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS), + isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS || data.REDIS_CLUSTER_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() @@ -354,6 +387,18 @@ const envSchema = z const [host, port] = el.trim().split(":"); return { host: host.trim(), port: Number(port.trim()) }; }), + REDIS_CLUSTER_HOSTS: data.REDIS_CLUSTER_HOSTS?.trim() + ?.split(",") + .map((el) => { + const [host, port] = el.trim().split(":"); + return { host: host.trim(), port: Number(port.trim()) }; + }), + REDIS_READ_REPLICAS: data.REDIS_READ_REPLICAS?.trim() + ?.split(",") + .map((el) => { + const [host, port] = el.trim().split(":"); + return { host: host.trim(), port: Number(port.trim()) }; + }), isSecretScanningConfigured: Boolean(data.SECRET_SCANNING_GIT_APP_ID) && Boolean(data.SECRET_SCANNING_PRIVATE_KEY) && @@ -482,6 +527,15 @@ export const overwriteSchema: { fields: { key: keyof TEnvConfig; description?: string }[]; }; } = { + auditLogs: { + name: "Audit Logs", + fields: [ + { + key: "DISABLE_AUDIT_LOG_STORAGE", + description: "Disable audit log storage" + } + ] + }, aws: { name: "AWS", fields: [ @@ -496,7 +550,7 @@ export const overwriteSchema: { ] }, azureAppConfiguration: { - name: "Azure App Configuration", + name: "Azure App Connection: App Configuration", fields: [ { key: "INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID", @@ -509,7 +563,7 @@ export const overwriteSchema: { ] }, azureKeyVault: { - name: "Azure Key Vault", + name: "Azure App Connection: Key Vault", fields: [ { key: "INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID", @@ -522,7 +576,7 @@ export const overwriteSchema: { ] }, azureClientSecrets: { - name: "Azure Client Secrets", + name: "Azure App Connection: Client Secrets", fields: [ { key: "INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID", @@ -535,7 +589,7 @@ export const overwriteSchema: { ] }, azureDevOps: { - name: "Azure DevOps", + name: "Azure App Connection: DevOps", fields: [ { key: "INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID", diff --git a/backend/src/lib/config/redis.ts b/backend/src/lib/config/redis.ts index 987518dd5..5187f740a 100644 --- a/backend/src/lib/config/redis.ts +++ b/backend/src/lib/config/redis.ts @@ -2,6 +2,14 @@ import { Redis } from "ioredis"; export type TRedisConfigKeys = Partial<{ REDIS_URL: string; + REDIS_USERNAME: string; + REDIS_PASSWORD: string; + + REDIS_CLUSTER_HOSTS: { host: string; port: number }[]; + REDIS_CLUSTER_ENABLE_TLS: boolean; + // ref: https://github.com/redis/ioredis?tab=readme-ov-file#special-note-aws-elasticache-clusters-with-tls + REDIS_CLUSTER_AWS_ELASTICACHE_DNS_LOOKUP_MODE: boolean; + REDIS_SENTINEL_HOSTS: { host: string; port: number }[]; REDIS_SENTINEL_MASTER_NAME: string; REDIS_SENTINEL_ENABLE_TLS: boolean; @@ -12,6 +20,19 @@ export type TRedisConfigKeys = Partial<{ export const buildRedisFromConfig = (cfg: TRedisConfigKeys) => { if (cfg.REDIS_URL) return new Redis(cfg.REDIS_URL, { maxRetriesPerRequest: null }); + if (cfg.REDIS_CLUSTER_HOSTS) { + return new Redis.Cluster(cfg.REDIS_CLUSTER_HOSTS, { + dnsLookup: cfg.REDIS_CLUSTER_AWS_ELASTICACHE_DNS_LOOKUP_MODE + ? (address, callback) => callback(null, address) + : undefined, + redisOptions: { + username: cfg.REDIS_USERNAME, + password: cfg.REDIS_PASSWORD, + tls: cfg?.REDIS_CLUSTER_ENABLE_TLS ? {} : undefined + } + }); + } + return new Redis({ // refine at tope will catch this case sentinels: cfg.REDIS_SENTINEL_HOSTS!, @@ -19,6 +40,8 @@ export const buildRedisFromConfig = (cfg: TRedisConfigKeys) => { maxRetriesPerRequest: null, sentinelUsername: cfg.REDIS_SENTINEL_USERNAME, sentinelPassword: cfg.REDIS_SENTINEL_PASSWORD, - enableTLSForSentinelMode: cfg.REDIS_SENTINEL_ENABLE_TLS + enableTLSForSentinelMode: cfg.REDIS_SENTINEL_ENABLE_TLS, + username: cfg.REDIS_USERNAME, + password: cfg.REDIS_PASSWORD }); }; diff --git a/backend/src/lib/crypto/cryptography/crypto.ts b/backend/src/lib/crypto/cryptography/crypto.ts index b8fc45645..05ea315cd 100644 --- a/backend/src/lib/crypto/cryptography/crypto.ts +++ b/backend/src/lib/crypto/cryptography/crypto.ts @@ -250,8 +250,11 @@ const cryptographyFactory = () => { }; }; - const encryptWithRootEncryptionKey = (data: string) => { - const appCfg = getConfig(); + const encryptWithRootEncryptionKey = ( + data: string, + appCfgOverride?: Pick + ) => { + const appCfg = appCfgOverride || getConfig(); const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; const encryptionKey = appCfg.ENCRYPTION_KEY; diff --git a/backend/src/lib/crypto/secret-encryption.ts b/backend/src/lib/crypto/secret-encryption.ts index 7355d4bd6..f22c918ac 100644 --- a/backend/src/lib/crypto/secret-encryption.ts +++ b/backend/src/lib/crypto/secret-encryption.ts @@ -53,7 +53,7 @@ type DecryptedIntegrationAuths = z.infer type TLatestKey = TProjectKeys & { sender: { - publicKey: string; + publicKey?: string; }; }; @@ -91,6 +91,10 @@ const getDecryptedValues = (data: Array<{ ciphertext: string; iv: string; tag: s return results; }; export const decryptSecrets = (encryptedSecrets: TSecrets[], privateKey: string, latestKey: TLatestKey) => { + if (!latestKey.sender.publicKey) { + throw new Error("Latest key sender public key not found"); + } + const key = crypto.encryption().asymmetric().decrypt({ ciphertext: latestKey.encryptedKey, nonce: latestKey.nonce, @@ -143,6 +147,10 @@ export const decryptSecretVersions = ( privateKey: string, latestKey: TLatestKey ) => { + if (!latestKey.sender.publicKey) { + throw new Error("Latest key sender public key not found"); + } + const key = crypto.encryption().asymmetric().decrypt({ ciphertext: latestKey.encryptedKey, nonce: latestKey.nonce, @@ -195,6 +203,10 @@ export const decryptSecretApprovals = ( privateKey: string, latestKey: TLatestKey ) => { + if (!latestKey.sender.publicKey) { + throw new Error("Latest key sender public key not found"); + } + const key = crypto.encryption().asymmetric().decrypt({ ciphertext: latestKey.encryptedKey, nonce: latestKey.nonce, @@ -247,6 +259,10 @@ export const decryptIntegrationAuths = ( privateKey: string, latestKey: TLatestKey ) => { + if (!latestKey.sender.publicKey) { + throw new Error("Latest key sender public key not found"); + } + const key = crypto.encryption().asymmetric().decrypt({ ciphertext: latestKey.encryptedKey, nonce: latestKey.nonce, diff --git a/backend/src/lib/crypto/srp.ts b/backend/src/lib/crypto/srp.ts index 3f403405e..2623e5756 100644 --- a/backend/src/lib/crypto/srp.ts +++ b/backend/src/lib/crypto/srp.ts @@ -4,6 +4,7 @@ import jsrp from "jsrp"; import { TUserEncryptionKeys } from "@app/db/schemas"; import { UserEncryption } from "@app/services/user/user-types"; +import { BadRequestError } from "../errors"; import { crypto, SymmetricKeySize } from "./cryptography"; export const generateSrpServerKey = async (salt: string, verifier: string) => { @@ -127,6 +128,10 @@ export const getUserPrivateKey = async ( > ) => { if (user.encryptionVersion === UserEncryption.V1) { + if (!user.encryptedPrivateKey || !user.iv || !user.tag || !user.salt) { + throw new BadRequestError({ message: "User encrypted private key not found" }); + } + return crypto .encryption() .symmetric() @@ -138,12 +143,25 @@ export const getUserPrivateKey = async ( keySize: SymmetricKeySize.Bits128 }); } + // still used for legacy things if ( user.encryptionVersion === UserEncryption.V2 && user.protectedKey && user.protectedKeyIV && user.protectedKeyTag ) { + if ( + !user.salt || + !user.protectedKey || + !user.protectedKeyIV || + !user.protectedKeyTag || + !user.encryptedPrivateKey || + !user.iv || + !user.tag + ) { + throw new BadRequestError({ message: "User encrypted private key not found" }); + } + const derivedKey = await argon2.hash(password, { salt: Buffer.from(user.salt), memoryCost: 65536, diff --git a/backend/src/lib/fn/string.ts b/backend/src/lib/fn/string.ts index 2fa4c9166..61e5a7e20 100644 --- a/backend/src/lib/fn/string.ts +++ b/backend/src/lib/fn/string.ts @@ -19,3 +19,17 @@ export const prefixWithSlash = (str: string) => { const vowelRegex = new RE2(/^[aeiou]/i); export const startsWithVowel = (str: string) => vowelRegex.test(str); + +const pickWordsRegex = new RE2(/(\W+)/); +export const sanitizeString = (dto: { unsanitizedString: string; tokens: string[] }) => { + const words = dto.unsanitizedString.split(pickWordsRegex); + + const redactionSet = new Set(dto.tokens.filter(Boolean)); + const sanitizedWords = words.map((el) => { + if (redactionSet.has(el)) { + return "[REDACTED]"; + } + return el; + }); + return sanitizedWords.join(""); +}; diff --git a/backend/src/lib/regex/index.ts b/backend/src/lib/regex/index.ts index a57705526..959fc741e 100644 --- a/backend/src/lib/regex/index.ts +++ b/backend/src/lib/regex/index.ts @@ -11,3 +11,5 @@ export const UserPrincipalNameRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9._-] export const LdapUrlRegex = new RE2(/^ldaps?:\/\//); export const BasicRepositoryRegex = new RE2(/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/); + +export const GitLabProjectRegex = new RE2(/^[a-zA-Z0-9._-]+(?:\/[a-zA-Z0-9._-]+)+$/); diff --git a/backend/src/lib/retry/index.ts b/backend/src/lib/retry/index.ts new file mode 100644 index 000000000..de7115399 --- /dev/null +++ b/backend/src/lib/retry/index.ts @@ -0,0 +1,43 @@ +/* eslint-disable no-await-in-loop */ +interface GitHubApiError extends Error { + status?: number; + response?: { + status?: number; + headers?: { + "x-ratelimit-reset"?: string; + }; + }; +} + +const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(() => resolve(), ms); + }); + +export const retryWithBackoff = async (fn: () => Promise, maxRetries = 3, baseDelay = 1000): Promise => { + let lastError: Error; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + const gitHubError = error as GitHubApiError; + const statusCode = gitHubError.status || gitHubError.response?.status; + if (statusCode === 403) { + const rateLimitReset = gitHubError.response?.headers?.["x-ratelimit-reset"]; + if (rateLimitReset) { + const resetTime = parseInt(rateLimitReset, 10) * 1000; + const waitTime = Math.max(resetTime - Date.now(), baseDelay); + await delay(Math.min(waitTime, 60000)); + } else { + await delay(baseDelay * 2 ** attempt); + } + } else if (attempt < maxRetries) { + await delay(baseDelay * 2 ** attempt); + } + } + } + + throw lastError!; +}; 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/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts index 8f195e0b5..a4c07b37d 100644 --- a/backend/src/lib/validator/validate-url.ts +++ b/backend/src/lib/validator/validate-url.ts @@ -14,6 +14,11 @@ export const blockLocalAndPrivateIpAddresses = async (url: string) => { if (appCfg.isDevelopmentMode) return; const validUrl = new URL(url); + + if (validUrl.username || validUrl.password) { + throw new BadRequestError({ message: "URLs with user credentials (e.g., user:pass@) are not allowed" }); + } + const inputHostIps: string[] = []; if (isIPv4(validUrl.hostname)) { inputHostIps.push(validUrl.hostname); 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/queue/queue-service.ts b/backend/src/queue/queue-service.ts index bcef34d4f..5a7c92f22 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -22,6 +22,8 @@ import { crypto } from "@app/lib/crypto"; import { logger } from "@app/lib/logger"; import { QueueWorkerProfile } from "@app/lib/types"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { ExternalPlatforms } from "@app/services/external-migration/external-migration-types"; +import { TCreateUserNotificationDTO } from "@app/services/notification/notification-types"; import { TFailedIntegrationSyncEmailsPayload, TIntegrationSyncPayload, @@ -66,7 +68,8 @@ export enum QueueName { SecretScanningV2 = "secret-scanning-v2", TelemetryAggregatedEvents = "telemetry-aggregated-events", DailyReminders = "daily-reminders", - SecretReminderMigration = "secret-reminder-migration" + SecretReminderMigration = "secret-reminder-migration", + UserNotification = "user-notification" } export enum QueueJobs { @@ -108,7 +111,8 @@ export enum QueueJobs { PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal", TelemetryAggregatedEvents = "telemetry-aggregated-events", DailyReminders = "daily-reminders", - SecretReminderMigration = "secret-reminder-migration" + SecretReminderMigration = "secret-reminder-migration", + UserNotification = "user-notification-job" } export type TQueueJobTypes = { @@ -228,6 +232,7 @@ export type TQueueJobTypes = { name: QueueJobs.ImportSecretsFromExternalSource; payload: { actorEmail: string; + importType: ExternalPlatforms; data: { iv: string; tag: string; @@ -311,6 +316,10 @@ export type TQueueJobTypes = { name: QueueJobs.TelemetryAggregatedEvents; payload: undefined; }; + [QueueName.UserNotification]: { + name: QueueJobs.UserNotification; + payload: { notifications: TCreateUserNotificationDTO[] }; + }; }; const SECRET_SCANNING_JOBS = [ @@ -413,6 +422,7 @@ export const queueServiceFactory = ( redisCfg: TRedisConfigKeys, { dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string } ): TQueueServiceFactory => { + const isClusterMode = Boolean(redisCfg?.REDIS_CLUSTER_HOSTS); const connection = buildRedisFromConfig(redisCfg); const queueContainer = {} as Record< QueueName, @@ -455,6 +465,8 @@ export const queueServiceFactory = ( } queueContainer[name] = new Queue(name as string, { + // ref: docs.bullmq.io/bull/patterns/redis-cluster + prefix: isClusterMode ? `{${name}}` : undefined, ...queueSettings, ...(crypto.isFipsModeEnabled() ? { @@ -470,6 +482,7 @@ export const queueServiceFactory = ( const appCfg = getConfig(); if (appCfg.QUEUE_WORKERS_ENABLED && isQueueEnabled(name)) { workerContainer[name] = new Worker(name, jobFn, { + prefix: isClusterMode ? `{${name}}` : undefined, ...queueSettings, ...(crypto.isFipsModeEnabled() ? { diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 321a3656e..8cf23f703 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -12,7 +12,7 @@ import type { FastifyRateLimitOptions } from "@fastify/rate-limit"; import ratelimiter from "@fastify/rate-limit"; import { fastifyRequestContext } from "@fastify/request-context"; import fastify from "fastify"; -import { Redis } from "ioredis"; +import { Cluster, Redis } from "ioredis"; import { Knex } from "knex"; import { HsmModule } from "@app/ee/services/hsm/hsm-types"; @@ -43,7 +43,7 @@ type TMain = { queue: TQueueServiceFactory; keyStore: TKeyStoreFactory; hsmModule: HsmModule; - redis: Redis; + redis: Redis | Cluster; envConfig: TEnvConfig; superAdminDAL: TSuperAdminDALFactory; }; @@ -76,6 +76,7 @@ export const main = async ({ server.setValidatorCompiler(validatorCompiler); server.setSerializerCompiler(serializerCompiler); + // @ts-expect-error akhilmhdh: even on setting it fastify as Redis | Cluster it's throwing error server.decorate("redis", redis); server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => { try { diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 4fe639510..2f128dda1 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -22,6 +22,7 @@ export type TAuthMode = orgId: string; authMethod: AuthMethod; isMfaVerified?: boolean; + token: AuthModeJwtTokenPayload; } | { authMode: AuthMode.API_KEY; @@ -30,6 +31,7 @@ export type TAuthMode = userId: string; user: TUsers; orgId: string; + token: string; } | { authMode: AuthMode.SERVICE_TOKEN; @@ -38,6 +40,7 @@ export type TAuthMode = serviceTokenId: string; orgId: string; authMethod: null; + token: string; } | { authMode: AuthMode.IDENTITY_ACCESS_TOKEN; @@ -47,6 +50,7 @@ export type TAuthMode = orgId: string; authMethod: null; isInstanceAdmin?: boolean; + token: TIdentityAccessTokenJwtPayload; } | { authMode: AuthMode.SCIM_TOKEN; @@ -56,7 +60,7 @@ export type TAuthMode = authMethod: null; }; -const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { +export const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { const apiKey = req.headers?.["x-api-key"]; if (apiKey) { return { authMode: AuthMode.API_KEY, token: apiKey, actor: ActorType.USER } as const; @@ -103,106 +107,117 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { }; // ! Important: You can only 100% count on the `req.permission.orgId` field being present when the auth method is Identity Access Token (Machine Identity). -export const injectIdentity = fp(async (server: FastifyZodProvider) => { - server.decorateRequest("auth", null); - server.addHook("onRequest", async (req) => { - const appCfg = getConfig(); +export const injectIdentity = fp( + async (server: FastifyZodProvider, opt: { shouldForwardWritesToPrimaryInstance?: boolean }) => { + server.decorateRequest("auth", null); + server.decorateRequest("shouldForwardWritesToPrimaryInstance", Boolean(opt.shouldForwardWritesToPrimaryInstance)); + server.addHook("onRequest", async (req) => { + const appCfg = getConfig(); - if (req.url.includes(".well-known/est") || req.url.includes("/api/v3/auth/")) { - return; - } - - // Authentication is handled on a route-level here. - if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) { - return; - } - - const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET); - - if (!authMode) return; - - switch (authMode) { - case AuthMode.JWT: { - const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token); - requestContext.set("orgId", orgId); - req.auth = { - authMode: AuthMode.JWT, - user, - userId: user.id, - tokenVersionId, - actor, - orgId: orgId as string, - authMethod: token.authMethod, - isMfaVerified: token.isMfaVerified - }; - break; + if (opt.shouldForwardWritesToPrimaryInstance && req.method !== "GET") { + return; } - case AuthMode.IDENTITY_ACCESS_TOKEN: { - const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); - const serverCfg = await getServerCfg(); - requestContext.set("orgId", identity.orgId); - req.auth = { - authMode: AuthMode.IDENTITY_ACCESS_TOKEN, - actor, - orgId: identity.orgId, - identityId: identity.identityId, - identityName: identity.name, - authMethod: null, - isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId) - }; - if (token?.identityAuth?.oidc) { - requestContext.set("identityAuthInfo", { - identityId: identity.identityId, - oidc: token?.identityAuth?.oidc - }); + + if (req.url.includes(".well-known/est") || req.url.includes("/api/v3/auth/")) { + return; + } + + // Authentication is handled on a route-level here. + if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) { + return; + } + + const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET); + + if (!authMode) return; + + switch (authMode) { + case AuthMode.JWT: { + const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token); + requestContext.set("orgId", orgId); + req.auth = { + authMode: AuthMode.JWT, + user, + userId: user.id, + tokenVersionId, + actor, + orgId: orgId as string, + authMethod: token.authMethod, + isMfaVerified: token.isMfaVerified, + token + }; + break; } - if (token?.identityAuth?.kubernetes) { - requestContext.set("identityAuthInfo", { + case AuthMode.IDENTITY_ACCESS_TOKEN: { + const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); + const serverCfg = await getServerCfg(); + requestContext.set("orgId", identity.orgId); + req.auth = { + authMode: AuthMode.IDENTITY_ACCESS_TOKEN, + actor, + orgId: identity.orgId, identityId: identity.identityId, - kubernetes: token?.identityAuth?.kubernetes - }); + identityName: identity.name, + authMethod: null, + isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId), + token + }; + if (token?.identityAuth?.oidc) { + requestContext.set("identityAuthInfo", { + identityId: identity.identityId, + oidc: token?.identityAuth?.oidc + }); + } + if (token?.identityAuth?.kubernetes) { + requestContext.set("identityAuthInfo", { + identityId: identity.identityId, + kubernetes: token?.identityAuth?.kubernetes + }); + } + if (token?.identityAuth?.aws) { + requestContext.set("identityAuthInfo", { + identityId: identity.identityId, + aws: token?.identityAuth?.aws + }); + } + break; } - if (token?.identityAuth?.aws) { - requestContext.set("identityAuthInfo", { - identityId: identity.identityId, - aws: token?.identityAuth?.aws - }); + case AuthMode.SERVICE_TOKEN: { + const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); + requestContext.set("orgId", serviceToken.orgId); + req.auth = { + orgId: serviceToken.orgId, + authMode: AuthMode.SERVICE_TOKEN as const, + serviceToken, + serviceTokenId: serviceToken.id, + actor, + authMethod: null, + token + }; + break; } - break; + case AuthMode.API_KEY: { + const user = await server.services.apiKey.fnValidateApiKey(token as string); + req.auth = { + authMode: AuthMode.API_KEY as const, + userId: user.id, + actor, + user, + orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon! + authMethod: null, + token: token as string + }; + break; + } + case AuthMode.SCIM_TOKEN: { + const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); + requestContext.set("orgId", orgId); + req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null }; + break; + } + default: + throw new BadRequestError({ message: "Invalid token strategy provided" }); } - case AuthMode.SERVICE_TOKEN: { - const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); - requestContext.set("orgId", serviceToken.orgId); - req.auth = { - orgId: serviceToken.orgId, - authMode: AuthMode.SERVICE_TOKEN as const, - serviceToken, - serviceTokenId: serviceToken.id, - actor, - authMethod: null - }; - break; - } - case AuthMode.API_KEY: { - const user = await server.services.apiKey.fnValidateApiKey(token as string); - req.auth = { - authMode: AuthMode.API_KEY as const, - userId: user.id, - actor, - user, - orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon! - authMethod: null - }; - break; - } - case AuthMode.SCIM_TOKEN: { - const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); - requestContext.set("orgId", orgId); - req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null }; - break; - } - default: - throw new BadRequestError({ message: "Invalid token strategy provided" }); - } - }); -}); + }); + } +); diff --git a/backend/src/server/plugins/auth/verify-auth.ts b/backend/src/server/plugins/auth/verify-auth.ts index 44ea069dc..c63199769 100644 --- a/backend/src/server/plugins/auth/verify-auth.ts +++ b/backend/src/server/plugins/auth/verify-auth.ts @@ -10,6 +10,10 @@ interface TAuthOptions { export const verifyAuth = (authStrategies: AuthMode[], options: TAuthOptions = { requireOrg: true }) => (req: T, _res: FastifyReply, done: HookHandlerDoneFunction) => { + if (req.shouldForwardWritesToPrimaryInstance && req.method !== "GET") { + return done(); + } + if (!Array.isArray(authStrategies)) throw new Error("Auth strategy must be array"); if (!req.auth) throw new UnauthorizedError({ message: "Token missing" }); diff --git a/backend/src/server/plugins/primary-forwarding-mode.ts b/backend/src/server/plugins/primary-forwarding-mode.ts new file mode 100644 index 000000000..611806a6b --- /dev/null +++ b/backend/src/server/plugins/primary-forwarding-mode.ts @@ -0,0 +1,14 @@ +import replyFrom from "@fastify/reply-from"; +import fp from "fastify-plugin"; + +export const forwardWritesToPrimary = fp(async (server, opt: { primaryUrl: string }) => { + await server.register(replyFrom, { + base: opt.primaryUrl + }); + + server.addHook("preValidation", async (request, reply) => { + if (request.url.startsWith("/api") && ["POST", "PUT", "DELETE", "PATCH"].includes(request.method)) { + return reply.from(request.url); + } + }); +}); diff --git a/backend/src/server/plugins/secret-scanner-v2.ts b/backend/src/server/plugins/secret-scanner-v2.ts index 1323fa4ad..3c0813be3 100644 --- a/backend/src/server/plugins/secret-scanner-v2.ts +++ b/backend/src/server/plugins/secret-scanner-v2.ts @@ -4,6 +4,8 @@ import { Probot } from "probot"; import { z } from "zod"; import { TBitbucketPushEvent } from "@app/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-types"; +import { TGitLabDataSourcePushEventPayload } from "@app/ee/services/secret-scanning-v2/gitlab"; +import { GitLabWebHookEvent } from "@app/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { writeLimit } from "@app/server/config/rateLimiter"; @@ -113,4 +115,36 @@ export const registerSecretScanningV2Webhooks = async (server: FastifyZodProvide return res.send("ok"); } }); + + // gitlab push event webhook + server.route({ + method: "POST", + url: "/gitlab", + config: { + rateLimit: writeLimit + }, + handler: async (req, res) => { + const event = req.headers["x-gitlab-event"] as GitLabWebHookEvent; + const token = req.headers["x-gitlab-token"] as string; + const dataSourceId = req.headers["x-data-source-id"] as string; + + if (event !== GitLabWebHookEvent.Push) { + return res.status(400).send({ message: `Event type not supported: ${event as string}` }); + } + + if (!token) { + return res.status(401).send({ message: "Unauthorized: Missing token" }); + } + + if (!dataSourceId) return res.status(400).send({ message: "Data Source ID header is required" }); + + await server.services.secretScanningV2.gitlab.handlePushEvent({ + dataSourceId, + payload: req.body as TGitLabDataSourcePushEventPayload, + token + }); + + return res.send("ok"); + } + }); }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 0fb3191f8..4803a6cd2 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -31,6 +31,8 @@ import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/pro import { dynamicSecretLeaseDALFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal"; import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue"; import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; +import { eventBusFactory } from "@app/ee/services/event/event-bus-service"; +import { sseServiceFactory } from "@app/ee/services/event/event-sse-service"; import { externalKmsDALFactory } from "@app/ee/services/external-kms/external-kms-dal"; import { externalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service"; import { gatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; @@ -43,6 +45,8 @@ import { groupServiceFactory } from "@app/ee/services/group/group-service"; import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { HsmModule } from "@app/ee/services/hsm/hsm-types"; +import { identityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template/identity-auth-template-dal"; +import { identityAuthTemplateServiceFactory } from "@app/ee/services/identity-auth-template/identity-auth-template-service"; import { identityProjectAdditionalPrivilegeDALFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal"; import { identityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { identityProjectAdditionalPrivilegeV2ServiceFactory } from "@app/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service"; @@ -214,6 +218,11 @@ import { kmsServiceFactory } from "@app/services/kms/kms-service"; import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal"; import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; +import { notificationQueueServiceFactory } from "@app/services/notification/notification-queue"; +import { notificationServiceFactory } from "@app/services/notification/notification-service"; +import { userNotificationDALFactory } from "@app/services/notification/user-notification-dal"; +import { offlineUsageReportDALFactory } from "@app/services/offline-usage-report/offline-usage-report-dal"; +import { offlineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; @@ -306,6 +315,7 @@ import { injectAssumePrivilege } from "../plugins/auth/inject-assume-privilege"; import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectPermission } from "../plugins/auth/inject-permission"; import { injectRateLimits } from "../plugins/inject-rate-limits"; +import { forwardWritesToPrimary } from "../plugins/primary-forwarding-mode"; import { registerV1Routes } from "./v1"; import { initializeOauthConfigSync } from "./v1/sso-router"; import { registerV2Routes } from "./v2"; @@ -381,6 +391,7 @@ export const registerRoutes = async ( const reminderRecipientDAL = reminderRecipientDALFactory(db); const integrationDAL = integrationDALFactory(db); + const offlineUsageReportDAL = offlineUsageReportDALFactory(db); const integrationAuthDAL = integrationAuthDALFactory(db); const webhookDAL = webhookDALFactory(db); const serviceTokenDAL = serviceTokenDALFactory(db); @@ -392,6 +403,7 @@ export const registerRoutes = async ( const identityProjectDAL = identityProjectDALFactory(db); const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db); const identityProjectAdditionalPrivilegeDAL = identityProjectAdditionalPrivilegeDALFactory(db); + const identityAuthTemplateDAL = identityAuthTemplateDALFactory(db); const identityTokenAuthDAL = identityTokenAuthDALFactory(db); const identityUaDAL = identityUaDALFactory(db); @@ -413,6 +425,7 @@ export const registerRoutes = async ( const telemetryDAL = telemetryDALFactory(db); const appConnectionDAL = appConnectionDALFactory(db); const secretSyncDAL = secretSyncDALFactory(db, folderDAL); + const userNotificationDAL = userNotificationDALFactory(db); // ee db layer ops const permissionDAL = permissionDALFactory(db); @@ -495,6 +508,9 @@ export const registerRoutes = async ( const projectMicrosoftTeamsConfigDAL = projectMicrosoftTeamsConfigDALFactory(db); const secretScanningV2DAL = secretScanningV2DALFactory(db); + const eventBusService = eventBusFactory(server.redis); + const sseService = sseServiceFactory(eventBusService, server.redis); + const permissionService = permissionServiceFactory({ permissionDAL, orgRoleDAL, @@ -547,20 +563,29 @@ export const registerRoutes = async ( permissionService }); + const auditLogStreamService = auditLogStreamServiceFactory({ + licenseService, + permissionService, + auditLogStreamDAL, + kmsService + }); + const auditLogQueue = await auditLogQueueServiceFactory({ auditLogDAL, queueService, projectDAL, licenseService, - auditLogStreamDAL + auditLogStreamService }); - const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); - const auditLogStreamService = auditLogStreamServiceFactory({ - licenseService, - permissionService, - auditLogStreamDAL + const notificationQueue = await notificationQueueServiceFactory({ + userNotificationDAL, + queueService }); + + const notificationService = notificationServiceFactory({ notificationQueue, userNotificationDAL }); + + const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({ projectEnvDAL, secretApprovalPolicyApproverDAL: sapApproverDAL, @@ -672,7 +697,8 @@ export const registerRoutes = async ( kmsService, permissionService, groupDAL, - userGroupMembershipDAL + userGroupMembershipDAL, + orgMembershipDAL }); const ldapService = ldapConfigServiceFactory({ @@ -718,7 +744,8 @@ export const registerRoutes = async ( permissionService, groupProjectDAL, smtpService, - projectMembershipDAL + projectMembershipDAL, + userAliasDAL }); const totpService = totpServiceFactory({ @@ -766,7 +793,6 @@ export const registerRoutes = async ( orgRoleDAL, permissionService, orgDAL, - projectBotDAL, incidentContactDAL, tokenService, projectUserAdditionalPrivilegeDAL, @@ -781,6 +807,7 @@ export const registerRoutes = async ( groupDAL, orgBotDAL, oidcConfigDAL, + ldapConfigDAL, loginService, projectBotService, reminderService @@ -833,7 +860,14 @@ export const registerRoutes = async ( licenseService, kmsService, microsoftTeamsService, - invalidateCacheQueue + invalidateCacheQueue, + smtpService, + tokenService + }); + + const offlineUsageReportService = offlineUsageReportServiceFactory({ + offlineUsageReportDAL, + licenseService }); const orgAdminService = orgAdminServiceFactory({ @@ -841,9 +875,6 @@ export const registerRoutes = async ( projectDAL, permissionService, projectUserMembershipRoleDAL, - userDAL, - projectBotDAL, - projectKeyDAL, projectMembershipDAL }); @@ -1044,6 +1075,15 @@ export const registerRoutes = async ( kmsService }); + const gatewayService = gatewayServiceFactory({ + permissionService, + gatewayDAL, + kmsService, + licenseService, + orgGatewayConfigDAL, + keyStore + }); + const secretSyncQueue = secretSyncQueueFactory({ queueService, secretSyncDAL, @@ -1067,7 +1107,8 @@ export const registerRoutes = async ( secretVersionTagV2BridgeDAL, resourceMetadataDAL, appConnectionDAL, - licenseService + licenseService, + gatewayService }); const secretQueueService = secretQueueFactory({ @@ -1106,7 +1147,9 @@ export const registerRoutes = async ( resourceMetadataDAL, folderCommitService, secretSyncQueue, - reminderService + reminderService, + eventBusService, + licenseService }); const projectService = projectServiceFactory({ @@ -1119,11 +1162,9 @@ export const registerRoutes = async ( projectBotService, identityProjectDAL, identityOrgMembershipDAL, - projectKeyDAL, userDAL, projectEnvDAL, orgDAL, - orgService, projectMembershipDAL, projectRoleDAL, folderDAL, @@ -1143,7 +1184,6 @@ export const registerRoutes = async ( identityProjectMembershipRoleDAL, keyStore, kmsService, - projectBotDAL, certificateTemplateDAL, projectSlackConfigDAL, slackIntegrationDAL, @@ -1354,7 +1394,8 @@ export const registerRoutes = async ( kmsService, groupDAL, microsoftTeamsService, - projectMicrosoftTeamsConfigDAL + projectMicrosoftTeamsConfigDAL, + notificationService }); const secretReplicationService = secretReplicationServiceFactory({ @@ -1442,7 +1483,17 @@ export const registerRoutes = async ( identityOrgMembershipDAL, identityProjectDAL, licenseService, - identityMetadataDAL + identityMetadataDAL, + keyStore + }); + + const identityAuthTemplateService = identityAuthTemplateServiceFactory({ + identityAuthTemplateDAL, + identityLdapAuthDAL, + permissionService, + kmsService, + licenseService, + auditLogService }); const identityAccessTokenService = identityAccessTokenServiceFactory({ @@ -1487,15 +1538,7 @@ export const registerRoutes = async ( identityAccessTokenDAL, identityUaClientSecretDAL, identityUaDAL, - licenseService - }); - - const gatewayService = gatewayServiceFactory({ - permissionService, - gatewayDAL, - kmsService, licenseService, - orgGatewayConfigDAL, keyStore }); @@ -1597,7 +1640,8 @@ export const registerRoutes = async ( identityAccessTokenDAL, identityOrgMembershipDAL, licenseService, - identityDAL + identityDAL, + identityAuthTemplateDAL }); const dynamicSecretProviders = buildDynamicSecretProviders({ @@ -1649,7 +1693,8 @@ export const registerRoutes = async ( secretVersionV2DAL: secretVersionV2BridgeDAL, identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL, serviceTokenService, - orgService + orgService, + userNotificationDAL }); const dailyReminderQueueService = dailyReminderQueueServiceFactory({ @@ -1729,7 +1774,8 @@ export const registerRoutes = async ( const migrationService = externalMigrationServiceFactory({ externalMigrationQueue, userDAL, - permissionService + permissionService, + gatewayService }); const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({ @@ -1932,7 +1978,8 @@ export const registerRoutes = async ( projectMembershipDAL, smtpService, kmsService, - keyStore + keyStore, + appConnectionDAL }); const secretScanningV2Service = secretScanningV2ServiceFactory({ @@ -1941,7 +1988,8 @@ export const registerRoutes = async ( licenseService, secretScanningV2DAL, secretScanningV2Queue, - kmsService + kmsService, + appConnectionDAL }); // setup the communication with license key server @@ -1957,7 +2005,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(); @@ -1965,6 +2013,7 @@ export const registerRoutes = async ( await kmsService.startService(); await microsoftTeamsService.start(); await dynamicSecretQueueService.init(); + await eventBusService.init(); // inject all services server.decorate("services", { @@ -1981,6 +2030,7 @@ export const registerRoutes = async ( apiKey: apiKeyService, authToken: tokenService, superAdmin: superAdminService, + offlineUsageReport: offlineUsageReportService, project: projectService, projectMembership: projectMembershipService, projectKey: projectKeyService, @@ -1998,6 +2048,7 @@ export const registerRoutes = async ( webhook: webhookService, serviceToken: serviceTokenService, identity: identityService, + identityAuthTemplate: identityAuthTemplateService, identityAccessToken: identityAccessTokenService, identityProject: identityProjectService, identityTokenAuth: identityTokenAuthService, @@ -2071,7 +2122,10 @@ export const registerRoutes = async ( githubOrgSync: githubOrgSyncConfigService, folderCommit: folderCommitService, secretScanningV2: secretScanningV2Service, - reminder: reminderService + reminder: reminderService, + bus: eventBusService, + sse: sseService, + notification: notificationService }); const cronJobs: CronJob[] = []; @@ -2110,8 +2164,14 @@ export const registerRoutes = async ( user: userDAL, kmipClient: kmipClientDAL }); + const shouldForwardWritesToPrimaryInstance = Boolean(envConfig.INFISICAL_PRIMARY_INSTANCE_URL); + if (shouldForwardWritesToPrimaryInstance) { + logger.info(`Infisical primary instance is configured: ${envConfig.INFISICAL_PRIMARY_INSTANCE_URL}`); - await server.register(injectIdentity, { userDAL, serviceTokenDAL }); + await server.register(forwardWritesToPrimary, { primaryUrl: envConfig.INFISICAL_PRIMARY_INSTANCE_URL as string }); + } + + await server.register(injectIdentity, { shouldForwardWritesToPrimaryInstance }); await server.register(injectAssumePrivilege); await server.register(injectPermission); await server.register(injectRateLimits); @@ -2132,7 +2192,8 @@ export const registerRoutes = async ( inviteOnlySignup: z.boolean().optional(), redisConfigured: z.boolean().optional(), secretScanningConfigured: z.boolean().optional(), - samlDefaultOrgSlug: z.string().optional() + samlDefaultOrgSlug: z.string().optional(), + auditLogStorageDisabled: z.boolean().optional() }) } }, @@ -2159,7 +2220,8 @@ export const registerRoutes = async ( inviteOnlySignup: Boolean(serverCfg.allowSignUp), redisConfigured: cfg.isRedisConfigured, secretScanningConfigured: cfg.isSecretScanningConfigured, - samlDefaultOrgSlug: cfg.samlDefaultOrgSlug + samlDefaultOrgSlug: cfg.samlDefaultOrgSlug, + auditLogStorageDisabled: Boolean(cfg.DISABLE_AUDIT_LOG_STORAGE) }; } }); @@ -2187,5 +2249,7 @@ export const registerRoutes = async ( server.addHook("onClose", async () => { cronJobs.forEach((job) => job.stop()); await telemetryService.flushAll(); + await eventBusService.close(); + sseService.close(); }); }; diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 8af4baa8b..344224008 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -246,13 +246,6 @@ export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ metadata: ResourceMetadataSchema.optional() }); -export const SanitizedAuditLogStreamSchema = z.object({ - id: z.string(), - url: z.string(), - createdAt: z.date(), - updatedAt: z.date() -}); - export const SanitizedProjectSchema = ProjectsSchema.pick({ id: true, name: true, diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 6ad368816..7f5a2f374 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -13,6 +13,7 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { addAuthOriginDomainCookie } from "@app/server/lib/cookie"; +import { GenericResourceNameSchema } from "@app/server/lib/schemas"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -53,7 +54,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { defaultAuthOrgAuthMethod: z.string().nullish(), isSecretScanningDisabled: z.boolean(), kubernetesAutoFetchServiceAccountToken: z.boolean(), - paramsFolderSecretDetectionEnabled: z.boolean() + paramsFolderSecretDetectionEnabled: z.boolean(), + isOfflineUsageReportsEnabled: z.boolean() }) }) } @@ -69,7 +71,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { isMigrationModeOn: serverEnvs.MAINTENANCE_MODE, isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING, kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN, - paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED + paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED, + isOfflineUsageReportsEnabled: !!serverEnvs.LICENSE_KEY_OFFLINE } }; } @@ -215,7 +218,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }), membershipId: z.string(), role: z.string(), - roleId: z.string().nullish() + roleId: z.string().nullish(), + status: z.string().nullish() }) .array(), projects: z @@ -464,6 +468,42 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "DELETE", + url: "/user-management/users", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + userIds: z.string().array() + }), + response: { + 200: z.object({ + users: UsersSchema.pick({ + username: true, + firstName: true, + lastName: true, + email: true, + id: true + }).array() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const users = await server.services.superAdmin.deleteUsers(req.body.userIds); + + return { + users + }; + } + }); + server.route({ method: "PATCH", url: "/user-management/users/:userId/admin-access", @@ -547,16 +587,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({ @@ -811,4 +842,121 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "POST", + url: "/organization-management/organizations", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + name: GenericResourceNameSchema, + inviteAdminEmails: z.string().email().array().min(1) + }), + response: { + 200: z.object({ + organization: OrganizationsSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organization = await server.services.superAdmin.createOrganization(req.body, req.permission); + return { organization }; + } + }); + + server.route({ + method: "POST", + url: "/organization-management/organizations/:organizationId/memberships/:membershipId/resend-invite", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + organizationId: z.string(), + membershipId: z.string() + }), + response: { + 200: z.object({ + organizationMembership: OrgMembershipsSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organizationMembership = await server.services.superAdmin.resendOrgInvite(req.params, req.permission); + return { organizationMembership }; + } + }); + + server.route({ + method: "POST", + url: "/organization-management/organizations/:organizationId/access", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + organizationId: z.string() + }), + response: { + 200: z.object({ + organizationMembership: OrgMembershipsSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organizationMembership = await server.services.superAdmin.joinOrganization( + req.params.organizationId, + req.permission + ); + return { organizationMembership }; + } + }); + + server.route({ + method: "POST", + url: "/usage-report/generate", + config: { + rateLimit: writeLimit + }, + schema: { + response: { + 200: z.object({ + csvContent: z.string(), + signature: z.string(), + filename: z.string() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const result = await server.services.offlineUsageReport.generateUsageReportCSV(); + + return { + csvContent: result.csvContent, + signature: result.signature, + filename: result.filename + }; + } + }); }; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 8468d7305..c2033f4b4 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -15,6 +15,10 @@ import { } from "@app/services/app-connection/1password"; import { Auth0ConnectionListItemSchema, SanitizedAuth0ConnectionSchema } from "@app/services/app-connection/auth0"; import { AwsConnectionListItemSchema, SanitizedAwsConnectionSchema } from "@app/services/app-connection/aws"; +import { + AzureADCSConnectionListItemSchema, + SanitizedAzureADCSConnectionSchema +} from "@app/services/app-connection/azure-adcs/azure-adcs-connection-schemas"; import { AzureAppConfigurationConnectionListItemSchema, SanitizedAzureAppConfigurationConnectionSchema @@ -75,6 +79,10 @@ import { import { LdapConnectionListItemSchema, SanitizedLdapConnectionSchema } from "@app/services/app-connection/ldap"; import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql"; import { MySqlConnectionListItemSchema, SanitizedMySqlConnectionSchema } from "@app/services/app-connection/mysql"; +import { + NetlifyConnectionListItemSchema, + SanitizedNetlifyConnectionSchema +} from "@app/services/app-connection/netlify"; import { OktaConnectionListItemSchema, SanitizedOktaConnectionSchema } from "@app/services/app-connection/okta"; import { PostgresConnectionListItemSchema, @@ -145,7 +153,9 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedChecklyConnectionSchema.options, ...SanitizedSupabaseConnectionSchema.options, ...SanitizedDigitalOceanConnectionSchema.options, - ...SanitizedOktaConnectionSchema.options + ...SanitizedNetlifyConnectionSchema.options, + ...SanitizedOktaConnectionSchema.options, + ...SanitizedAzureADCSConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -184,7 +194,9 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ ChecklyConnectionListItemSchema, SupabaseConnectionListItemSchema, DigitalOceanConnectionListItemSchema, - OktaConnectionListItemSchema + NetlifyConnectionListItemSchema, + OktaConnectionListItemSchema, + AzureADCSConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/azure-adcs-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/azure-adcs-connection-router.ts new file mode 100644 index 000000000..cd4d4d1fd --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/azure-adcs-connection-router.ts @@ -0,0 +1,18 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateAzureADCSConnectionSchema, + SanitizedAzureADCSConnectionSchema, + UpdateAzureADCSConnectionSchema +} from "@app/services/app-connection/azure-adcs"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerAzureADCSConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.AzureADCS, + server, + sanitizedResponseSchema: SanitizedAzureADCSConnectionSchema, + createSchema: CreateAzureADCSConnectionSchema, + updateSchema: UpdateAzureADCSConnectionSchema + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts index bbe3fbbfb..668bea065 100644 --- a/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts @@ -53,4 +53,36 @@ export const registerChecklyConnectionRouter = async (server: FastifyZodProvider return { accounts }; } }); + + server.route({ + method: "GET", + url: `/:connectionId/accounts/:accountId/groups`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid(), + accountId: z.string() + }), + response: { + 200: z.object({ + groups: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId, accountId } = req.params; + + const groups = await server.services.appConnection.checkly.listGroups(connectionId, accountId, req.permission); + + return { groups }; + } + }); }; diff --git a/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts index d18d0564e..dea917947 100644 --- a/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts @@ -46,7 +46,6 @@ export const registerCloudflareConnectionRouter = async (server: FastifyZodProvi const { connectionId } = req.params; const projects = await server.services.appConnection.cloudflare.listPagesProjects(connectionId, req.permission); - return projects; } }); @@ -73,9 +72,36 @@ export const registerCloudflareConnectionRouter = async (server: FastifyZodProvi handler: async (req) => { const { connectionId } = req.params; - const projects = await server.services.appConnection.cloudflare.listWorkersScripts(connectionId, req.permission); + const scripts = await server.services.appConnection.cloudflare.listWorkersScripts(connectionId, req.permission); + return scripts; + } + }); - return projects; + server.route({ + method: "GET", + url: `/:connectionId/cloudflare-zones`, + 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 zones = await server.services.appConnection.cloudflare.listZones(connectionId, req.permission); + return zones; } }); }; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 6744e46b3..70804d173 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -5,6 +5,7 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums import { registerOnePassConnectionRouter } from "./1password-connection-router"; import { registerAuth0ConnectionRouter } from "./auth0-connection-router"; import { registerAwsConnectionRouter } from "./aws-connection-router"; +import { registerAzureADCSConnectionRouter } from "./azure-adcs-connection-router"; import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-configuration-connection-router"; import { registerAzureClientSecretsConnectionRouter } from "./azure-client-secrets-connection-router"; import { registerAzureDevOpsConnectionRouter } from "./azure-devops-connection-router"; @@ -26,6 +27,7 @@ import { registerHumanitecConnectionRouter } from "./humanitec-connection-router import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; +import { registerNetlifyConnectionRouter } from "./netlify-connection-router"; import { registerOktaConnectionRouter } from "./okta-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerRailwayConnectionRouter } from "./railway-connection-router"; @@ -49,6 +51,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Netlify, + server, + sanitizedResponseSchema: SanitizedNetlifyConnectionSchema, + createSchema: CreateNetlifyConnectionSchema, + updateSchema: UpdateNetlifyConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/accounts`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + accounts: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const accounts = await server.services.appConnection.netlify.listAccounts(connectionId, req.permission); + + return { accounts }; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/accounts/:accountId/sites`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid(), + accountId: z.string() + }), + response: { + 200: z.object({ + sites: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId, accountId } = req.params; + + const sites = await server.services.appConnection.netlify.listSites(connectionId, req.permission, accountId); + + return { sites }; + } + }); +}; 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/certificate-authority-routers/azure-ad-cs-certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-routers/azure-ad-cs-certificate-authority-router.ts new file mode 100644 index 000000000..28266b2f0 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-authority-routers/azure-ad-cs-certificate-authority-router.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + AzureAdCsCertificateAuthoritySchema, + CreateAzureAdCsCertificateAuthoritySchema, + UpdateAzureAdCsCertificateAuthoritySchema +} from "@app/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; + +import { registerCertificateAuthorityEndpoints } from "./certificate-authority-endpoints"; + +export const registerAzureAdCsCertificateAuthorityRouter = async (server: FastifyZodProvider) => { + registerCertificateAuthorityEndpoints({ + caType: CaType.AZURE_AD_CS, + server, + responseSchema: AzureAdCsCertificateAuthoritySchema, + createSchema: CreateAzureAdCsCertificateAuthoritySchema, + updateSchema: UpdateAzureAdCsCertificateAuthoritySchema + }); + + server.route({ + method: "GET", + url: "/:caId/templates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + description: "Get available certificate templates from Azure AD CS CA", + params: z.object({ + caId: z.string().describe("Azure AD CS CA ID") + }), + querystring: z.object({ + projectId: z.string().describe("Project ID") + }), + response: { + 200: z.object({ + templates: z.array( + z.object({ + id: z.string().describe("Template identifier"), + name: z.string().describe("Template display name"), + description: z.string().optional().describe("Template description") + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const templates = await server.services.certificateAuthority.getAzureAdcsTemplates({ + caId: req.params.caId, + projectId: req.query.projectId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.GET_AZURE_AD_TEMPLATES, + metadata: { + caId: req.params.caId, + amount: templates.length + } + } + }); + + return { templates }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-authority-routers/index.ts b/backend/src/server/routes/v1/certificate-authority-routers/index.ts index 56a236911..d146e68be 100644 --- a/backend/src/server/routes/v1/certificate-authority-routers/index.ts +++ b/backend/src/server/routes/v1/certificate-authority-routers/index.ts @@ -1,6 +1,7 @@ import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { registerAcmeCertificateAuthorityRouter } from "./acme-certificate-authority-router"; +import { registerAzureAdCsCertificateAuthorityRouter } from "./azure-ad-cs-certificate-authority-router"; import { registerInternalCertificateAuthorityRouter } from "./internal-certificate-authority-router"; export * from "./internal-certificate-authority-router"; @@ -8,5 +9,6 @@ export * from "./internal-certificate-authority-router"; export const CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP: Record Promise> = { [CaType.INTERNAL]: registerInternalCertificateAuthorityRouter, - [CaType.ACME]: registerAcmeCertificateAuthorityRouter + [CaType.ACME]: registerAcmeCertificateAuthorityRouter, + [CaType.AZURE_AD_CS]: registerAzureAdCsCertificateAuthorityRouter }; diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 029d4a848..a54bd5ccf 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { z } from "zod"; import { SecretFoldersSchema, SecretImportsSchema, UsersSchema } from "@app/db/schemas"; +import { RemindersSchema } from "@app/db/schemas/reminders"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; @@ -628,7 +629,10 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), - tags: SanitizedTagSchema.array().optional() + tags: SanitizedTagSchema.array().optional(), + reminder: RemindersSchema.extend({ + recipients: z.string().array().optional() + }).nullish() }) .array() .optional(), @@ -699,6 +703,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { // prevent older projects from accessing endpoint if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" }); + // verify folder exists and user has project permission + await server.services.folder.getFolderByPath({ projectId, environment, secretPath }, req.permission); + const tags = req.query.tags?.split(",") ?? []; let remainingLimit = limit; @@ -706,7 +713,11 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let imports: Awaited> | undefined; let folders: Awaited> | undefined; - let secrets: Awaited>["secrets"] | undefined; + let secrets: + | (Awaited>["secrets"][number] & { + reminder: Awaited>[string] | null; + })[] + | undefined; let dynamicSecrets: Awaited> | undefined; let secretRotations: | Awaited> @@ -904,7 +915,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }); if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { - secrets = ( + const rawSecrets = ( await server.services.secret.getSecretsRaw({ actorId: req.permission.id, actor: req.permission.type, @@ -925,6 +936,15 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeMetadataInSearch: true }) ).secrets; + + const reminders = await server.services.reminder.getRemindersForDashboard( + rawSecrets.map((secret) => secret.id) + ); + + secrets = rawSecrets.map((secret) => ({ + ...secret, + reminder: reminders[secret.id] ?? null + })); } } } catch (error) { diff --git a/backend/src/server/routes/v1/event-router.ts b/backend/src/server/routes/v1/event-router.ts new file mode 100644 index 000000000..156819df9 --- /dev/null +++ b/backend/src/server/routes/v1/event-router.ts @@ -0,0 +1,123 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import { subject } from "@casl/ability"; +import { pipeline } from "stream/promises"; +import { z } from "zod"; + +import { ActionProjectType, ProjectType } from "@app/db/schemas"; +import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream"; +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"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerEventRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/subscribe/project-events", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Events], + description: "Subscribe to project events", + body: z.object({ + projectId: z.string().trim().describe(EventSubscriptions.SUBSCRIBE_PROJECT_EVENTS.projectId), + register: z.array(EventRegisterSchema).min(1).max(10) + }), + produces: ["text/event-stream"] + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req, reply) => { + try { + const { sse, permission, identityAccessToken, authToken, license } = req.server.services; + + const plan = await license.getPlan(req.auth.orgId); + + if (!plan.eventSubscriptions) { + throw new BadRequestError({ + message: + "Failed to use event subscriptions due to plan restriction. Upgrade plan to access enterprise event subscriptions." + }); + } + + const count = await sse.getActiveConnectionsCount(req.body.projectId, req.permission.id); + + if (count >= 5) { + throw new RateLimitError({ + message: `Too many active connections for project ${req.body.projectId}. Please close some connections before opening a new one.` + }); + } + + const client = await sse.subscribe({ + type: ProjectType.SecretManager, + registered: req.body.register, + async getAuthInfo() { + const ability = await permission.getProjectPermission({ + actor: req.auth.actor, + projectId: req.body.projectId, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: req.auth.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId + }); + + return { permission: ability.permission, actorId: req.permission.id, projectId: req.body.projectId }; + }, + async onAuthRefresh(info) { + switch (req.auth.authMode) { + case AuthMode.JWT: + await authToken.fnValidateJwtIdentity(req.auth.token); + break; + case AuthMode.IDENTITY_ACCESS_TOKEN: + await identityAccessToken.fnValidateIdentityAccessToken(req.auth.token, req.realIp); + break; + default: + throw new Error("Unsupported authentication method"); + } + + req.body.register.forEach((r) => { + const fields = { + environment: r.conditions?.environmentSlug ?? "", + secretPath: r.conditions?.secretPath ?? "/" + }; + + 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 ${ProjectPermissionSub.SecretEvents}`, + details: { + action, + environmentSlug: fields.environment, + secretPath: fields.secretPath + } + }); + } + }); + } + }); + + // Switches to manual response and enable SSE streaming + reply.hijack(); + reply.raw.writeHead(200, getServerSentEventsHeaders()).flushHeaders(); + reply.raw.on("close", client.abort); + + await pipeline(client.stream, reply.raw, { signal: client.signal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + // If the stream is aborted, we don't need to do anything + return; + } + + throw error; + } + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index 3da8a425b..5d3612bf5 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -200,49 +200,104 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) params: z.object({ identityId: z.string().trim().describe(LDAP_AUTH.ATTACH.identityId) }), - body: z - .object({ - url: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.url), - bindDN: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.bindDN), - bindPass: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.bindPass), - searchBase: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.searchBase), - searchFilter: z - .string() - .trim() - .min(1) - .default("(uid={{username}})") - .refine(isValidLdapFilter, "Invalid LDAP search filter") - .describe(LDAP_AUTH.ATTACH.searchFilter), - allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.ATTACH.allowedFields), - ldapCaCertificate: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.ldapCaCertificate), - accessTokenTrustedIps: z - .object({ - ipAddress: z.string().trim() - }) - .array() - .min(1) - .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) - .describe(LDAP_AUTH.ATTACH.accessTokenTrustedIps), - accessTokenTTL: z - .number() - .int() - .min(0) - .max(315360000) - .default(2592000) - .describe(LDAP_AUTH.ATTACH.accessTokenTTL), - accessTokenMaxTTL: z - .number() - .int() - .min(1) - .max(315360000) - .default(2592000) - .describe(LDAP_AUTH.ATTACH.accessTokenMaxTTL), - accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) - }) - .refine( - (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, - "Access Token TTL cannot be greater than Access Token Max TTL." - ), + body: z.union([ + // Template-based configuration + z + .object({ + templateId: z.string().trim().describe(LDAP_AUTH.ATTACH.templateId), + searchFilter: z + .string() + .trim() + .min(1) + .default("(uid={{username}})") + .refine(isValidLdapFilter, "Invalid LDAP search filter") + .describe(LDAP_AUTH.ATTACH.searchFilter), + allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.ATTACH.allowedFields), + ldapCaCertificate: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.ldapCaCertificate), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(LDAP_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(0) + .max(315360000) + .default(2592000) + .describe(LDAP_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .min(1) + .max(315360000) + .default(2592000) + .describe(LDAP_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z + .number() + .int() + .min(0) + .default(0) + .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) + }) + .refine( + (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, + "Access Token TTL cannot be greater than Access Token Max TTL." + ), + + // Manual configuration + z + .object({ + url: z.string().trim().describe(LDAP_AUTH.ATTACH.url), + bindDN: z.string().trim().describe(LDAP_AUTH.ATTACH.bindDN), + bindPass: z.string().trim().describe(LDAP_AUTH.ATTACH.bindPass), + searchBase: z.string().trim().describe(LDAP_AUTH.ATTACH.searchBase), + searchFilter: z + .string() + .trim() + .min(1) + .default("(uid={{username}})") + .refine(isValidLdapFilter, "Invalid LDAP search filter") + .describe(LDAP_AUTH.ATTACH.searchFilter), + allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.ATTACH.allowedFields), + ldapCaCertificate: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.ldapCaCertificate), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(LDAP_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(0) + .max(315360000) + .default(2592000) + .describe(LDAP_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .min(1) + .max(315360000) + .default(2592000) + .describe(LDAP_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z + .number() + .int() + .min(0) + .default(0) + .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) + }) + .refine( + (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, + "Access Token TTL cannot be greater than Access Token Max TTL." + ) + ]), response: { 200: z.object({ identityLdapAuth: IdentityLdapAuthsSchema.omit({ @@ -275,7 +330,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, accessTokenTTL: identityLdapAuth.accessTokenTTL, accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, - allowedFields: req.body.allowedFields + allowedFields: req.body.allowedFields, + templateId: identityLdapAuth.templateId } } }); @@ -309,6 +365,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) bindDN: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.bindDN), bindPass: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.bindPass), searchBase: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.searchBase), + templateId: z.string().trim().optional().describe(LDAP_AUTH.UPDATE.templateId), searchFilter: z .string() .trim() @@ -376,7 +433,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) accessTokenTTL: identityLdapAuth.accessTokenTTL, accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, accessTokenTrustedIps: identityLdapAuth.accessTokenTrustedIps as TIdentityTrustedIp[], - allowedFields: req.body.allowedFields + allowedFields: req.body.allowedFields, + templateId: identityLdapAuth.templateId } } }); @@ -413,7 +471,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) }).extend({ bindDN: z.string(), bindPass: z.string(), - ldapCaCertificate: z.string().optional() + ldapCaCertificate: z.string().optional(), + templateId: z.string().optional().nullable() }) }) } diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index c0578fc0a..65b9448c5 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -250,7 +250,8 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { description: true }).optional(), identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ - authMethods: z.array(z.string()) + authMethods: z.array(z.string()), + activeLockoutAuthMethods: z.array(z.string()) }) }) }) @@ -478,4 +479,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/identity-universal-auth-router.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts index 09fffbff8..6d911a88e 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -137,7 +137,21 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { .min(0) .default(0) .describe(UNIVERSAL_AUTH.ATTACH.accessTokenNumUsesLimit), - accessTokenPeriod: z.number().int().min(0).default(0).describe(UNIVERSAL_AUTH.ATTACH.accessTokenPeriod) + accessTokenPeriod: z.number().int().min(0).default(0).describe(UNIVERSAL_AUTH.ATTACH.accessTokenPeriod), + lockoutEnabled: z.boolean().default(true).describe(UNIVERSAL_AUTH.ATTACH.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).default(3).describe(UNIVERSAL_AUTH.ATTACH.lockoutThreshold), + lockoutDurationSeconds: z + .number() + .min(30) + .max(86400) + .default(300) + .describe(UNIVERSAL_AUTH.ATTACH.lockoutDurationSeconds), + lockoutCounterResetSeconds: z + .number() + .min(5) + .max(3600) + .default(30) + .describe(UNIVERSAL_AUTH.ATTACH.lockoutCounterResetSeconds) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -171,7 +185,11 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL, accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps as TIdentityTrustedIp[], clientSecretTrustedIps: identityUniversalAuth.clientSecretTrustedIps as TIdentityTrustedIp[], - accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit + accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit, + lockoutEnabled: identityUniversalAuth.lockoutEnabled, + lockoutThreshold: identityUniversalAuth.lockoutThreshold, + lockoutDurationSeconds: identityUniversalAuth.lockoutDurationSeconds, + lockoutCounterResetSeconds: identityUniversalAuth.lockoutCounterResetSeconds } } }); @@ -243,7 +261,21 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { .min(0) .max(315360000) .optional() - .describe(UNIVERSAL_AUTH.UPDATE.accessTokenPeriod) + .describe(UNIVERSAL_AUTH.UPDATE.accessTokenPeriod), + lockoutEnabled: z.boolean().optional().describe(UNIVERSAL_AUTH.UPDATE.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).optional().describe(UNIVERSAL_AUTH.UPDATE.lockoutThreshold), + lockoutDurationSeconds: z + .number() + .min(30) + .max(86400) + .optional() + .describe(UNIVERSAL_AUTH.UPDATE.lockoutDurationSeconds), + lockoutCounterResetSeconds: z + .number() + .min(5) + .max(3600) + .optional() + .describe(UNIVERSAL_AUTH.UPDATE.lockoutCounterResetSeconds) }) .refine( (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), @@ -276,7 +308,11 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL, accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps as TIdentityTrustedIp[], clientSecretTrustedIps: identityUniversalAuth.clientSecretTrustedIps as TIdentityTrustedIp[], - accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit + accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit, + lockoutEnabled: identityUniversalAuth.lockoutEnabled, + lockoutThreshold: identityUniversalAuth.lockoutThreshold, + lockoutDurationSeconds: identityUniversalAuth.lockoutDurationSeconds, + lockoutCounterResetSeconds: identityUniversalAuth.lockoutCounterResetSeconds } } }); @@ -594,4 +630,53 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { return { clientSecretData }; } }); + + server.route({ + method: "POST", + url: "/universal-auth/identities/:identityId/clear-lockouts", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.UniversalAuth], + description: "Clear Universal Auth Lockouts for identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(UNIVERSAL_AUTH.CLEAR_CLIENT_LOCKOUTS.identityId) + }), + response: { + 200: z.object({ + deleted: z.number() + }) + } + }, + handler: async (req) => { + const clearLockoutsData = await server.services.identityUa.clearUniversalAuthLockouts({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: clearLockoutsData.orgId, + event: { + type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS, + metadata: { + identityId: clearLockoutsData.identityId + } + } + }); + + return clearLockoutsData; + } + }); }; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 848bd4f31..6108be32b 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -13,6 +13,7 @@ import { registerCaRouter } from "./certificate-authority-router"; import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers"; import { registerCertRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; +import { registerEventRouter } from "./event-router"; import { registerExternalGroupOrgRoleMappingRouter } from "./external-group-org-role-mapping-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAliCloudAuthRouter } from "./identity-alicloud-auth-router"; @@ -32,6 +33,7 @@ import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router"; +import { registerNotificationRouter } from "./notification-router"; import { registerOrgAdminRouter } from "./org-admin-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; @@ -82,6 +84,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerAdminRouter, { prefix: "/admin" }); await server.register(registerOrgAdminRouter, { prefix: "/organization-admin" }); await server.register(registerUserRouter, { prefix: "/user" }); + await server.register(registerNotificationRouter, { prefix: "/notifications" }); await server.register(registerInviteOrgRouter, { prefix: "/invite-org" }); await server.register(registerUserActionRouter, { prefix: "/user-action" }); await server.register(registerSecretImportRouter, { prefix: "/secret-imports" }); @@ -183,4 +186,6 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { }, { prefix: "/reminders" } ); + + await server.register(registerEventRouter, { prefix: "/events" }); }; diff --git a/backend/src/server/routes/v1/notification-router.ts b/backend/src/server/routes/v1/notification-router.ts new file mode 100644 index 000000000..5f72b88d6 --- /dev/null +++ b/backend/src/server/routes/v1/notification-router.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +import { UserNotificationsSchema } from "@app/db/schemas/user-notifications"; +import { UnauthorizedError } from "@app/lib/errors"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerNotificationRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/user", + config: { + rateLimit: readLimit + }, + method: "GET", + schema: { + response: { + 200: z.object({ + notifications: UserNotificationsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + if (req.auth.authMode !== AuthMode.JWT) { + throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); + } + + const notifications = await server.services.notification.listUserNotifications({ + userId: req.auth.userId, + orgId: req.auth.orgId + }); + + return { notifications }; + } + }); + + server.route({ + url: "/user/:notificationId", + config: { + rateLimit: writeLimit + }, + method: "DELETE", + schema: { + params: z.object({ + notificationId: z.string() + }), + response: { + 200: z.object({ + notification: UserNotificationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + if (req.auth.authMode !== AuthMode.JWT) { + throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); + } + + const notification = await server.services.notification.deleteUserNotification({ + notificationId: req.params.notificationId, + userId: req.auth.userId + }); + + return { notification }; + } + }); + + server.route({ + url: "/user/:notificationId", + config: { + rateLimit: writeLimit + }, + method: "PATCH", + schema: { + params: z.object({ + notificationId: z.string() + }), + body: z.object({ + isRead: z.boolean() + }), + response: { + 200: z.object({ + notification: UserNotificationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + if (req.auth.authMode !== AuthMode.JWT) { + throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); + } + + const notification = await server.services.notification.updateUserNotification({ + notificationId: req.params.notificationId, + userId: req.auth.userId, + ...req.body + }); + + return { notification }; + } + }); + + // Mark all user notifications as read + server.route({ + url: "/user/mark-as-read", + config: { + rateLimit: writeLimit + }, + method: "POST", + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + if (req.auth.authMode !== AuthMode.JWT) { + throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" }); + } + + await server.services.notification.markUserNotificationsAsRead({ + userId: req.auth.userId, + orgId: req.auth.orgId + }); + } + }); +}; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index e1669c784..b8eb3ad6b 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -247,7 +247,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { lastName: true, id: true, superAdmin: true - }).merge(z.object({ publicKey: z.string().nullable() })) + }).merge(z.object({ publicKey: z.string().nullable().optional() })) }) ) .omit({ createdAt: true, updatedAt: true }) @@ -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/password-router.ts b/backend/src/server/routes/v1/password-router.ts index 3396cebe9..72d20db57 100644 --- a/backend/src/server/routes/v1/password-router.ts +++ b/backend/src/server/routes/v1/password-router.ts @@ -9,73 +9,6 @@ import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { UserEncryption } from "@app/services/user/user-types"; export const registerPasswordRouter = async (server: FastifyZodProvider) => { - server.route({ - method: "POST", - url: "/srp1", - config: { - rateLimit: authRateLimit - }, - schema: { - body: z.object({ - clientPublicKey: z.string().trim() - }), - response: { - 200: z.object({ - serverPublicKey: z.string(), - salt: z.string() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const { salt, serverPublicKey } = await server.services.password.generateServerPubKey( - req.permission.id, - req.body.clientPublicKey - ); - return { salt, serverPublicKey }; - } - }); - - server.route({ - method: "POST", - url: "/change-password", - config: { - rateLimit: authRateLimit - }, - schema: { - body: z.object({ - clientProof: z.string().trim(), - protectedKey: z.string().trim(), - protectedKeyIV: z.string().trim(), - protectedKeyTag: z.string().trim(), - encryptedPrivateKey: z.string().trim(), - encryptedPrivateKeyIV: z.string().trim(), - encryptedPrivateKeyTag: z.string().trim(), - salt: z.string().trim(), - verifier: z.string().trim(), - password: z.string().trim() - }), - response: { - 200: z.object({ - message: z.string() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req, res) => { - const appCfg = getConfig(); - await server.services.password.changePassword({ ...req.body, userId: req.permission.id }); - - void res.cookie("jid", "", { - httpOnly: true, - path: "/", - sameSite: "strict", - secure: appCfg.HTTPS_ENABLED - }); - return { message: "Successfully changed password" }; - } - }); - server.route({ method: "POST", url: "/email/password-reset", @@ -131,41 +64,6 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { } }); - server.route({ - method: "POST", - url: "/backup-private-key", - config: { - rateLimit: authRateLimit - }, - onRequest: verifyAuth([AuthMode.JWT]), - schema: { - body: z.object({ - clientProof: z.string().trim(), - encryptedPrivateKey: z.string().trim(), - iv: z.string().trim(), - tag: z.string().trim(), - salt: z.string().trim(), - verifier: z.string().trim() - }), - response: { - 200: z.object({ - message: z.string(), - backupPrivateKey: BackupPrivateKeySchema.omit({ verifier: true }) - }) - } - }, - handler: async (req) => { - const token = validateSignUpAuthorization(req.headers.authorization as string, "", false)!; - const backupPrivateKey = await server.services.password.createBackupPrivateKey({ - ...req.body, - userId: token.userId - }); - if (!backupPrivateKey) throw new Error("Failed to create backup key"); - - return { message: "Successfully updated backup private key", backupPrivateKey }; - } - }); - server.route({ method: "GET", url: "/backup-private-key", @@ -257,14 +155,6 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - protectedKey: z.string().trim(), - protectedKeyIV: z.string().trim(), - protectedKeyTag: z.string().trim(), - encryptedPrivateKey: z.string().trim(), - encryptedPrivateKeyIV: z.string().trim(), - encryptedPrivateKeyTag: z.string().trim(), - salt: z.string().trim(), - verifier: z.string().trim(), password: z.string().trim(), token: z.string().trim() }), diff --git a/backend/src/server/routes/v1/pki-subscriber-router.ts b/backend/src/server/routes/v1/pki-subscriber-router.ts index 0e9ec6e0c..30202af18 100644 --- a/backend/src/server/routes/v1/pki-subscriber-router.ts +++ b/backend/src/server/routes/v1/pki-subscriber-router.ts @@ -1,3 +1,4 @@ +import RE2 from "re2"; import { z } from "zod"; import { CertificatesSchema } from "@app/db/schemas"; @@ -112,7 +113,88 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => .transform((arr) => Array.from(new Set(arr))) .describe(PKI_SUBSCRIBERS.CREATE.extendedKeyUsages), enableAutoRenewal: z.boolean().optional().describe(PKI_SUBSCRIBERS.CREATE.enableAutoRenewal), - autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.CREATE.autoRenewalPeriodInDays) + autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.CREATE.autoRenewalPeriodInDays), + properties: z + .object({ + azureTemplateType: z.string().optional().describe("Azure ADCS Certificate Template Type"), + organization: z + .string() + .trim() + .min(1) + .max(64, "Organization cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Organization contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Organization cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Organization (O) - Maximum 64 characters, no special DN characters"), + organizationalUnit: z + .string() + .trim() + .min(1) + .max(64, "Organizational Unit cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Organizational Unit contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Organizational Unit cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Organizational Unit (OU) - Maximum 64 characters, no special DN characters"), + country: z + .string() + .trim() + .length(2, "Country must be exactly 2 characters") + .regex(new RE2("^[A-Z]{2}$"), "Country must be exactly 2 uppercase letters") + .optional() + .describe("Country (C) - Two uppercase letter country code (e.g., US, CA, GB)"), + state: z + .string() + .trim() + .min(1) + .max(64, "State cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'State contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "State cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("State/Province (ST) - Maximum 64 characters, no special DN characters"), + locality: z + .string() + .trim() + .min(1) + .max(64, "Locality cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Locality contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Locality cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Locality (L) - Maximum 64 characters, no special DN characters"), + emailAddress: z + .string() + .trim() + .email("Email Address must be a valid email format") + .min(6, "Email Address must be at least 6 characters") + .max(64, "Email Address cannot exceed 64 characters") + .optional() + .describe("Email Address - Valid email format between 6 and 64 characters") + }) + .optional() + .describe("Additional subscriber properties and subject fields") }), response: { 200: sanitizedPkiSubscriber @@ -199,7 +281,88 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => .optional() .describe(PKI_SUBSCRIBERS.UPDATE.extendedKeyUsages), enableAutoRenewal: z.boolean().optional().describe(PKI_SUBSCRIBERS.UPDATE.enableAutoRenewal), - autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.UPDATE.autoRenewalPeriodInDays) + autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.UPDATE.autoRenewalPeriodInDays), + properties: z + .object({ + azureTemplateType: z.string().optional().describe("Azure ADCS Certificate Template Type"), + organization: z + .string() + .trim() + .min(1) + .max(64, "Organization cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Organization contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Organization cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Organization (O) - Maximum 64 characters, no special DN characters"), + organizationalUnit: z + .string() + .trim() + .min(1) + .max(64, "Organizational Unit cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Organizational Unit contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Organizational Unit cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Organizational Unit (OU) - Maximum 64 characters, no special DN characters"), + country: z + .string() + .trim() + .length(2, "Country must be exactly 2 characters") + .regex(new RE2("^[A-Z]{2}$"), "Country must be exactly 2 uppercase letters") + .optional() + .describe("Country (C) - Two uppercase letter country code (e.g., US, CA, GB)"), + state: z + .string() + .trim() + .min(1) + .max(64, "State cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'State contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "State cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("State/Province (ST) - Maximum 64 characters, no special DN characters"), + locality: z + .string() + .trim() + .min(1) + .max(64, "Locality cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Locality contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Locality cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Locality (L) - Maximum 64 characters, no special DN characters"), + emailAddress: z + .string() + .trim() + .email("Email Address must be a valid email format") + .min(6, "Email Address must be at least 6 characters") + .max(64, "Email Address cannot exceed 64 characters") + .optional() + .describe("Email Address - Valid email format between 6 and 64 characters") + }) + .optional() + .describe("Additional subscriber properties and subject fields") }), response: { 200: sanitizedPkiSubscriber diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 6f981f61f..5e7dce76e 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -52,7 +52,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 200: z.object({ publicKeys: z .object({ - publicKey: z.string().optional(), + publicKey: z.string().nullable().optional(), userId: z.string() }) .array() @@ -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/reminder-routers/secret-reminder-router.ts b/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts index 4aa68197f..046607da1 100644 --- a/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts +++ b/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts @@ -22,6 +22,7 @@ export const registerSecretReminderRouter = async (server: FastifyZodProvider) = message: z.string().trim().max(1024).optional(), repeatDays: z.number().min(1).nullable().optional(), nextReminderDate: z.string().datetime().nullable().optional(), + fromDate: z.string().datetime().nullable().optional(), recipients: z.string().array().optional() }) .refine((data) => { @@ -45,6 +46,7 @@ export const registerSecretReminderRouter = async (server: FastifyZodProvider) = message: req.body.message, repeatDays: req.body.repeatDays, nextReminderDate: req.body.nextReminderDate, + fromDate: req.body.fromDate, recipients: req.body.recipients } }); 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/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 03cf9d053..fed56277e 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -21,6 +21,7 @@ import { registerGitLabSyncRouter } from "./gitlab-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerNetlifySyncRouter } from "./netlify-sync-router"; import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; import { registerSupabaseSyncRouter } from "./supabase-sync-router"; @@ -61,5 +62,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.Netlify, + server, + responseSchema: NetlifySyncSchema, + createSchema: CreateNetlifySyncSchema, + updateSchema: UpdateNetlifySyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts index 0388ab53e..71e8b2cca 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -44,6 +44,7 @@ import { GitLabSyncListItemSchema, GitLabSyncSchema } from "@app/services/secret import { HCVaultSyncListItemSchema, HCVaultSyncSchema } from "@app/services/secret-sync/hc-vault"; import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret-sync/heroku"; import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec"; +import { NetlifySyncListItemSchema, NetlifySyncSchema } from "@app/services/secret-sync/netlify"; import { RailwaySyncListItemSchema, RailwaySyncSchema } from "@app/services/secret-sync/railway/railway-sync-schemas"; import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas"; import { SupabaseSyncListItemSchema, SupabaseSyncSchema } from "@app/services/secret-sync/supabase"; @@ -82,6 +83,7 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ RailwaySyncSchema, ChecklySyncSchema, DigitalOceanAppPlatformSyncSchema, + NetlifySyncSchema, BitbucketSyncSchema ]); @@ -114,6 +116,7 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ RailwaySyncListItemSchema, ChecklySyncListItemSchema, SupabaseSyncListItemSchema, + NetlifySyncListItemSchema, BitbucketSyncListItemSchema ]); 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/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index a0c3592f7..7ef2e0d33 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -19,23 +19,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { schema: { response: { 200: z.object({ - user: UsersSchema.merge( - UserEncryptionKeysSchema.pick({ - clientPublicKey: true, - serverPrivateKey: true, - encryptionVersion: true, - protectedKey: true, - protectedKeyIV: true, - protectedKeyTag: true, - publicKey: true, - encryptedPrivateKey: true, - iv: true, - tag: true, - salt: true, - verifier: true, - userId: true - }) - ) + user: UsersSchema.extend({ + encryptionVersion: z.number() + }) }) } }, @@ -94,26 +80,6 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { } }); - server.route({ - method: "GET", - url: "/private-key", - config: { - rateLimit: readLimit - }, - schema: { - response: { - 200: z.object({ - privateKey: z.string() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), - handler: async (req) => { - const privateKey = await server.services.user.getUserPrivateKey(req.permission.id); - return { privateKey }; - } - }); - server.route({ method: "GET", url: "/:userId/unlock", diff --git a/backend/src/server/routes/v2/certificate-authority-router.ts b/backend/src/server/routes/v2/certificate-authority-router.ts index d8b434fdf..28d5527a8 100644 --- a/backend/src/server/routes/v2/certificate-authority-router.ts +++ b/backend/src/server/routes/v2/certificate-authority-router.ts @@ -6,12 +6,14 @@ import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { AcmeCertificateAuthoritySchema } from "@app/services/certificate-authority/acme/acme-certificate-authority-schemas"; +import { AzureAdCsCertificateAuthoritySchema } from "@app/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { InternalCertificateAuthoritySchema } from "@app/services/certificate-authority/internal/internal-certificate-authority-schemas"; const CertificateAuthoritySchema = z.discriminatedUnion("type", [ InternalCertificateAuthoritySchema, - AcmeCertificateAuthoritySchema + AcmeCertificateAuthoritySchema, + AzureAdCsCertificateAuthoritySchema ]); export const registerCaRouter = async (server: FastifyZodProvider) => { @@ -52,19 +54,31 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { req.permission ); + const azureAdCsCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId( + { + projectId: req.query.projectId, + type: CaType.AZURE_AD_CS + }, + req.permission + ); + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: req.query.projectId, event: { type: EventType.GET_CAS, metadata: { - caIds: [...(internalCas ?? []).map((ca) => ca.id), ...(acmeCas ?? []).map((ca) => ca.id)] + caIds: [ + ...(internalCas ?? []).map((ca) => ca.id), + ...(acmeCas ?? []).map((ca) => ca.id), + ...(azureAdCsCas ?? []).map((ca) => ca.id) + ] } } }); return { - certificateAuthorities: [...(internalCas ?? []), ...(acmeCas ?? [])] + certificateAuthorities: [...(internalCas ?? []), ...(acmeCas ?? []), ...(azureAdCsCas ?? [])] }; } }); diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts index 59a3943f7..d8a57d29a 100644 --- a/backend/src/server/routes/v2/mfa-router.ts +++ b/backend/src/server/routes/v2/mfa-router.ts @@ -97,13 +97,13 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ encryptionVersion: z.number().default(1).nullable().optional(), - protectedKey: z.string().nullable(), - protectedKeyIV: z.string().nullable(), - protectedKeyTag: z.string().nullable(), - publicKey: z.string(), - encryptedPrivateKey: z.string(), - iv: z.string(), - tag: z.string(), + protectedKey: z.string().nullish(), + protectedKeyIV: z.string().nullish(), + protectedKeyTag: z.string().nullish(), + publicKey: z.string().nullish(), + encryptedPrivateKey: z.string().nullish(), + iv: z.string().nullish(), + tag: z.string().nullish(), token: z.string() }) } diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index c17200a30..9135d2356 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -153,7 +153,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { firstName: true, lastName: true, id: true - }).extend({ publicKey: z.string().nullable() }) + }).extend({ publicKey: z.string().nullish() }) }).omit({ createdAt: true, updatedAt: true }) }) } @@ -264,6 +264,48 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "DELETE", + url: "/:organizationId/memberships", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Organizations], + description: "Bulk delete organization user memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.BULK_DELETE_USER_MEMBERSHIPS.organizationId) + }), + body: z.object({ + membershipIds: z.string().trim().array().describe(ORGANIZATIONS.BULK_DELETE_USER_MEMBERSHIPS.membershipIds) + }), + response: { + 200: z.object({ + memberships: OrgMembershipsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + if (req.auth.actor !== ActorType.USER) return; + + const memberships = await server.services.org.bulkDeleteOrgMemberships({ + userId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + orgId: req.params.organizationId, + membershipIds: req.body.membershipIds, + actorOrgId: req.permission.orgId + }); + return { memberships }; + } + }); + server.route({ // TODO: re-think endpoint structure in future so users only need to pass in membershipId bc organizationId is redundant method: "GET", diff --git a/backend/src/server/routes/v2/password-router.ts b/backend/src/server/routes/v2/password-router.ts index 63b6d8aac..0f0747c3f 100644 --- a/backend/src/server/routes/v2/password-router.ts +++ b/backend/src/server/routes/v2/password-router.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { getConfig } from "@app/lib/config/env"; import { authRateLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { validatePasswordResetAuthorization } from "@app/services/auth/auth-fns"; @@ -41,13 +42,38 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { rateLimit: authRateLimit }, onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), - handler: async (req) => { + handler: async (req, res) => { + const appCfg = getConfig(); + await server.services.password.resetPasswordV2({ type: ResetPasswordV2Type.LoggedInReset, userId: req.permission.id, newPassword: req.body.newPassword, oldPassword: req.body.oldPassword }); + + void res.cookie("jid", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + + void res.cookie("infisical-project-assume-privileges", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED, + maxAge: 0 + }); + + void res.cookie("aod", "", { + httpOnly: false, + path: "/", + sameSite: "lax", + secure: appCfg.HTTPS_ENABLED, + maxAge: 0 + }); } }); }; diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 2a883dcb6..8b9091364 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -52,7 +52,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 200: ProjectKeysSchema.merge( z.object({ sender: z.object({ - publicKey: z.string() + publicKey: z.string().optional() }) }) ) @@ -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/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 92b4138f2..f416fe8bb 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -18,14 +18,14 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - username: z.string().trim() + token: z.string().trim() }), response: { 200: z.object({}) } }, handler: async (req) => { - await server.services.user.sendEmailVerificationCode(req.body.username); + await server.services.user.sendEmailVerificationCode(req.body.token); return {}; } }); diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index 89621811d..a3b744485 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -2,10 +2,13 @@ import fastifyMultipart from "@fastify/multipart"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; -import { writeLimit } from "@app/server/config/rateLimiter"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { VaultMappingType } from "@app/services/external-migration/external-migration-types"; +import { + ExternalMigrationProviders, + VaultMappingType +} from "@app/services/external-migration/external-migration-types"; const MB25_IN_BYTES = 26214400; @@ -19,7 +22,7 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider config: { rateLimit: writeLimit }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { const data = await req.file({ limits: { @@ -66,10 +69,11 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider vaultAccessToken: z.string(), vaultNamespace: z.string().trim().optional(), vaultUrl: z.string(), - mappingType: z.nativeEnum(VaultMappingType) + mappingType: z.nativeEnum(VaultMappingType), + gatewayId: z.string().optional() }) }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { await server.services.migration.importVaultData({ actorId: req.permission.id, @@ -80,4 +84,33 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider }); } }); + + server.route({ + method: "GET", + url: "/custom-migration-enabled/:provider", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + provider: z.nativeEnum(ExternalMigrationProviders) + }), + response: { + 200: z.object({ + enabled: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const enabled = await server.services.migration.hasCustomVaultMigration({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + provider: req.params.provider + }); + return { enabled }; + } + }); }; diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index 3a8510f34..07923fd6c 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -20,8 +20,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - serverPublicKey: z.string(), - salt: z.string() + serverPublicKey: z.string().nullish(), + salt: z.string().nullish() }) } }, @@ -124,14 +124,14 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - encryptionVersion: z.number().default(1).nullable().optional(), - protectedKey: z.string().nullable(), - protectedKeyIV: z.string().nullable(), - protectedKeyTag: z.string().nullable(), - publicKey: z.string(), - encryptedPrivateKey: z.string(), - iv: z.string(), - tag: z.string(), + encryptionVersion: z.number().default(1).nullish(), + protectedKey: z.string().nullish(), + protectedKeyIV: z.string().nullish(), + protectedKeyTag: z.string().nullish(), + publicKey: z.string().nullish(), + encryptedPrivateKey: z.string().nullish(), + iv: z.string().nullish(), + tag: z.string().nullish(), token: z.string() }) } @@ -181,4 +181,59 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { } as const; } }); + + // New login route that doesn't use SRP + server.route({ + method: "POST", + url: "/login", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + email: z.string().trim(), + password: z.string().trim(), + providerAuthToken: z.string().trim().optional(), + captchaToken: z.string().trim().optional() + }), + response: { + 200: z.object({ + accessToken: z.string() + }) + } + }, + handler: async (req, res) => { + const userAgent = req.headers["user-agent"]; + if (!userAgent) throw new Error("user agent header is required"); + + const { tokens } = await server.services.login.login({ + email: req.body.email, + password: req.body.password, + ip: req.realIp, + userAgent, + providerAuthToken: req.body.providerAuthToken, + captchaToken: req.body.captchaToken + }); + const appCfg = getConfig(); + + void res.setCookie("jid", tokens.refreshToken, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + + addAuthOriginDomainCookie(res); + + void res.cookie("infisical-project-assume-privileges", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED, + maxAge: 0 + }); + + return { accessToken: tokens.accessToken }; + } + }); }; diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 55fc094b7..ce0d4f188 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -419,6 +419,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { 200: z.object({ secret: secretRawSchema.extend({ secretValueHidden: z.boolean(), + secretPath: z.string(), tags: SanitizedTagSchema.array().optional(), secretMetadata: ResourceMetadataSchema.optional() }) diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index 391c459a2..736bc13aa 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -98,15 +98,6 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { email: 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(), providerAuthToken: z.string().trim().optional().nullish(), attributionSource: z.string().trim().optional(), password: z.string() @@ -189,15 +180,6 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { password: z.string(), 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(), tokenMetadata: z.string().optional() }), response: { diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 833522cbb..76dcdd5f0 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -8,6 +8,7 @@ export enum AppConnection { AzureAppConfiguration = "azure-app-configuration", AzureClientSecrets = "azure-client-secrets", AzureDevOps = "azure-devops", + AzureADCS = "azure-adcs", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", Vercel = "vercel", @@ -34,6 +35,7 @@ export enum AppConnection { Checkly = "checkly", Supabase = "supabase", DigitalOcean = "digital-ocean", + Netlify = "netlify", Okta = "okta" } diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index d72fca654..94de51f1e 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -31,6 +31,11 @@ import { } from "./app-connection-types"; import { Auth0ConnectionMethod, getAuth0ConnectionListItem, validateAuth0ConnectionCredentials } from "./auth0"; import { AwsConnectionMethod, getAwsConnectionListItem, validateAwsConnectionCredentials } from "./aws"; +import { AzureADCSConnectionMethod } from "./azure-adcs"; +import { + getAzureADCSConnectionListItem, + validateAzureADCSConnectionCredentials +} from "./azure-adcs/azure-adcs-connection-fns"; import { AzureAppConfigurationConnectionMethod, getAzureAppConfigurationConnectionListItem, @@ -97,6 +102,7 @@ import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnection import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; +import { getNetlifyConnectionListItem, validateNetlifyConnectionCredentials } from "./netlify"; import { getOktaConnectionListItem, OktaConnectionMethod, validateOktaConnectionCredentials } from "./okta"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway"; @@ -135,6 +141,7 @@ export const listAppConnectionOptions = () => { getAzureKeyVaultConnectionListItem(), getAzureAppConfigurationConnectionListItem(), getAzureDevopsConnectionListItem(), + getAzureADCSConnectionListItem(), getDatabricksConnectionListItem(), getHumanitecConnectionListItem(), getTerraformCloudConnectionListItem(), @@ -163,6 +170,7 @@ export const listAppConnectionOptions = () => { getChecklyConnectionListItem(), getSupabaseConnectionListItem(), getDigitalOceanConnectionListItem(), + getNetlifyConnectionListItem(), getOktaConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -225,6 +233,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.AzureClientSecrets]: validateAzureClientSecretsConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureDevOps]: validateAzureDevOpsConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.AzureADCS]: validateAzureADCSConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, @@ -251,7 +260,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Checkly]: validateChecklyConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.DigitalOcean]: validateDigitalOceanConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService); @@ -297,6 +307,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case MsSqlConnectionMethod.UsernameAndPassword: case MySqlConnectionMethod.UsernameAndPassword: case OracleDBConnectionMethod.UsernameAndPassword: + case AzureADCSConnectionMethod.UsernamePassword: return "Username & Password"; case WindmillConnectionMethod.AccessToken: case HCVaultConnectionMethod.AccessToken: @@ -354,6 +365,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported, [AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported, [AppConnection.AzureDevOps]: platformManagedCredentialsNotSupported, + [AppConnection.AzureADCS]: platformManagedCredentialsNotSupported, [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, @@ -381,6 +393,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Checkly]: platformManagedCredentialsNotSupported, [AppConnection.Supabase]: platformManagedCredentialsNotSupported, [AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported, + [AppConnection.Netlify]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 603d30ccd..a2ce02669 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -9,6 +9,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AzureAppConfiguration]: "Azure App Configuration", [AppConnection.AzureClientSecrets]: "Azure Client Secrets", [AppConnection.AzureDevOps]: "Azure DevOps", + [AppConnection.AzureADCS]: "Azure ADCS", [AppConnection.Databricks]: "Databricks", [AppConnection.Humanitec]: "Humanitec", [AppConnection.TerraformCloud]: "Terraform Cloud", @@ -36,6 +37,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Checkly]: "Checkly", [AppConnection.Supabase]: "Supabase", [AppConnection.DigitalOcean]: "DigitalOcean App Platform", + [AppConnection.Netlify]: "Netlify", [AppConnection.Okta]: "Okta" }; @@ -48,6 +50,7 @@ export const APP_CONNECTION_PLAN_MAP: Record; +} + +interface AxiosNtlmResponse { + status: number; + data: string; + headers: unknown; +} + +// Types for credential parsing +interface ParsedCredentials { + domain: string; + username: string; + fullUsername: string; // domain\username format +} + +// Helper function to parse and normalize credentials for Windows authentication +const parseCredentials = (inputUsername: string): ParsedCredentials => { + // Ensure inputUsername is a string + if (typeof inputUsername !== "string" || !inputUsername.trim()) { + throw new BadRequestError({ + message: "Username must be a non-empty string" + }); + } + + let domain = ""; + let username = ""; + let fullUsername = ""; + + if (inputUsername.includes("\\")) { + // Already in domain\username format + const parts = inputUsername.split("\\"); + if (parts.length === 2) { + [domain, username] = parts; + fullUsername = inputUsername; + } else { + throw new BadRequestError({ + message: "Invalid domain\\username format. Expected format: DOMAIN\\username" + }); + } + } else if (inputUsername.includes("@")) { + // UPN format: user@domain.com + const [user, domainPart] = inputUsername.split("@"); + if (!user || !domainPart) { + throw new BadRequestError({ + message: "Invalid UPN format. Expected format: user@domain.com" + }); + } + + username = user; + // Extract NetBIOS name from FQDN + domain = domainPart.split(".")[0].toUpperCase(); + fullUsername = `${domain}\\${username}`; + } else { + // Plain username - assume local account or current domain + username = inputUsername; + domain = ""; + fullUsername = inputUsername; + } + + return { domain, username, fullUsername }; +}; + +// Helper to normalize URL +const normalizeAdcsUrl = (url: string): string => { + let normalizedUrl = url.trim(); + + // Remove trailing slash + normalizedUrl = normalizedUrl.replace(/\/$/, ""); + + // Ensure HTTPS protocol + if (normalizedUrl.startsWith("http://")) { + normalizedUrl = normalizedUrl.replace("http://", "https://"); + } else if (!normalizedUrl.startsWith("https://")) { + normalizedUrl = `https://${normalizedUrl}`; + } + + return normalizedUrl; +}; + +// NTLM request wrapper +const createHttpsAgent = (sslRejectUnauthorized: boolean, sslCertificate?: string): https.Agent => { + const agentOptions: https.AgentOptions = { + rejectUnauthorized: sslRejectUnauthorized, + keepAlive: true, // axios-ntlm needs keepAlive for NTLM handshake + ca: sslCertificate ? [sslCertificate.trim()] : undefined, + // Disable hostname verification as Microsoft servers by default use local IPs for certificates + // which may not match the hostname used to connect + checkServerIdentity: () => undefined + }; + + return new https.Agent(agentOptions); +}; + +const axiosNtlmRequest = async (config: AxiosNtlmConfig): Promise => { + const method = config.method || "GET"; + + const credentials = { + username: config.ntlm.username, + password: config.ntlm.password, + domain: config.ntlm.domain || "", + workstation: "" + }; + + const axiosConfig = { + httpsAgent: config.httpsAgent, + timeout: 30000 + }; + + const client = NtlmClient(credentials, axiosConfig); + + const requestOptions: { url: string; method: string; data?: string; headers?: Record } = { + url: config.url, + method + }; + + if (config.data) { + requestOptions.data = config.data; + } + + if (config.headers) { + requestOptions.headers = config.headers; + } + + const response = await client(requestOptions); + + return { + status: response.status, + data: response.data, + headers: response.headers + }; +}; + +// Test ADCS connectivity and authentication using NTLM +const testAdcsConnection = async ( + credentials: ParsedCredentials, + password: string, + baseUrl: string, + sslRejectUnauthorized: boolean = true, + sslCertificate?: string +): Promise => { + // Test endpoints in order of preference + const testEndpoints = [ + "/certsrv/certrqus.asp", // Certificate request status (most reliable) + "/certsrv/certfnsh.asp", // Certificate finalization + "/certsrv/default.asp", // Main ADCS page + "/certsrv/" // Root certsrv + ]; + + for (const endpoint of testEndpoints) { + try { + const testUrl = `${baseUrl}${endpoint}`; + + const shouldRejectUnauthorized = sslRejectUnauthorized; + + const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate); + + const response = await axiosNtlmRequest({ + url: testUrl, + method: "GET", + httpsAgent, + ntlm: { + domain: credentials.domain, + username: credentials.username, + password + } + }); + + // Check if we got a successful response + if (response.status === 200) { + const responseText = response.data; + + // Verify this is actually an ADCS server by checking content + const adcsIndicators = [ + "Microsoft Active Directory Certificate Services", + "Certificate Services", + "Request a certificate", + "certsrv", + "Certificate Template", + "Web Enrollment" + ]; + + const isAdcsServer = adcsIndicators.some((indicator) => + responseText.toLowerCase().includes(indicator.toLowerCase()) + ); + + if (isAdcsServer) { + // Successfully authenticated and confirmed ADCS + return true; + } + } + + if (response.status === 401) { + throw new BadRequestError({ + message: "Authentication failed. Please verify your credentials are correct." + }); + } + + if (response.status === 403) { + throw new BadRequestError({ + message: "Access denied. Your account may not have permission to access ADCS web enrollment." + }); + } + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + // Handle network and connection errors + if (error instanceof Error) { + if (error.message.includes("ENOTFOUND")) { + throw new BadRequestError({ + message: "Cannot resolve ADCS server hostname. Please verify the URL is correct." + }); + } + if (error.message.includes("ECONNREFUSED")) { + throw new BadRequestError({ + message: "Connection refused by ADCS server. Please verify the server is running and accessible." + }); + } + if (error.message.includes("ETIMEDOUT") || error.message.includes("timeout")) { + throw new BadRequestError({ + message: "Connection timeout. Please verify the server is accessible and not blocked by firewall." + }); + } + if (error.message.includes("certificate") || error.message.includes("SSL") || error.message.includes("TLS")) { + throw new BadRequestError({ + message: `SSL/TLS certificate error: ${error.message}. This may indicate a certificate verification failure.` + }); + } + if (error.message.includes("DEPTH_ZERO_SELF_SIGNED_CERT")) { + throw new BadRequestError({ + message: + "Self-signed certificate detected. Either provide the server's certificate or set 'sslRejectUnauthorized' to false." + }); + } + if (error.message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE")) { + throw new BadRequestError({ + message: "Unable to verify certificate signature. Please provide the correct CA certificate." + }); + } + } + + // Continue to next endpoint for other errors + continue; + } + } + + // If we get here, no endpoint worked + throw new BadRequestError({ + message: "Could not connect to ADCS server. Please verify the server URL and that Web Enrollment is enabled." + }); +}; + +// Create authenticated NTLM client for ADCS operations +const createNtlmClient = ( + username: string, + password: string, + baseUrl: string, + sslRejectUnauthorized: boolean = true, + sslCertificate?: string +) => { + const parsedCredentials = parseCredentials(username); + const normalizedUrl = normalizeAdcsUrl(baseUrl); + + return { + get: async (endpoint: string, additionalHeaders: Record = {}) => { + const shouldRejectUnauthorized = sslRejectUnauthorized; + + const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate); + + return axiosNtlmRequest({ + url: `${normalizedUrl}${endpoint}`, + method: "GET", + httpsAgent, + headers: additionalHeaders, + ntlm: { + domain: parsedCredentials.domain, + username: parsedCredentials.username, + password + } + }); + }, + post: async (endpoint: string, body: string, additionalHeaders: Record = {}) => { + const shouldRejectUnauthorized = sslRejectUnauthorized; + + const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate); + + return axiosNtlmRequest({ + url: `${normalizedUrl}${endpoint}`, + method: "POST", + httpsAgent, + data: body, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...additionalHeaders + }, + ntlm: { + domain: parsedCredentials.domain, + username: parsedCredentials.username, + password + } + }); + }, + baseUrl: normalizedUrl, + credentials: parsedCredentials + }; +}; + +export const getAzureADCSConnectionCredentials = async ( + connectionId: string, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureADCS) { + throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not an Azure ADCS connection` }); + } + + switch (appConnection.method) { + case AzureADCSConnectionMethod.UsernamePassword: + const credentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as { + username: string; + password: string; + adcsUrl: string; + sslRejectUnauthorized?: boolean; + sslCertificate?: string; + }; + + return { + username: credentials.username, + password: credentials.password, + adcsUrl: credentials.adcsUrl, + sslRejectUnauthorized: credentials.sslRejectUnauthorized ?? true, + sslCertificate: credentials.sslCertificate + }; + + default: + throw new BadRequestError({ + message: `Unsupported Azure ADCS connection method: ${appConnection.method}` + }); + } +}; + +export const validateAzureADCSConnectionCredentials = async (appConnection: TAzureADCSConnectionConfig) => { + const { credentials } = appConnection; + + try { + // Parse and validate credentials + const parsedCredentials = parseCredentials(credentials.username); + const normalizedUrl = normalizeAdcsUrl(credentials.adcsUrl); + + // Validate URL to prevent DNS manipulation attacks and SSRF + await blockLocalAndPrivateIpAddresses(normalizedUrl); + + // Test the connection using NTLM + await testAdcsConnection( + parsedCredentials, + credentials.password, + normalizedUrl, + credentials.sslRejectUnauthorized ?? true, + credentials.sslCertificate + ); + + // If we get here, authentication was successful + return { + username: credentials.username, + password: credentials.password, + adcsUrl: credentials.adcsUrl, + sslRejectUnauthorized: credentials.sslRejectUnauthorized ?? true, + sslCertificate: credentials.sslCertificate + }; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + // Handle unexpected errors + let errorMessage = "Unable to validate ADCS connection."; + if (error instanceof Error) { + if (error.message.includes("401") || error.message.includes("Unauthorized")) { + errorMessage = "NTLM authentication failed. Please verify your username, password, and domain are correct."; + } else if (error.message.includes("ENOTFOUND") || error.message.includes("ECONNREFUSED")) { + errorMessage = "Cannot connect to the ADCS server. Please verify the server URL is correct and accessible."; + } else if (error.message.includes("timeout")) { + errorMessage = "Connection to ADCS server timed out. Please verify the server is accessible."; + } else if ( + error.message.includes("certificate") || + error.message.includes("SSL") || + error.message.includes("TLS") || + error.message.includes("DEPTH_ZERO_SELF_SIGNED_CERT") || + error.message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE") + ) { + errorMessage = `SSL/TLS certificate error: ${error.message}. The server certificate may be self-signed or the CA certificate may be incorrect.`; + } + } + + throw new BadRequestError({ + message: `Failed to validate Azure ADCS connection: ${errorMessage} Details: ${ + error instanceof Error ? error.message : "Unknown error" + }` + }); + } +}; + +export const getAzureADCSConnectionListItem = () => ({ + name: "Azure ADCS" as const, + app: AppConnection.AzureADCS as const, + methods: [AzureADCSConnectionMethod.UsernamePassword] as [AzureADCSConnectionMethod.UsernamePassword] +}); + +// Export helper functions for use in certificate ordering +export const createAdcsHttpClient = ( + username: string, + password: string, + baseUrl: string, + sslRejectUnauthorized: boolean = true, + sslCertificate?: string +) => { + return createNtlmClient(username, password, baseUrl, sslRejectUnauthorized, sslCertificate); +}; diff --git a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts new file mode 100644 index 000000000..a43bb5954 --- /dev/null +++ b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts @@ -0,0 +1,88 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AzureADCSConnectionMethod } from "./azure-adcs-connection-enums"; + +export const AzureADCSUsernamePasswordCredentialsSchema = z.object({ + adcsUrl: z + .string() + .trim() + .min(1, "ADCS URL required") + .max(255) + .refine((value) => value.startsWith("https://"), "ADCS URL must use HTTPS") + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.adcsUrl), + username: z + .string() + .trim() + .min(1, "Username required") + .max(255) + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.username), + password: z + .string() + .trim() + .min(1, "Password required") + .max(255) + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.password), + sslRejectUnauthorized: z.boolean().optional().describe(AppConnections.CREDENTIALS.AZURE_ADCS.sslRejectUnauthorized), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.sslCertificate) +}); + +const BaseAzureADCSConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AzureADCS) }); + +export const AzureADCSConnectionSchema = BaseAzureADCSConnectionSchema.extend({ + method: z.literal(AzureADCSConnectionMethod.UsernamePassword), + credentials: AzureADCSUsernamePasswordCredentialsSchema +}); + +export const SanitizedAzureADCSConnectionSchema = z.discriminatedUnion("method", [ + BaseAzureADCSConnectionSchema.extend({ + method: z.literal(AzureADCSConnectionMethod.UsernamePassword), + credentials: AzureADCSUsernamePasswordCredentialsSchema.pick({ + username: true, + adcsUrl: true, + sslRejectUnauthorized: true, + sslCertificate: true + }) + }) +]); + +export const ValidateAzureADCSConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(AzureADCSConnectionMethod.UsernamePassword) + .describe(AppConnections.CREATE(AppConnection.AzureADCS).method), + credentials: AzureADCSUsernamePasswordCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureADCS).credentials + ) + }) +]); + +export const CreateAzureADCSConnectionSchema = ValidateAzureADCSConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.AzureADCS) +); + +export const UpdateAzureADCSConnectionSchema = z + .object({ + credentials: AzureADCSUsernamePasswordCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.AzureADCS).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureADCS)); + +export const AzureADCSConnectionListItemSchema = z.object({ + name: z.literal("Azure ADCS"), + app: z.literal(AppConnection.AzureADCS), + methods: z.nativeEnum(AzureADCSConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-types.ts b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-types.ts new file mode 100644 index 000000000..051d13c0d --- /dev/null +++ b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-types.ts @@ -0,0 +1,23 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + AzureADCSConnectionSchema, + CreateAzureADCSConnectionSchema, + ValidateAzureADCSConnectionCredentialsSchema +} from "./azure-adcs-connection-schemas"; + +export type TAzureADCSConnection = z.infer; + +export type TAzureADCSConnectionInput = z.infer & { + app: AppConnection.AzureADCS; +}; + +export type TValidateAzureADCSConnectionCredentialsSchema = typeof ValidateAzureADCSConnectionCredentialsSchema; + +export type TAzureADCSConnectionConfig = DiscriminativePick< + TAzureADCSConnectionInput, + "method" | "app" | "credentials" +>; diff --git a/backend/src/services/app-connection/azure-adcs/index.ts b/backend/src/services/app-connection/azure-adcs/index.ts new file mode 100644 index 000000000..3cefd9090 --- /dev/null +++ b/backend/src/services/app-connection/azure-adcs/index.ts @@ -0,0 +1,4 @@ +export * from "./azure-adcs-connection-enums"; +export * from "./azure-adcs-connection-fns"; +export * from "./azure-adcs-connection-schemas"; +export * from "./azure-adcs-connection-types"; diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-enums.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-enums.ts index 450cb9255..b79d9c67a 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-enums.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-enums.ts @@ -1,3 +1,4 @@ export enum AzureAppConfigurationConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts index 114794dc5..d173a47b5 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-case-declarations */ import { AxiosError, AxiosResponse } from "axios"; import { getConfig } from "@app/lib/config/env"; @@ -19,7 +20,10 @@ export const getAzureAppConfigurationConnectionListItem = () => { return { name: "Azure App Configuration" as const, app: AppConnection.AzureAppConfiguration as const, - methods: Object.values(AzureAppConfigurationConnectionMethod) as [AzureAppConfigurationConnectionMethod.OAuth], + methods: Object.values(AzureAppConfigurationConnectionMethod) as [ + AzureAppConfigurationConnectionMethod.OAuth, + AzureAppConfigurationConnectionMethod.ClientSecret + ], oauthClientId: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID }; }; @@ -35,71 +39,111 @@ export const validateAzureAppConfigurationConnectionCredentials = async ( SITE_URL } = getConfig(); - if ( - !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID || - !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET - ) { - throw new InternalServerError({ - message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` - }); - } - - let tokenResp: AxiosResponse | null = null; - let tokenError: AxiosError | null = null; - - try { - tokenResp = await request.post( - IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"), - new URLSearchParams({ - grant_type: "authorization_code", - code: inputCredentials.code, - scope: `openid offline_access https://azconfig.io/.default`, - client_id: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID, - client_secret: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET, - redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` - }) - ); - } catch (e: unknown) { - if (e instanceof AxiosError) { - tokenError = e; - } else { - throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` - }); - } - } - - if (tokenError) { - if (tokenError instanceof AxiosError) { - throw new BadRequestError({ - message: `Failed to get access token: ${ - (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" - }` - }); - } else { - throw new InternalServerError({ - message: "Failed to get access token" - }); - } - } - - if (!tokenResp) { - throw new InternalServerError({ - message: `Failed to get access token: Token was empty with no error` - }); - } - switch (method) { case AzureAppConfigurationConnectionMethod.OAuth: + if ( + !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID || + !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET + ) { + throw new InternalServerError({ + message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse | null = null; + let tokenError: AxiosError | null = null; + const oauthCredentials = inputCredentials as { code: string; tenantId?: string }; + try { + tokenResp = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "authorization_code", + code: oauthCredentials.code, + scope: `openid offline_access https://azconfig.io/.default`, + client_id: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET, + redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` + }) + ); + } catch (e: unknown) { + if (e instanceof AxiosError) { + tokenError = e; + } else { + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + } + + if (tokenError) { + if (tokenError instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + + if (!tokenResp) { + throw new InternalServerError({ + message: `Failed to get access token: Token was empty with no error` + }); + } + return { - tenantId: inputCredentials.tenantId, + tenantId: oauthCredentials.tenantId, accessToken: tokenResp.data.access_token, refreshToken: tokenResp.data.refresh_token, expiresAt: Date.now() + tokenResp.data.expires_in * 1000 }; + + case AzureAppConfigurationConnectionMethod.ClientSecret: + const { tenantId, clientId, clientSecret } = inputCredentials as { + tenantId: string; + clientId: string; + clientSecret: string; + }; + + try { + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://azconfig.io/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + return { + tenantId, + accessToken: clientData.access_token, + expiresAt: Date.now() + clientData.expires_in * 1000, + clientId, + clientSecret + }; + } catch (e: unknown) { + if (e instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (e?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + default: throw new InternalServerError({ - message: `Unhandled Azure connection method: ${method as AzureAppConfigurationConnectionMethod}` + message: `Unhandled Azure App Configuration connection method: ${method as AzureAppConfigurationConnectionMethod}` }); } }; diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts index 183376acf..68579f9ba 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts @@ -22,6 +22,29 @@ export const AzureAppConfigurationConnectionOAuthOutputCredentialsSchema = z.obj expiresAt: z.number() }); +export const AzureAppConfigurationConnectionClientSecretInputCredentialsSchema = z.object({ + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .max(50, "Client ID must be at most 50 characters long"), + clientSecret: z + .string() + .trim() + .min(1, "Client Secret required") + .max(50, "Client Secret must be at most 50 characters long"), + tenantId: z.string().uuid().trim().min(1, "Tenant ID required") +}); + +export const AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema = z.object({ + clientId: z.string(), + clientSecret: z.string(), + tenantId: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureAppConfigurationConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -30,6 +53,14 @@ export const ValidateAzureAppConfigurationConnectionCredentialsSchema = z.discri credentials: AzureAppConfigurationConnectionOAuthInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureAppConfiguration).credentials ) + }), + z.object({ + method: z + .literal(AzureAppConfigurationConnectionMethod.ClientSecret) + .describe(AppConnections.CREATE(AppConnection.AzureAppConfiguration).method), + credentials: AzureAppConfigurationConnectionClientSecretInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureAppConfiguration).credentials + ) }) ]); @@ -39,9 +70,13 @@ export const CreateAzureAppConfigurationConnectionSchema = ValidateAzureAppConfi export const UpdateAzureAppConfigurationConnectionSchema = z .object({ - credentials: AzureAppConfigurationConnectionOAuthInputCredentialsSchema.optional().describe( - AppConnections.UPDATE(AppConnection.AzureAppConfiguration).credentials - ) + credentials: z + .union([ + AzureAppConfigurationConnectionOAuthInputCredentialsSchema, + AzureAppConfigurationConnectionClientSecretInputCredentialsSchema + ]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.AzureAppConfiguration).credentials) }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureAppConfiguration)); @@ -55,6 +90,10 @@ export const AzureAppConfigurationConnectionSchema = z.intersection( z.object({ method: z.literal(AzureAppConfigurationConnectionMethod.OAuth), credentials: AzureAppConfigurationConnectionOAuthOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureAppConfigurationConnectionMethod.ClientSecret), + credentials: AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema }) ]) ); @@ -65,6 +104,13 @@ export const SanitizedAzureAppConfigurationConnectionSchema = z.discriminatedUni credentials: AzureAppConfigurationConnectionOAuthOutputCredentialsSchema.pick({ tenantId: true }) + }), + BaseAzureAppConfigurationConnectionSchema.extend({ + method: z.literal(AzureAppConfigurationConnectionMethod.ClientSecret), + credentials: AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema.pick({ + clientId: true, + tenantId: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts index 8111b4c50..e60161197 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema, AzureAppConfigurationConnectionOAuthOutputCredentialsSchema, AzureAppConfigurationConnectionSchema, CreateAzureAppConfigurationConnectionSchema, @@ -39,3 +40,7 @@ export type ExchangeCodeAzureResponse = { export type TAzureAppConfigurationConnectionCredentials = z.infer< typeof AzureAppConfigurationConnectionOAuthOutputCredentialsSchema >; + +export type TAzureAppConfigurationConnectionClientSecretCredentials = z.infer< + typeof AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema +>; diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts index 41dbb4392..2614cfd12 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts @@ -186,7 +186,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } } diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts b/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts index f724a0b4a..c0ce74b27 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts @@ -1,4 +1,5 @@ export enum AzureDevOpsConnectionMethod { OAuth = "oauth", - AccessToken = "access-token" + AccessToken = "access-token", + ClientSecret = "client-secret" } diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts index e9bb1f6bd..a3a9f10bd 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts @@ -18,6 +18,7 @@ import { AppConnection } from "../app-connection-enums"; import { AzureDevOpsConnectionMethod } from "./azure-devops-enums"; import { ExchangeCodeAzureResponse, + TAzureDevOpsConnectionClientSecretCredentials, TAzureDevOpsConnectionConfig, TAzureDevOpsConnectionCredentials } from "./azure-devops-types"; @@ -30,7 +31,8 @@ export const getAzureDevopsConnectionListItem = () => { app: AppConnection.AzureDevOps as const, methods: Object.values(AzureDevOpsConnectionMethod) as [ AzureDevOpsConnectionMethod.OAuth, - AzureDevOpsConnectionMethod.AccessToken + AzureDevOpsConnectionMethod.AccessToken, + AzureDevOpsConnectionMethod.ClientSecret ], oauthClientId: INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID }; @@ -53,11 +55,7 @@ export const getAzureDevopsConnection = async ( }); } - const credentials = (await decryptAppConnectionCredentials({ - orgId: appConnection.orgId, - kmsService, - encryptedCredentials: appConnection.encryptedCredentials - })) as TAzureDevOpsConnectionCredentials; + const currentTime = Date.now(); // Handle different connection methods switch (appConnection.method) { @@ -69,12 +67,17 @@ export const getAzureDevopsConnection = async ( }); } - if (!("refreshToken" in credentials)) { + const oauthCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureDevOpsConnectionCredentials; + + if (!("refreshToken" in oauthCredentials)) { throw new BadRequestError({ message: "Invalid OAuth credentials" }); } - const { refreshToken, tenantId } = credentials; - const currentTime = Date.now(); + const { refreshToken, tenantId } = oauthCredentials; const { data } = await request.post( IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), @@ -87,29 +90,75 @@ export const getAzureDevopsConnection = async ( }) ); - const updatedCredentials = { - ...credentials, + const updatedOAuthCredentials = { + ...oauthCredentials, accessToken: data.access_token, expiresAt: currentTime + data.expires_in * 1000, refreshToken: data.refresh_token }; - const encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: updatedCredentials, + const encryptedOAuthCredentials = await encryptAppConnectionCredentials({ + credentials: updatedOAuthCredentials, orgId: appConnection.orgId, kmsService }); - await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedOAuthCredentials }); return data.access_token; case AzureDevOpsConnectionMethod.AccessToken: - if (!("accessToken" in credentials)) { + const accessTokenCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as { accessToken: string }; + + if (!("accessToken" in accessTokenCredentials)) { throw new BadRequestError({ message: "Invalid API token credentials" }); } // For access token, return the basic auth token directly - return credentials.accessToken; + return accessTokenCredentials.accessToken; + + case AzureDevOpsConnectionMethod.ClientSecret: + const clientSecretCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureDevOpsConnectionClientSecretCredentials; + + const { accessToken, expiresAt, clientId, clientSecret, tenantId: clientTenantId } = clientSecretCredentials; + + // Check if token is still valid (with 5 minute buffer) + if (accessToken && expiresAt && expiresAt > currentTime + 300000) { + return accessToken; + } + + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", clientTenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://app.vssps.visualstudio.com/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + const updatedClientCredentials = { + ...clientSecretCredentials, + accessToken: clientData.access_token, + expiresAt: currentTime + clientData.expires_in * 1000 + }; + + const encryptedClientCredentials = await encryptAppConnectionCredentials({ + credentials: updatedClientCredentials, + orgId: appConnection.orgId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); + + return clientData.access_token; default: throw new BadRequestError({ message: `Unsupported connection method` }); @@ -138,7 +187,7 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev let tokenError: AxiosError | null = null; try { - const oauthCredentials = inputCredentials as { code: string; tenantId: string }; + const oauthCredentials = inputCredentials as { code: string; tenantId: string; orgName: string }; tokenResp = await request.post( IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), new URLSearchParams({ @@ -155,7 +204,7 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } } @@ -262,9 +311,67 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev }); } + case AzureDevOpsConnectionMethod.ClientSecret: + const { tenantId, clientId, clientSecret, orgName } = inputCredentials as { + tenantId: string; + clientId: string; + clientSecret: string; + orgName: string; + }; + + try { + // First, get the access token using client credentials flow + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://app.vssps.visualstudio.com/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + // Validate access to the specific organization + const response = await request.get( + `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/_apis/projects?api-version=7.2-preview.2&$top=1`, + { + headers: { + Authorization: `Bearer ${clientData.access_token}` + } + } + ); + + if (response.status !== 200) { + throw new BadRequestError({ + message: `Failed to validate connection to organization '${orgName}': ${response.status}` + }); + } + + return { + tenantId, + clientId, + clientSecret, + orgName, + accessToken: clientData.access_token, + expiresAt: Date.now() + clientData.expires_in * 1000 + }; + } catch (e: unknown) { + if (e instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to authenticate with Azure DevOps using client credentials: ${ + (e?.response?.data as { error_description?: string })?.error_description || e.message + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to validate Azure DevOps client credentials" + }); + } + } + default: throw new InternalServerError({ - message: `Unhandled Azure connection method: ${method as AzureDevOpsConnectionMethod}` + message: `Unhandled Azure DevOps connection method: ${method as AzureDevOpsConnectionMethod}` }); } }; diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts b/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts index e07ed008d..2580d0114 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts @@ -38,6 +38,42 @@ export const AzureDevOpsConnectionAccessTokenOutputCredentialsSchema = z.object( orgName: z.string() }); +export const AzureDevOpsConnectionClientSecretInputCredentialsSchema = z.object({ + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .max(50, "Client ID must be at most 50 characters long") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.clientId), + clientSecret: z + .string() + .trim() + .min(1, "Client Secret required") + .max(50, "Client Secret must be at most 50 characters long") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.clientSecret), + tenantId: z + .string() + .uuid() + .trim() + .min(1, "Tenant ID required") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.tenantId), + orgName: z + .string() + .trim() + .min(1, "Organization name required") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.orgName) +}); + +export const AzureDevOpsConnectionClientSecretOutputCredentialsSchema = z.object({ + clientId: z.string(), + clientSecret: z.string(), + tenantId: z.string(), + orgName: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureDevOpsConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -54,6 +90,14 @@ export const ValidateAzureDevOpsConnectionCredentialsSchema = z.discriminatedUni credentials: AzureDevOpsConnectionAccessTokenInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureDevOps).credentials ) + }), + z.object({ + method: z + .literal(AzureDevOpsConnectionMethod.ClientSecret) + .describe(AppConnections.CREATE(AppConnection.AzureDevOps).method), + credentials: AzureDevOpsConnectionClientSecretInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureDevOps).credentials + ) }) ]); @@ -64,7 +108,11 @@ export const CreateAzureDevOpsConnectionSchema = ValidateAzureDevOpsConnectionCr export const UpdateAzureDevOpsConnectionSchema = z .object({ credentials: z - .union([AzureDevOpsConnectionOAuthInputCredentialsSchema, AzureDevOpsConnectionAccessTokenInputCredentialsSchema]) + .union([ + AzureDevOpsConnectionOAuthInputCredentialsSchema, + AzureDevOpsConnectionAccessTokenInputCredentialsSchema, + AzureDevOpsConnectionClientSecretInputCredentialsSchema + ]) .optional() .describe(AppConnections.UPDATE(AppConnection.AzureDevOps).credentials) }) @@ -84,6 +132,10 @@ export const AzureDevOpsConnectionSchema = z.intersection( z.object({ method: z.literal(AzureDevOpsConnectionMethod.AccessToken), credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureDevOpsConnectionMethod.ClientSecret), + credentials: AzureDevOpsConnectionClientSecretOutputCredentialsSchema }) ]) ); @@ -101,6 +153,14 @@ export const SanitizedAzureDevOpsConnectionSchema = z.discriminatedUnion("method credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema.pick({ orgName: true }) + }), + BaseAzureDevOpsConnectionSchema.extend({ + method: z.literal(AzureDevOpsConnectionMethod.ClientSecret), + credentials: AzureDevOpsConnectionClientSecretOutputCredentialsSchema.pick({ + clientId: true, + tenantId: true, + orgName: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-service.ts b/backend/src/services/app-connection/azure-devops/azure-devops-service.ts index a85430e4d..6006dc1b4 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-service.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-service.ts @@ -52,6 +52,11 @@ const getAuthHeaders = (appConnection: TAzureDevOpsConnection, accessToken: stri Authorization: `Basic ${basicAuthToken}`, Accept: "application/json" }; + case AzureDevOpsConnectionMethod.ClientSecret: + return { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + }; default: throw new BadRequestError({ message: "Unsupported connection method" }); } diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-types.ts b/backend/src/services/app-connection/azure-devops/azure-devops-types.ts index 62a80c1c5..f556b4600 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-types.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureDevOpsConnectionClientSecretOutputCredentialsSchema, AzureDevOpsConnectionOAuthOutputCredentialsSchema, AzureDevOpsConnectionSchema, CreateAzureDevOpsConnectionSchema, @@ -27,6 +28,10 @@ export type TAzureDevOpsConnectionConfig = DiscriminativePick< export type TAzureDevOpsConnectionCredentials = z.infer; +export type TAzureDevOpsConnectionClientSecretCredentials = z.infer< + typeof AzureDevOpsConnectionClientSecretOutputCredentialsSchema +>; + export interface ExchangeCodeAzureResponse { token_type: string; scope: string; diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-enums.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-enums.ts index 895e88298..aa70ccb68 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-enums.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-enums.ts @@ -1,3 +1,4 @@ export enum AzureKeyVaultConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts index 95102c5d1..d6a260050 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-case-declarations */ import { AxiosError, AxiosResponse } from "axios"; import { getConfig } from "@app/lib/config/env"; @@ -16,25 +17,16 @@ import { AppConnection } from "../app-connection-enums"; import { AzureKeyVaultConnectionMethod } from "./azure-key-vault-connection-enums"; import { ExchangeCodeAzureResponse, + TAzureKeyVaultConnectionClientSecretCredentials, TAzureKeyVaultConnectionConfig, TAzureKeyVaultConnectionCredentials } from "./azure-key-vault-connection-types"; export const getAzureConnectionAccessToken = async ( connectionId: string, - appConnectionDAL: Pick, + appConnectionDAL: Pick, kmsService: Pick ) => { - const appCfg = getConfig(); - if ( - !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || - !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET - ) { - throw new BadRequestError({ - message: `Azure environment variables have not been configured` - }); - } - const appConnection = await appConnectionDAL.findById(connectionId); if (!appConnection) { @@ -49,49 +41,101 @@ export const getAzureConnectionAccessToken = async ( throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not a valid Azure connection` }); } - const credentials = (await decryptAppConnectionCredentials({ - orgId: appConnection.orgId, - kmsService, - encryptedCredentials: appConnection.encryptedCredentials - })) as TAzureKeyVaultConnectionCredentials; + const currentTime = Date.now(); - const { data } = await request.post( - IntegrationUrls.AZURE_TOKEN_URL.replace("common", credentials.tenantId || "common"), - new URLSearchParams({ - grant_type: "refresh_token", - scope: `openid offline_access`, - client_id: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, - client_secret: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, - refresh_token: credentials.refreshToken - }) - ); + switch (appConnection.method) { + case AzureKeyVaultConnectionMethod.OAuth: + const appCfg = getConfig(); + if ( + !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || + !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET + ) { + throw new BadRequestError({ + message: `Azure environment variables have not been configured` + }); + } - const accessExpiresAt = new Date(); - accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + const oauthCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureKeyVaultConnectionCredentials; - const updatedCredentials = { - ...credentials, - accessToken: data.access_token, - expiresAt: accessExpiresAt.getTime(), - refreshToken: data.refresh_token - }; + const { data } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "refresh_token", + scope: `openid offline_access https://vault.azure.net/.default`, + client_id: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, + client_secret: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, + refresh_token: oauthCredentials.refreshToken + }) + ); - const encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: updatedCredentials, - orgId: appConnection.orgId, - kmsService - }); + const updatedOAuthCredentials = { + ...oauthCredentials, + accessToken: data.access_token, + expiresAt: currentTime + data.expires_in * 1000, + refreshToken: data.refresh_token + }; - await appConnectionDAL.update( - { id: connectionId }, - { - encryptedCredentials - } - ); + const encryptedOAuthCredentials = await encryptAppConnectionCredentials({ + credentials: updatedOAuthCredentials, + orgId: appConnection.orgId, + kmsService + }); - return { - accessToken: data.access_token - }; + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedOAuthCredentials }); + + return { + accessToken: data.access_token + }; + + case AzureKeyVaultConnectionMethod.ClientSecret: + const clientSecretCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureKeyVaultConnectionClientSecretCredentials; + + const { accessToken, expiresAt, clientId, clientSecret, tenantId } = clientSecretCredentials; + + // Check if token is still valid (with 5 minute buffer) + if (accessToken && expiresAt && expiresAt > currentTime + 300000) { + return { accessToken }; + } + + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://vault.azure.net/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + const updatedClientCredentials = { + ...clientSecretCredentials, + accessToken: clientData.access_token, + expiresAt: currentTime + clientData.expires_in * 1000 + }; + + const encryptedClientCredentials = await encryptAppConnectionCredentials({ + credentials: updatedClientCredentials, + orgId: appConnection.orgId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); + + return { accessToken: clientData.access_token }; + + default: + throw new InternalServerError({ + message: `Unhandled Azure Key Vault connection method: ${appConnection.method as AzureKeyVaultConnectionMethod}` + }); + } }; export const getAzureKeyVaultConnectionListItem = () => { @@ -100,7 +144,10 @@ export const getAzureKeyVaultConnectionListItem = () => { return { name: "Azure Key Vault" as const, app: AppConnection.AzureKeyVault as const, - methods: Object.values(AzureKeyVaultConnectionMethod) as [AzureKeyVaultConnectionMethod.OAuth], + methods: Object.values(AzureKeyVaultConnectionMethod) as [ + AzureKeyVaultConnectionMethod.OAuth, + AzureKeyVaultConnectionMethod.ClientSecret + ], oauthClientId: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID }; }; @@ -111,68 +158,108 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK const { INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, SITE_URL } = getConfig(); - if (!INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || !INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET) { - throw new InternalServerError({ - message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` - }); - } - - let tokenResp: AxiosResponse | null = null; - let tokenError: AxiosError | null = null; - - try { - tokenResp = await request.post( - IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"), - new URLSearchParams({ - grant_type: "authorization_code", - code: inputCredentials.code, - scope: `openid offline_access https://vault.azure.net/.default`, - client_id: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, - client_secret: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, - redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` - }) - ); - } catch (e: unknown) { - if (e instanceof AxiosError) { - tokenError = e; - } else { - throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` - }); - } - } - - if (tokenError) { - if (tokenError instanceof AxiosError) { - throw new BadRequestError({ - message: `Failed to get access token: ${ - (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" - }` - }); - } else { - throw new InternalServerError({ - message: "Failed to get access token" - }); - } - } - - if (!tokenResp) { - throw new InternalServerError({ - message: `Failed to get access token: Token was empty with no error` - }); - } - switch (method) { case AzureKeyVaultConnectionMethod.OAuth: + if (!INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || !INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET) { + throw new InternalServerError({ + message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse | null = null; + let tokenError: AxiosError | null = null; + const oauthCredentials = inputCredentials as { code: string; tenantId?: string }; + try { + tokenResp = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "authorization_code", + code: oauthCredentials.code, + scope: `openid offline_access https://vault.azure.net/.default`, + client_id: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, + redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` + }) + ); + } catch (e: unknown) { + if (e instanceof AxiosError) { + tokenError = e; + } else { + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + } + + if (tokenError) { + if (tokenError instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + + if (!tokenResp) { + throw new InternalServerError({ + message: `Failed to get access token: Token was empty with no error` + }); + } + return { - tenantId: inputCredentials.tenantId, + tenantId: oauthCredentials.tenantId, accessToken: tokenResp.data.access_token, refreshToken: tokenResp.data.refresh_token, expiresAt: Date.now() + tokenResp.data.expires_in * 1000 }; + + case AzureKeyVaultConnectionMethod.ClientSecret: + const { tenantId, clientId, clientSecret } = inputCredentials as { + tenantId: string; + clientId: string; + clientSecret: string; + }; + + try { + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://vault.azure.net/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + return { + tenantId, + accessToken: clientData.access_token, + expiresAt: Date.now() + clientData.expires_in * 1000, + clientId, + clientSecret + }; + } catch (e: unknown) { + if (e instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (e?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + default: throw new InternalServerError({ - message: `Unhandled Azure connection method: ${method as AzureKeyVaultConnectionMethod}` + message: `Unhandled Azure Key Vault connection method: ${method as AzureKeyVaultConnectionMethod}` }); } }; diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts index f3c7c43b8..d86878bab 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts @@ -22,6 +22,29 @@ export const AzureKeyVaultConnectionOAuthOutputCredentialsSchema = z.object({ expiresAt: z.number() }); +export const AzureKeyVaultConnectionClientSecretInputCredentialsSchema = z.object({ + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .max(50, "Client ID must be at most 50 characters long"), + clientSecret: z + .string() + .trim() + .min(1, "Client Secret required") + .max(50, "Client Secret must be at most 50 characters long"), + tenantId: z.string().uuid().trim().min(1, "Tenant ID required") +}); + +export const AzureKeyVaultConnectionClientSecretOutputCredentialsSchema = z.object({ + clientId: z.string(), + clientSecret: z.string(), + tenantId: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureKeyVaultConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -30,6 +53,14 @@ export const ValidateAzureKeyVaultConnectionCredentialsSchema = z.discriminatedU credentials: AzureKeyVaultConnectionOAuthInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureKeyVault).credentials ) + }), + z.object({ + method: z + .literal(AzureKeyVaultConnectionMethod.ClientSecret) + .describe(AppConnections.CREATE(AppConnection.AzureKeyVault).method), + credentials: AzureKeyVaultConnectionClientSecretInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureKeyVault).credentials + ) }) ]); @@ -39,9 +70,13 @@ export const CreateAzureKeyVaultConnectionSchema = ValidateAzureKeyVaultConnecti export const UpdateAzureKeyVaultConnectionSchema = z .object({ - credentials: AzureKeyVaultConnectionOAuthInputCredentialsSchema.optional().describe( - AppConnections.UPDATE(AppConnection.AzureKeyVault).credentials - ) + credentials: z + .union([ + AzureKeyVaultConnectionOAuthInputCredentialsSchema, + AzureKeyVaultConnectionClientSecretInputCredentialsSchema + ]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.AzureKeyVault).credentials) }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureKeyVault)); @@ -55,6 +90,10 @@ export const AzureKeyVaultConnectionSchema = z.intersection( z.object({ method: z.literal(AzureKeyVaultConnectionMethod.OAuth), credentials: AzureKeyVaultConnectionOAuthOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureKeyVaultConnectionMethod.ClientSecret), + credentials: AzureKeyVaultConnectionClientSecretOutputCredentialsSchema }) ]) ); @@ -65,6 +104,13 @@ export const SanitizedAzureKeyVaultConnectionSchema = z.discriminatedUnion("meth credentials: AzureKeyVaultConnectionOAuthOutputCredentialsSchema.pick({ tenantId: true }) + }), + BaseAzureKeyVaultConnectionSchema.extend({ + method: z.literal(AzureKeyVaultConnectionMethod.ClientSecret), + credentials: AzureKeyVaultConnectionClientSecretOutputCredentialsSchema.pick({ + clientId: true, + tenantId: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-types.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-types.ts index 95e68952a..5c5c89b95 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-types.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureKeyVaultConnectionClientSecretOutputCredentialsSchema, AzureKeyVaultConnectionOAuthOutputCredentialsSchema, AzureKeyVaultConnectionSchema, CreateAzureKeyVaultConnectionSchema, @@ -36,3 +37,7 @@ export type ExchangeCodeAzureResponse = { }; export type TAzureKeyVaultConnectionCredentials = z.infer; + +export type TAzureKeyVaultConnectionClientSecretCredentials = z.infer< + typeof AzureKeyVaultConnectionClientSecretOutputCredentialsSchema +>; diff --git a/backend/src/services/app-connection/camunda/camunda-connection-fns.ts b/backend/src/services/app-connection/camunda/camunda-connection-fns.ts index 90d9744b8..91a033c0e 100644 --- a/backend/src/services/app-connection/camunda/camunda-connection-fns.ts +++ b/backend/src/services/app-connection/camunda/camunda-connection-fns.ts @@ -82,7 +82,7 @@ export const validateCamundaConnectionCredentials = async (appConnection: TCamun }; } catch (e: unknown) { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } }; diff --git a/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts b/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts index 4e5db231f..35279d007 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts @@ -4,6 +4,7 @@ import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode, isAxi import { createRequestClient } from "@app/lib/config/request"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; import { ChecklyConnectionMethod } from "./checkly-connection-constants"; import { TChecklyAccount, TChecklyConnectionConfig, TChecklyVariable } from "./checkly-connection-types"; @@ -181,6 +182,122 @@ class ChecklyPublicClient { return res; } + + async getCheckGroups(connection: TChecklyConnectionConfig, accountId: string, limit = 50, page = 1) { + const res = await this.send<{ id: number; name: string }[]>(connection, { + accountId, + method: "GET", + url: `/v1/check-groups`, + params: { limit, page } + }); + + return res?.map((group) => ({ + id: group.id.toString(), + name: group.name + })); + } + + async getCheckGroup(connection: TChecklyConnectionConfig, accountId: string, groupId: string) { + try { + type ChecklyGroupResponse = { + id: number; + name: string; + environmentVariables: Array<{ + key: string; + value: string; + locked: boolean; + }>; + }; + + const res = await this.send(connection, { + accountId, + method: "GET", + url: `/v1/check-groups/${groupId}` + }); + + if (!res) return null; + + return { + id: res.id.toString(), + name: res.name, + environmentVariables: res.environmentVariables + }; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + throw error; + } + } + + async updateCheckGroupEnvironmentVariables( + connection: TChecklyConnectionConfig, + accountId: string, + groupId: string, + environmentVariables: Array<{ key: string; value: string; locked?: boolean }> + ) { + if (environmentVariables.length > 50) { + throw new SecretSyncError({ + message: "Checkly does not support syncing more than 50 variables to Check Group", + shouldRetry: false + }); + } + + const apiVariables = environmentVariables.map((v) => ({ + key: v.key, + value: v.value, + locked: v.locked ?? false, + secret: true + })); + + const group = await this.getCheckGroup(connection, accountId, groupId); + + await this.send(connection, { + accountId, + method: "PUT", + url: `/v2/check-groups/${groupId}`, + data: { name: group?.name, environmentVariables: apiVariables } + }); + + return this.getCheckGroup(connection, accountId, groupId); + } + + async getCheckGroupEnvironmentVariables(connection: TChecklyConnectionConfig, accountId: string, groupId: string) { + const group = await this.getCheckGroup(connection, accountId, groupId); + return group?.environmentVariables || []; + } + + async upsertCheckGroupEnvironmentVariables( + connection: TChecklyConnectionConfig, + accountId: string, + groupId: string, + variables: Array<{ key: string; value: string; locked?: boolean }> + ) { + const existingVars = await this.getCheckGroupEnvironmentVariables(connection, accountId, groupId); + const varMap = new Map(existingVars.map((v) => [v.key, v])); + + for (const newVar of variables) { + varMap.set(newVar.key, { + key: newVar.key, + value: newVar.value, + locked: newVar.locked ?? false + }); + } + + return this.updateCheckGroupEnvironmentVariables(connection, accountId, groupId, Array.from(varMap.values())); + } + + async deleteCheckGroupEnvironmentVariable( + connection: TChecklyConnectionConfig, + accountId: string, + groupId: string, + variableKey: string + ) { + const existingVars = await this.getCheckGroupEnvironmentVariables(connection, accountId, groupId); + const filteredVars = existingVars.filter((v) => v.key !== variableKey); + + return this.updateCheckGroupEnvironmentVariables(connection, accountId, groupId, filteredVars); + } } export const ChecklyPublicAPI = new ChecklyPublicClient(); diff --git a/backend/src/services/app-connection/checkly/checkly-connection-service.ts b/backend/src/services/app-connection/checkly/checkly-connection-service.ts index c3598320f..1312b4590 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-service.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-service.ts @@ -24,7 +24,19 @@ export const checklyConnectionService = (getAppConnection: TGetAppConnectionFunc } }; + const listGroups = async (connectionId: string, accountId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Checkly, connectionId, actor); + try { + const groups = await ChecklyPublicAPI.getCheckGroups(appConnection, accountId); + return groups!; + } catch (error) { + logger.error(error, "Failed to list accounts on Checkly"); + return []; + } + }; + return { - listAccounts + listAccounts, + listGroups }; }; diff --git a/backend/src/services/app-connection/checkly/checkly-connection-types.ts b/backend/src/services/app-connection/checkly/checkly-connection-types.ts index e8bb242ba..195d319d6 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-types.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-types.ts @@ -33,3 +33,15 @@ export type TChecklyAccount = { name: string; runtimeId: string; }; + +export type TChecklyGroupEnvironmentVariable = { + key: string; + value: string; + locked: boolean; +}; + +export type TChecklyGroup = { + id: string; + name: string; + environmentVariables?: TChecklyGroupEnvironmentVariable[]; +}; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts index d0ac070f3..02f304a7f 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts @@ -10,7 +10,8 @@ import { TCloudflareConnection, TCloudflareConnectionConfig, TCloudflarePagesProject, - TCloudflareWorkersScript + TCloudflareWorkersScript, + TCloudflareZone } from "./cloudflare-connection-types"; export const getCloudflareConnectionListItem = () => { @@ -66,6 +67,27 @@ export const listCloudflareWorkersScripts = async ( })); }; +export const listCloudflareZones = async (appConnection: TCloudflareConnection): Promise => { + const { + credentials: { apiToken } + } = appConnection; + + const { data } = await request.get<{ result: { name: string; id: string }[] }>( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + } + ); + + return data.result.map((a) => ({ + name: a.name, + id: a.id + })); +}; + export const validateCloudflareConnectionCredentials = async (config: TCloudflareConnectionConfig) => { const { apiToken, accountId } = config.credentials; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts index 5a8a161fc..c832ddb90 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts @@ -2,7 +2,11 @@ import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { listCloudflarePagesProjects, listCloudflareWorkersScripts } from "./cloudflare-connection-fns"; +import { + listCloudflarePagesProjects, + listCloudflareWorkersScripts, + listCloudflareZones +} from "./cloudflare-connection-fns"; import { TCloudflareConnection } from "./cloudflare-connection-types"; type TGetAppConnectionFunc = ( @@ -16,7 +20,6 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); try { const projects = await listCloudflarePagesProjects(appConnection); - return projects; } catch (error) { logger.error( @@ -30,9 +33,8 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF const listWorkersScripts = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); try { - const projects = await listCloudflareWorkersScripts(appConnection); - - return projects; + const scripts = await listCloudflareWorkersScripts(appConnection); + return scripts; } catch (error) { logger.error( error, @@ -42,8 +44,20 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF } }; + const listZones = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); + try { + const zones = await listCloudflareZones(appConnection); + return zones; + } catch (error) { + logger.error(error, `Failed to list Cloudflare Zones for Cloudflare connection [connectionId=${connectionId}]`); + return []; + } + }; + return { listPagesProjects, - listWorkersScripts + listWorkersScripts, + listZones }; }; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts index 0ac1b708c..163bf6495 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts @@ -32,3 +32,8 @@ export type TCloudflarePagesProject = { export type TCloudflareWorkersScript = { id: string; }; + +export type TCloudflareZone = { + id: string; + name: string; +}; diff --git a/backend/src/services/app-connection/databricks/databricks-connection-fns.ts b/backend/src/services/app-connection/databricks/databricks-connection-fns.ts index a35cc4aec..8912ad936 100644 --- a/backend/src/services/app-connection/databricks/databricks-connection-fns.ts +++ b/backend/src/services/app-connection/databricks/databricks-connection-fns.ts @@ -89,7 +89,7 @@ export const validateDatabricksConnectionCredentials = async (appConnection: TDa }; } catch (e: unknown) { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } }; diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts index fba852a0a..7349f89a6 100644 --- a/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts @@ -114,7 +114,7 @@ export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRa } throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } 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 360923e19..05e6fdda3 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -1,12 +1,17 @@ -import { createAppAuth } from "@octokit/auth-app"; -import { Octokit } from "@octokit/rest"; -import { AxiosResponse } from "axios"; +import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; +import https from "https"; +import RE2 from "re2"; +import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { getConfig } from "@app/lib/config/env"; -import { request } from "@app/lib/config/request"; +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"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns"; -import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { AppConnection } from "../app-connection-enums"; import { GitHubConnectionMethod } from "./github-connection-enums"; @@ -24,123 +29,340 @@ export const getGitHubConnectionListItem = () => { }; }; -export const getGitHubClient = (appConnection: TGitHubConnection) => { - const appCfg = getConfig(); +export const getGitHubInstanceApiUrl = async (config: { + credentials: Pick; +}) => { + const host = config.credentials.host || "github.com"; - const { method, credentials } = appConnection; + await blockLocalAndPrivateIpAddresses(`https://${host}`); - let client: Octokit; - - const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID; - const appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY; - - switch (method) { - case GitHubConnectionMethod.App: - if (!appId || !appPrivateKey) { - throw new InternalServerError({ - message: `GitHub ${getAppConnectionMethodName(method).replace("GitHub", "")} has not been configured` - }); - } - - client = new Octokit({ - authStrategy: createAppAuth, - auth: { - appId, - privateKey: appPrivateKey, - installationId: credentials.installationId - } - }); - break; - case GitHubConnectionMethod.OAuth: - client = new Octokit({ - auth: credentials.accessToken - }); - break; - default: - throw new InternalServerError({ - message: `Unhandled GitHub connection method: ${method as GitHubConnectionMethod}` - }); + let apiBase: string; + if (config.credentials.instanceType === "server") { + apiBase = `${host}/api/v3`; + } else { + apiBase = `api.${host}`; } - return client; + return apiBase; +}; + +export const requestWithGitHubGateway = async ( + appConnection: { gatewayId?: string | null }, + gatewayService: Pick, + requestConfig: AxiosRequestConfig +): Promise> => { + const { gatewayId } = appConnection; + + // If gateway isn't set up, don't proxy request + if (!gatewayId) { + return httpRequest.request(requestConfig); + } + + const url = new URL(requestConfig.url as string); + + await blockLocalAndPrivateIpAddresses(url.toString()); + + const [targetHost] = await verifyHostInputValidity(url.host, true); + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + return withGatewayProxy( + async (proxyPort) => { + const httpsAgent = new https.Agent({ + servername: targetHost + }); + + url.protocol = "https:"; + url.host = `localhost:${proxyPort}`; + + const finalRequestConfig: AxiosRequestConfig = { + ...requestConfig, + url: url.toString(), + httpsAgent, + headers: { + ...requestConfig.headers, + Host: targetHost + } + }; + + try { + return await httpRequest.request(finalRequestConfig); + } catch (error) { + const axiosError = error as AxiosError; + logger.error( + { message: axiosError.message, data: axiosError.response?.data }, + "Error during GitHub gateway request:" + ); + throw error; + } + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost, + targetPort: 443, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); +}; + +export const getGitHubAppAuthToken = async ( + appConnection: TGitHubConnection, + gatewayService: Pick +) => { + const appCfg = getConfig(); + const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID; + let appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY; + + if (!appId || !appPrivateKey) { + throw new InternalServerError({ + message: `GitHub App keys are not configured.` + }); + } + + 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 now = Math.floor(Date.now() / 1000); + const payload = { + iat: now, + exp: now + 5 * 60, + iss: appId + }; + + 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 { + const links = parseGitHubLinkHeader(linkHeader); + return links.next || null; +} + +export const makePaginatedGitHubRequest = async ( + appConnection: TGitHubConnection, + gatewayService: Pick, + path: string, + dataMapper?: (data: R) => T[] +): Promise => { + const { credentials, method } = appConnection; + + const token = + 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[] = []; + const maxIterations = 1000; + + // 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 firstPageItems = dataMapper ? dataMapper(firstResponse.data) : (firstResponse.data as unknown as T[]); + results = results.concat(firstPageItems); + + 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; }; type GitHubOrganization = { login: string; id: number; + type: string; }; type GitHubRepository = { id: number; name: string; owner: GitHubOrganization; + permissions?: { + admin: boolean; + maintain: boolean; + push: boolean; + triage: boolean; + pull: boolean; + }; }; -export const getGitHubRepositories = async (appConnection: TGitHubConnection) => { - const client = getGitHubClient(appConnection); +type GitHubEnvironment = { + id: number; + name: string; +}; - let repositories: GitHubRepository[]; - - switch (appConnection.method) { - case GitHubConnectionMethod.App: - repositories = await client.paginate("GET /installation/repositories"); - break; - case GitHubConnectionMethod.OAuth: - default: - repositories = (await client.paginate("GET /user/repos")).filter((repo) => repo.permissions?.admin); - break; +export const getGitHubRepositories = async ( + appConnection: TGitHubConnection, + gatewayService: Pick +) => { + if (appConnection.method === GitHubConnectionMethod.App) { + return makePaginatedGitHubRequest( + appConnection, + gatewayService, + "/installation/repositories", + (data) => data.repositories + ); } - return repositories; + const repos = await makePaginatedGitHubRequest(appConnection, gatewayService, "/user/repos"); + return repos.filter((repo) => repo.permissions?.admin); }; -export const getGitHubOrganizations = async (appConnection: TGitHubConnection) => { - const client = getGitHubClient(appConnection); +export const getGitHubOrganizations = async ( + appConnection: TGitHubConnection, + gatewayService: Pick +) => { + if (appConnection.method === GitHubConnectionMethod.App) { + const installationRepositories = await makePaginatedGitHubRequest< + GitHubRepository, + { repositories: GitHubRepository[] } + >(appConnection, gatewayService, "/installation/repositories", (data) => data.repositories); - let organizations: GitHubOrganization[]; - - switch (appConnection.method) { - case GitHubConnectionMethod.App: { - const installationRepositories = await client.paginate("GET /installation/repositories"); - - const organizationMap: Record = {}; - - installationRepositories.forEach((repo) => { - if (repo.owner.type === "Organization") { - organizationMap[repo.owner.id] = repo.owner; - } - }); - - organizations = Object.values(organizationMap); - - break; - } - case GitHubConnectionMethod.OAuth: - default: - organizations = await client.paginate("GET /user/orgs"); - break; - } - - return organizations; -}; - -export const getGitHubEnvironments = async (appConnection: TGitHubConnection, owner: string, repo: string) => { - const client = getGitHubClient(appConnection); - - try { - const environments = await client.paginate("GET /repos/{owner}/{repo}/environments", { - owner, - repo + const organizationMap: Record = {}; + installationRepositories.forEach((repo) => { + if (repo.owner.type === "Organization") { + organizationMap[repo.owner.id] = repo.owner; + } }); - return environments; - } catch (e) { - // repo doesn't have envs - if ((e as { status: number }).status === 404) { - return []; - } + return Object.values(organizationMap); + } - throw e; + return makePaginatedGitHubRequest(appConnection, gatewayService, "/user/orgs"); +}; + +export const getGitHubEnvironments = async ( + appConnection: TGitHubConnection, + gatewayService: Pick, + owner: string, + repo: string +) => { + try { + return await makePaginatedGitHubRequest( + appConnection, + gatewayService, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/environments`, + (data) => data.environments + ); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response?.status === 404) return []; + throw error; } }; @@ -159,9 +381,11 @@ export function isGithubErrorResponse(data: GithubTokenRespData): data is Github return "error" in data; } -export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => { +export const validateGitHubConnectionCredentials = async ( + config: TGitHubConnectionConfig, + gatewayService: Pick +) => { const { credentials, method } = config; - const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET, @@ -192,10 +416,13 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect } let tokenResp: AxiosResponse; + const host = credentials.host || "github.com"; try { - tokenResp = await request.get("https://github.com/login/oauth/access_token", { - params: { + tokenResp = await requestWithGitHubGateway(config, gatewayService, { + url: `https://${host}/login/oauth/access_token`, + method: "POST", + data: { client_id: clientId, client_secret: clientSecret, code: credentials.code, @@ -203,7 +430,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect }, headers: { Accept: "application/json", - "Accept-Encoding": "application/json" + "Content-Type": "application/json" } }); @@ -213,12 +440,14 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect }); } } catch (e: unknown) { + logger.error(e, "Unable to verify GitHub connection"); + if (e instanceof BadRequestError) { throw e; } throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } @@ -233,7 +462,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` }); } - const installationsResp = await request.get<{ + const installationsResp = await requestWithGitHubGateway<{ installations: { id: number; account: { @@ -242,7 +471,8 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect id: number; }; }[]; - }>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, { + }>(config, gatewayService, { + url: `https://${await getGitHubInstanceApiUrl(config)}/user/installations`, headers: { Accept: "application/json", Authorization: `Bearer ${tokenResp.data.access_token}`, @@ -264,11 +494,15 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect switch (method) { case GitHubConnectionMethod.App: return { - installationId: credentials.installationId + installationId: credentials.installationId, + instanceType: credentials.instanceType, + host: credentials.host }; case GitHubConnectionMethod.OAuth: return { - accessToken: tokenResp.data.access_token + accessToken: tokenResp.data.access_token, + instanceType: credentials.instanceType, + host: credentials.host }; default: throw new InternalServerError({ diff --git a/backend/src/services/app-connection/github/github-connection-schemas.ts b/backend/src/services/app-connection/github/github-connection-schemas.ts index e98b9169d..1b8aa9c3f 100644 --- a/backend/src/services/app-connection/github/github-connection-schemas.ts +++ b/backend/src/services/app-connection/github/github-connection-schemas.ts @@ -10,22 +10,59 @@ import { import { GitHubConnectionMethod } from "./github-connection-enums"; -export const GitHubConnectionOAuthInputCredentialsSchema = z.object({ - code: z.string().trim().min(1, "OAuth code required") -}); +export const GitHubConnectionOAuthInputCredentialsSchema = z.union([ + z.object({ + code: z.string().trim().min(1, "OAuth code required"), + instanceType: z.literal("server"), + host: z.string().trim().min(1, "Host is required for server instance type") + }), + z.object({ + code: z.string().trim().min(1, "OAuth code required"), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); -export const GitHubConnectionAppInputCredentialsSchema = z.object({ - code: z.string().trim().min(1, "GitHub App code required"), - installationId: z.string().min(1, "GitHub App Installation ID required") -}); +export const GitHubConnectionAppInputCredentialsSchema = z.union([ + z.object({ + code: z.string().trim().min(1, "GitHub App code required"), + installationId: z.string().min(1, "GitHub App Installation ID required"), + instanceType: z.literal("server"), + host: z.string().trim().min(1, "Host is required for server instance type") + }), + z.object({ + code: z.string().trim().min(1, "GitHub App code required"), + installationId: z.string().min(1, "GitHub App Installation ID required"), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); -export const GitHubConnectionOAuthOutputCredentialsSchema = z.object({ - accessToken: z.string() -}); +export const GitHubConnectionOAuthOutputCredentialsSchema = z.union([ + z.object({ + accessToken: z.string(), + instanceType: z.literal("server"), + host: z.string().trim().min(1) + }), + z.object({ + accessToken: z.string(), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); -export const GitHubConnectionAppOutputCredentialsSchema = z.object({ - installationId: z.string() -}); +export const GitHubConnectionAppOutputCredentialsSchema = z.union([ + z.object({ + installationId: z.string(), + instanceType: z.literal("server"), + host: z.string().trim().min(1) + }), + z.object({ + installationId: z.string(), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ @@ -43,7 +80,9 @@ export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("m ]); export const CreateGitHubConnectionSchema = ValidateGitHubConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.GitHub) + GenericCreateAppConnectionFieldsSchema(AppConnection.GitHub, { + supportsGateways: true + }) ); export const UpdateGitHubConnectionSchema = z @@ -53,7 +92,11 @@ export const UpdateGitHubConnectionSchema = z .optional() .describe(AppConnections.UPDATE(AppConnection.GitHub).credentials) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GitHub)); + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.GitHub, { + supportsGateways: true + }) + ); const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) }); @@ -74,11 +117,17 @@ export const GitHubConnectionSchema = z.intersection( export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [ BaseGitHubConnectionSchema.extend({ method: z.literal(GitHubConnectionMethod.App), - credentials: GitHubConnectionAppOutputCredentialsSchema.pick({}) + credentials: z.object({ + instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), + host: z.string().optional() + }) }), BaseGitHubConnectionSchema.extend({ method: z.literal(GitHubConnectionMethod.OAuth), - credentials: GitHubConnectionOAuthOutputCredentialsSchema.pick({}) + credentials: z.object({ + instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), + host: z.string().optional() + }) }) ]); diff --git a/backend/src/services/app-connection/github/github-connection-service.ts b/backend/src/services/app-connection/github/github-connection-service.ts index b4e95c5a7..f1198ddfa 100644 --- a/backend/src/services/app-connection/github/github-connection-service.ts +++ b/backend/src/services/app-connection/github/github-connection-service.ts @@ -1,3 +1,4 @@ +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { @@ -19,11 +20,14 @@ type TListGitHubEnvironmentsDTO = { owner: string; }; -export const githubConnectionService = (getAppConnection: TGetAppConnectionFunc) => { +export const githubConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + gatewayService: Pick +) => { const listRepositories = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor); - const repositories = await getGitHubRepositories(appConnection); + const repositories = await getGitHubRepositories(appConnection, gatewayService); return repositories; }; @@ -31,7 +35,7 @@ export const githubConnectionService = (getAppConnection: TGetAppConnectionFunc) const listOrganizations = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor); - const organizations = await getGitHubOrganizations(appConnection); + const organizations = await getGitHubOrganizations(appConnection, gatewayService); return organizations; }; @@ -42,7 +46,7 @@ export const githubConnectionService = (getAppConnection: TGetAppConnectionFunc) ) => { const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor); - const environments = await getGitHubEnvironments(appConnection, owner, repo); + const environments = await getGitHubEnvironments(appConnection, gatewayService, owner, repo); return environments; }; diff --git a/backend/src/services/app-connection/github/github-connection-types.ts b/backend/src/services/app-connection/github/github-connection-types.ts index 600506277..c4aed54b5 100644 --- a/backend/src/services/app-connection/github/github-connection-types.ts +++ b/backend/src/services/app-connection/github/github-connection-types.ts @@ -17,4 +17,7 @@ export type TGitHubConnectionInput = z.infer; +export type TGitHubConnectionConfig = DiscriminativePick< + TGitHubConnectionInput, + "method" | "app" | "credentials" | "gatewayId" +>; diff --git a/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts b/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts index d874d1447..cb4e27e94 100644 --- a/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts +++ b/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts @@ -222,6 +222,37 @@ export const validateGitLabConnectionCredentials = async (config: TGitLabConnect return inputCredentials; }; +export const getGitLabConnectionClient = async ( + appConnection: TGitLabConnection, + appConnectionDAL: Pick, + kmsService: Pick +) => { + let { accessToken } = appConnection.credentials; + + if ( + appConnection.method === GitLabConnectionMethod.OAuth && + appConnection.credentials.refreshToken && + new Date(appConnection.credentials.expiresAt) < new Date() + ) { + accessToken = await refreshGitLabToken( + appConnection.credentials.refreshToken, + appConnection.id, + appConnection.orgId, + appConnectionDAL, + kmsService, + appConnection.credentials.instanceUrl + ); + } + + const client = await getGitLabClient( + accessToken, + appConnection.credentials.instanceUrl, + appConnection.method === GitLabConnectionMethod.OAuth + ); + + return client; +}; + export const listGitLabProjects = async ({ appConnection, appConnectionDAL, diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts index 48695a2a5..46a59bcec 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -1,18 +1,18 @@ -import { AxiosError } from "axios"; +import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; +import https from "https"; +import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { request } from "@app/lib/config/request"; import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { HCVaultConnectionMethod } from "./hc-vault-connection-enums"; -import { - THCVaultConnection, - THCVaultConnectionConfig, - THCVaultMountResponse, - TValidateHCVaultConnectionCredentials -} from "./hc-vault-connection-types"; +import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types"; export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => { const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); @@ -37,7 +37,78 @@ type TokenRespData = { }; }; -export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnectionCredentials) => { +export const requestWithHCVaultGateway = async ( + appConnection: { gatewayId?: string | null }, + gatewayService: Pick, + requestConfig: AxiosRequestConfig +): Promise> => { + const { gatewayId } = appConnection; + + // If gateway isn't set up, don't proxy request + if (!gatewayId) { + return request.request(requestConfig); + } + + const url = new URL(requestConfig.url as string); + + await blockLocalAndPrivateIpAddresses(url.toString()); + + const [targetHost] = await verifyHostInputValidity(url.hostname, true); + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + return withGatewayProxy( + async (proxyPort) => { + const httpsAgent = new https.Agent({ + servername: targetHost + }); + + url.protocol = "https:"; + url.host = `localhost:${proxyPort}`; + + const finalRequestConfig: AxiosRequestConfig = { + ...requestConfig, + url: url.toString(), + httpsAgent, + headers: { + ...requestConfig.headers, + Host: targetHost + } + }; + + try { + return await request.request(finalRequestConfig); + } catch (error) { + if (error instanceof AxiosError) { + logger.error( + { message: error.message, data: (error.response as undefined | { data: unknown })?.data }, + "Error during HashiCorp Vault gateway request:" + ); + } + throw error; + } + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost, + targetPort: url.port ? Number(url.port) : 8200, // 8200 is the default port for Vault self-hosted/dedicated + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); +}; + +export const getHCVaultAccessToken = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { // Return access token directly if not using AppRole method if (connection.method !== HCVaultConnectionMethod.AppRole) { return connection.credentials.accessToken; @@ -46,16 +117,16 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti // Generate temporary token for AppRole method try { const { instanceUrl, roleId, secretId } = connection.credentials; - const tokenResp = await request.post( - `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`, - { role_id: roleId, secret_id: secretId }, - { - headers: { - "Content-Type": "application/json", - ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) - } - } - ); + + const tokenResp = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`, + method: "POST", + headers: { + "Content-Type": "application/json", + ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) + }, + data: { role_id: roleId, secret_id: secretId } + }); if (tokenResp.status !== 200) { throw new BadRequestError({ @@ -71,38 +142,55 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti } }; -export const validateHCVaultConnectionCredentials = async (config: THCVaultConnectionConfig) => { - const instanceUrl = await getHCVaultInstanceUrl(config); +export const validateHCVaultConnectionCredentials = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); try { - const accessToken = await getHCVaultAccessToken(config); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); // Verify token - await request.get(`${instanceUrl}/v1/auth/token/lookup-self`, { + await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/auth/token/lookup-self`, + method: "GET", headers: { "X-Vault-Token": accessToken } }); - return config.credentials; + return connection.credentials; } catch (error: unknown) { + logger.error(error, "Unable to verify HC Vault connection"); + if (error instanceof AxiosError) { throw new BadRequestError({ message: `Failed to validate credentials: ${error.message || "Unknown error"}` }); } + + if (error instanceof BadRequestError) { + throw error; + } + throw new BadRequestError({ message: "Unable to validate connection: verify credentials" }); } }; -export const listHCVaultMounts = async (appConnection: THCVaultConnection) => { - const instanceUrl = await getHCVaultInstanceUrl(appConnection); - const accessToken = await getHCVaultAccessToken(appConnection); +export const listHCVaultMounts = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); - const { data } = await request.get(`${instanceUrl}/v1/sys/mounts`, { + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/mounts`, + method: "GET", headers: { "X-Vault-Token": accessToken, - ...(appConnection.credentials.namespace ? { "X-Vault-Namespace": appConnection.credentials.namespace } : {}) + ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) } }); diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts index a6db4d0eb..57e8fbeaf 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts @@ -55,11 +55,18 @@ export const HCVaultConnectionSchema = z.intersection( export const SanitizedHCVaultConnectionSchema = z.discriminatedUnion("method", [ BaseHCVaultConnectionSchema.extend({ method: z.literal(HCVaultConnectionMethod.AccessToken), - credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({}) + credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({ + namespace: true, + instanceUrl: true + }) }), BaseHCVaultConnectionSchema.extend({ method: z.literal(HCVaultConnectionMethod.AppRole), - credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({}) + credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({ + namespace: true, + instanceUrl: true, + roleId: true + }) }) ]); @@ -81,7 +88,7 @@ export const ValidateHCVaultConnectionCredentialsSchema = z.discriminatedUnion(" ]); export const CreateHCVaultConnectionSchema = ValidateHCVaultConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault) + GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true }) ); export const UpdateHCVaultConnectionSchema = z @@ -91,7 +98,7 @@ export const UpdateHCVaultConnectionSchema = z .optional() .describe(AppConnections.UPDATE(AppConnection.HCVault).credentials) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault)); + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true })); export const HCVaultConnectionListItemSchema = z.object({ name: z.literal("HCVault"), diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts index b5cee6fdd..589c7c1bd 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts @@ -1,3 +1,4 @@ +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; @@ -11,12 +12,15 @@ type TGetAppConnectionFunc = ( actor: OrgServiceActor ) => Promise; -export const hcVaultConnectionService = (getAppConnection: TGetAppConnectionFunc) => { +export const hcVaultConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + gatewayService: Pick +) => { const listMounts = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.HCVault, connectionId, actor); try { - const mounts = await listHCVaultMounts(appConnection); + const mounts = await listHCVaultMounts(appConnection, gatewayService); return mounts; } catch (error) { logger.error(error, "Failed to establish connection with Hashicorp Vault"); diff --git a/backend/src/services/app-connection/netlify/index.ts b/backend/src/services/app-connection/netlify/index.ts new file mode 100644 index 000000000..3e0f589f5 --- /dev/null +++ b/backend/src/services/app-connection/netlify/index.ts @@ -0,0 +1,4 @@ +export * from "./netlify-connection-constants"; +export * from "./netlify-connection-fns"; +export * from "./netlify-connection-schemas"; +export * from "./netlify-connection-types"; diff --git a/backend/src/services/app-connection/netlify/netlify-connection-constants.ts b/backend/src/services/app-connection/netlify/netlify-connection-constants.ts new file mode 100644 index 000000000..6e74bd2f7 --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-constants.ts @@ -0,0 +1,3 @@ +export enum NetlifyConnectionMethod { + AccessToken = "access-token" +} diff --git a/backend/src/services/app-connection/netlify/netlify-connection-fns.ts b/backend/src/services/app-connection/netlify/netlify-connection-fns.ts new file mode 100644 index 000000000..7f1bee4ff --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-fns.ts @@ -0,0 +1,35 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { BadRequestError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { NetlifyConnectionMethod } from "./netlify-connection-constants"; +import { NetlifyPublicAPI } from "./netlify-connection-public-client"; +import { TNetlifyConnectionConfig } from "./netlify-connection-types"; + +export const getNetlifyConnectionListItem = () => { + return { + name: "Netlify" as const, + app: AppConnection.Netlify as const, + methods: Object.values(NetlifyConnectionMethod) + }; +}; + +export const validateNetlifyConnectionCredentials = async (config: TNetlifyConnectionConfig) => { + try { + await NetlifyPublicAPI.healthcheck(config); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to validate connection - verify credentials" + }); + } + + return config.credentials; +}; diff --git a/backend/src/services/app-connection/netlify/netlify-connection-public-client.ts b/backend/src/services/app-connection/netlify/netlify-connection-public-client.ts new file mode 100644 index 000000000..d1328539b --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-public-client.ts @@ -0,0 +1,261 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable no-await-in-loop */ +/* eslint-disable class-methods-use-this */ +import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode, isAxiosError } from "axios"; + +import { createRequestClient } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { NetlifyConnectionMethod } from "./netlify-connection-constants"; +import { TNetlifyAccount, TNetlifyConnectionConfig, TNetlifySite, TNetlifyVariable } from "./netlify-connection-types"; + +export function getNetlifyAuthHeaders(connection: TNetlifyConnectionConfig): Record { + switch (connection.method) { + case NetlifyConnectionMethod.AccessToken: + return { + Authorization: `Bearer ${connection.credentials.accessToken}` + }; + default: + throw new Error(`Unsupported Netlify connection method`); + } +} + +export function getNetlifyRatelimiter(response: AxiosResponse): { + maxAttempts: number; + isRatelimited: boolean; + wait: () => Promise; +} { + const wait = (seconds: number = 60) => { + return new Promise((res) => { + setTimeout(res, seconds * 1000); + }); + }; + + let remaining = parseInt(response.headers["X-RateLimit-Remaining"] as string, 10); + let isRatelimited = response.status === HttpStatusCode.TooManyRequests; + + if (isRatelimited) { + if (Math.round(remaining) > 0) { + isRatelimited = true; + remaining += 1; // Jitter to ensure we wait at least 1 second + } else { + remaining = 60; + } + } + + return { + isRatelimited, + wait: () => wait(remaining), + maxAttempts: 3 + }; +} + +type NetlifyParams = { + account_id: string; + context_name?: string; + site_id?: string; +}; + +class NetlifyPublicClient { + private client: AxiosInstance; + + constructor() { + this.client = createRequestClient({ + baseURL: `${IntegrationUrls.NETLIFY_API_URL}/api/v1`, + headers: { + "Content-Type": "application/json" + } + }); + } + + async send(connection: TNetlifyConnectionConfig, config: AxiosRequestConfig, retryAttempt = 0): Promise { + const response = await this.client.request({ + ...config, + timeout: 1000 * 60, // 60 seconds timeout + validateStatus: (status) => (status >= 200 && status < 300) || status === HttpStatusCode.TooManyRequests, + headers: getNetlifyAuthHeaders(connection) + }); + const limiter = getNetlifyRatelimiter(response); + + if (limiter.isRatelimited && retryAttempt <= limiter.maxAttempts) { + await limiter.wait(); + return this.send(connection, config, retryAttempt + 1); + } + + return response.data; + } + + healthcheck(connection: TNetlifyConnectionConfig) { + switch (connection.method) { + case NetlifyConnectionMethod.AccessToken: + return this.getNetlifyAccounts(connection); + default: + throw new Error(`Unsupported Netlify connection method`); + } + } + + async getVariables( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + limit: number = 50, + page: number = 1 + ) { + const res = await this.send(connection, { + method: "GET", + url: `/accounts/${account_id}/env`, + params: { + ...params, + limit, + page + } + }); + + return res; + } + + async createVariable( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + ...variables: TNetlifyVariable[] + ) { + const res = await this.send(connection, { + method: "POST", + url: `/accounts/${account_id}/env`, + data: variables, + params + }); + + return res; + } + + async updateVariableValue( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + variable: TNetlifyVariable + ) { + const res = await this.send(connection, { + method: "PATCH", + url: `/accounts/${account_id}/env/${variable.key}`, + data: variable, + params + }); + + return res; + } + + async updateVariable( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + variable: TNetlifyVariable + ) { + const res = await this.send(connection, { + method: "PUT", + url: `/accounts/${account_id}/env/${variable.key}`, + data: variable, + params + }); + + return res; + } + + async getVariable( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + variable: Pick + ) { + try { + const res = await this.send(connection, { + method: "GET", + url: `/accounts/${account_id}/env/${variable.key}`, + params + }); + + return res; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + + throw error; + } + } + + async upsertVariable(connection: TNetlifyConnectionConfig, params: NetlifyParams, variable: TNetlifyVariable) { + const res = await this.getVariable(connection, params, variable); + + if (!res) { + return this.createVariable(connection, params, variable); + } + + if (res.is_secret) { + await this.deleteVariable(connection, params, variable); + return this.createVariable(connection, params, variable); + } + + return this.updateVariable(connection, params, variable); + } + + async deleteVariable( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + variable: Pick + ) { + try { + const res = await this.send(connection, { + method: "DELETE", + url: `/accounts/${account_id}/env/${variable.key}`, + params + }); + + return res; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + + throw error; + } + } + + async deleteVariableValue( + connection: TNetlifyConnectionConfig, + { account_id, value_id, ...params }: NetlifyParams & { value_id: string }, + variable: Pick + ) { + try { + const res = await this.send(connection, { + method: "DELETE", + url: `/accounts/${account_id}/${variable.key}/value/${value_id}`, + params + }); + + return res; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + + throw error; + } + } + + async getSites(connection: TNetlifyConnectionConfig, accountId: string) { + const res = await this.send(connection, { + method: "GET", + url: `/${accountId}/sites` + }); + + return res; + } + + async getNetlifyAccounts(connection: TNetlifyConnectionConfig) { + const res = await this.send(connection, { + method: "GET", + url: `/accounts` + }); + + return res; + } +} + +export const NetlifyPublicAPI = new NetlifyPublicClient(); diff --git a/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts b/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts new file mode 100644 index 000000000..45fb89760 --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts @@ -0,0 +1,67 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { NetlifyConnectionMethod } from "./netlify-connection-constants"; + +export const NetlifyConnectionMethodSchema = z + .nativeEnum(NetlifyConnectionMethod) + .describe(AppConnections.CREATE(AppConnection.Netlify).method); + +export const NetlifyConnectionAccessTokenCredentialsSchema = z.object({ + accessToken: z + .string() + .trim() + .min(1, "Access Token required") + .max(255) + .describe(AppConnections.CREDENTIALS.NETLIFY.accessToken) +}); + +const BaseNetlifyConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Netlify) +}); + +export const NetlifyConnectionSchema = BaseNetlifyConnectionSchema.extend({ + method: NetlifyConnectionMethodSchema, + credentials: NetlifyConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedNetlifyConnectionSchema = z.discriminatedUnion("method", [ + BaseNetlifyConnectionSchema.extend({ + method: NetlifyConnectionMethodSchema, + credentials: NetlifyConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateNetlifyConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: NetlifyConnectionMethodSchema, + credentials: NetlifyConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Netlify).credentials + ) + }) +]); + +export const CreateNetlifyConnectionSchema = ValidateNetlifyConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Netlify) +); + +export const UpdateNetlifyConnectionSchema = z + .object({ + credentials: NetlifyConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Netlify).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Netlify)); + +export const NetlifyConnectionListItemSchema = z.object({ + name: z.literal("Netlify"), + app: z.literal(AppConnection.Netlify), + methods: z.nativeEnum(NetlifyConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/netlify/netlify-connection-service.ts b/backend/src/services/app-connection/netlify/netlify-connection-service.ts new file mode 100644 index 000000000..c2cf65e24 --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-service.ts @@ -0,0 +1,42 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { NetlifyPublicAPI } from "./netlify-connection-public-client"; +import { TNetlifyConnection } from "./netlify-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const netlifyConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listAccounts = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Netlify, connectionId, actor); + try { + const accounts = await NetlifyPublicAPI.getNetlifyAccounts(appConnection); + return accounts; + } catch (error) { + logger.error(error, "Failed to list accounts on Netlify"); + return []; + } + }; + + const listSites = async (connectionId: string, actor: OrgServiceActor, accountId: string) => { + const appConnection = await getAppConnection(AppConnection.Netlify, connectionId, actor); + try { + const sites = await NetlifyPublicAPI.getSites(appConnection, accountId); + return sites; + } catch (error) { + logger.error(error, "Failed to list sites on Netlify"); + return []; + } + }; + + return { + listAccounts, + listSites + }; +}; diff --git a/backend/src/services/app-connection/netlify/netlify-connection-types.ts b/backend/src/services/app-connection/netlify/netlify-connection-types.ts new file mode 100644 index 000000000..dc32f48d0 --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-types.ts @@ -0,0 +1,51 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateNetlifyConnectionSchema, + NetlifyConnectionSchema, + ValidateNetlifyConnectionCredentialsSchema +} from "./netlify-connection-schemas"; + +export type TNetlifyConnection = z.infer; + +export type TNetlifyConnectionInput = z.infer & { + app: AppConnection.Netlify; +}; + +export type TValidateNetlifyConnectionCredentialsSchema = typeof ValidateNetlifyConnectionCredentialsSchema; + +export type TNetlifyConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TNetlifyVariable = { + key: string; + id?: string; // ID of the variable (present in responses) + created_at?: string; + updated_at?: string; + is_secret?: boolean; + scopes?: ("builds" | "functions" | "runtime" | "post_processing")[]; + values: TNetlifyVariableValue[]; +}; + +export type TNetlifyVariableValue = { + id?: string; + context?: string; // "all", "dev", "branch-deploy", etc. + value?: string; // Omitted in response if `is_secret` is true + site_id?: string; // Optional: overrides at site-level + created_at?: string; + updated_at?: string; +}; + +export type TNetlifyAccount = { + id: string; + name: string; +}; + +export type TNetlifySite = { + id: string; + name: string; +}; 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/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts index 4c671986f..02bd0c77f 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -44,7 +44,8 @@ const getConnectionConfig = ({ ? { trustServerCertificate: !sslRejectUnauthorized, encrypt: true, - cryptoCredentialsDetails: sslCertificate ? { ca: sslCertificate } : {} + cryptoCredentialsDetails: sslCertificate ? { ca: sslCertificate } : {}, + servername: host } : { encrypt: false } }; @@ -55,7 +56,7 @@ const getConnectionConfig = ({ ? { rejectUnauthorized: sslRejectUnauthorized, ca: sslCertificate, - servername: host + serverName: host } : false }; @@ -89,7 +90,7 @@ export const getSqlConnectionClient = async (appConnection: Pick( }, { protocol: GatewayProxyProtocol.Tcp, - targetHost, + targetHost: app === AppConnection.Postgres ? targetHost : credentials.host, targetPort: credentials.port, relayHost, relayPort: Number(relayPort), diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 1a2f290ec..c309b3998 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -75,7 +75,7 @@ export const getTokenConfig = (tokenType: TokenType) => { }; export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => { - const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => { + const createTokenForUser = async ({ type, userId, orgId, aliasId }: TCreateTokenForUserDTO) => { const { token, ...tkCfg } = getTokenConfig(type); const appCfg = getConfig(); const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS); @@ -88,7 +88,8 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu type, userId, orgId, - triesLeft: tkCfg?.triesLeft + triesLeft: tkCfg?.triesLeft, + aliasId }, tx ); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 5f5843bc6..7deb719a9 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -14,6 +14,7 @@ export type TCreateTokenForUserDTO = { type: TokenType; userId: string; orgId?: string; + aliasId?: string; }; export type TCreateOrgInviteTokenDTO = { diff --git a/backend/src/services/auth/auth-fns.ts b/backend/src/services/auth/auth-fns.ts index b38275c8b..d490486cb 100644 --- a/backend/src/services/auth/auth-fns.ts +++ b/backend/src/services/auth/auth-fns.ts @@ -1,8 +1,16 @@ +import { TUsers } from "@app/db/schemas"; +import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto"; -import { ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; -import { AuthModeProviderJwtTokenPayload, AuthModeProviderSignUpTokenPayload, AuthTokenType } from "./auth-type"; +import { + AuthMethod, + AuthModeProviderJwtTokenPayload, + AuthModeProviderSignUpTokenPayload, + AuthTokenType +} from "./auth-type"; export const validateProviderAuthToken = (providerToken: string, username?: string) => { if (!providerToken) throw new UnauthorizedError(); @@ -97,3 +105,50 @@ export const enforceUserLockStatus = (isLocked: boolean, temporaryLockDateEnd?: } } }; + +export const verifyCaptcha = async (user: TUsers, captchaToken?: string) => { + const appCfg = getConfig(); + if ( + user.consecutiveFailedPasswordAttempts && + user.consecutiveFailedPasswordAttempts >= 10 && + Boolean(appCfg.CAPTCHA_SECRET) + ) { + if (!captchaToken) { + throw new BadRequestError({ + name: "Captcha Required", + message: "Accomplish the required captcha by logging in via Web" + }); + } + + // validate captcha token + const response = await request.postForm<{ success: boolean }>("https://api.hcaptcha.com/siteverify", { + response: captchaToken, + secret: appCfg.CAPTCHA_SECRET + }); + + if (!response.data.success) { + throw new BadRequestError({ + name: "Invalid Captcha" + }); + } + } +}; + +export const getAuthMethodAndOrgId = (email: string, providerAuthToken?: string) => { + let authMethod = AuthMethod.EMAIL; + let organizationId: string | undefined; + + if (providerAuthToken) { + const decodedProviderToken = validateProviderAuthToken(providerAuthToken, email); + + authMethod = decodedProviderToken.authMethod; + if ( + (isAuthMethodSaml(authMethod) || [AuthMethod.LDAP, AuthMethod.OIDC].includes(authMethod)) && + decodedProviderToken.orgId + ) { + organizationId = decodedProviderToken.orgId; + } + } + + return { authMethod, organizationId }; +}; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index aea90fe11..ede9e29e3 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -4,7 +4,6 @@ import { OrgMembershipRole, OrgMembershipStatus, TableName, TUsers, UserDeviceSc import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; -import { request } from "@app/lib/config/request"; import { crypto, generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; @@ -22,7 +21,8 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { LoginMethod } from "../super-admin/super-admin-types"; import { TTotpServiceFactory } from "../totp/totp-service"; import { TUserDALFactory } from "../user/user-dal"; -import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns"; +import { UserEncryption } from "../user/user-types"; +import { enforceUserLockStatus, getAuthMethodAndOrgId, validateProviderAuthToken, verifyCaptcha } from "./auth-fns"; import { TLoginClientProofDTO, TLoginGenServerPublicKeyDTO, @@ -148,9 +148,15 @@ export const authLoginServiceFactory = ({ if (organizationId) { const org = await orgDAL.findById(organizationId); - if (org && org.userTokenExpiration) { - tokenSessionExpiresIn = getMinExpiresIn(cfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); - refreshTokenExpiresIn = org.userTokenExpiration; + if (org) { + await orgMembershipDAL.update( + { userId: user.id, orgId: org.id }, + { lastLoginAuthMethod: authMethod, lastLoginTime: new Date() } + ); + if (org.userTokenExpiration) { + tokenSessionExpiresIn = getMinExpiresIn(cfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); + refreshTokenExpiresIn = org.userTokenExpiration; + } } } @@ -208,6 +214,10 @@ export const authLoginServiceFactory = ({ throw new Error("Failed to find user"); } + if (!userEnc.salt || !userEnc.verifier) { + throw new BadRequestError({ message: "Salt or verifier not found" }); + } + if ( serverCfg.enabledLoginMethods && !serverCfg.enabledLoginMethods.includes(LoginMethod.EMAIL) && @@ -247,8 +257,6 @@ export const authLoginServiceFactory = ({ captchaToken, password }: TLoginClientProofDTO) => { - const appCfg = getConfig(); - // akhilmhdh: case sensitive email resolution const usersByUsername = await userDAL.findUserEncKeyByUsername({ username: email @@ -259,44 +267,11 @@ export const authLoginServiceFactory = ({ const user = await userDAL.findById(userEnc.userId); const cfg = getConfig(); - let authMethod = AuthMethod.EMAIL; - let organizationId: string | undefined; + const { authMethod, organizationId } = getAuthMethodAndOrgId(email, providerAuthToken); + await verifyCaptcha(user, captchaToken); - if (providerAuthToken) { - const decodedProviderToken = validateProviderAuthToken(providerAuthToken, email); - - authMethod = decodedProviderToken.authMethod; - if ( - (isAuthMethodSaml(authMethod) || [AuthMethod.LDAP, AuthMethod.OIDC].includes(authMethod)) && - decodedProviderToken.orgId - ) { - organizationId = decodedProviderToken.orgId; - } - } - - if ( - user.consecutiveFailedPasswordAttempts && - user.consecutiveFailedPasswordAttempts >= 10 && - Boolean(appCfg.CAPTCHA_SECRET) - ) { - if (!captchaToken) { - throw new BadRequestError({ - name: "Captcha Required", - message: "Accomplish the required captcha by logging in via Web" - }); - } - - // validate captcha token - const response = await request.postForm<{ success: boolean }>("https://api.hcaptcha.com/siteverify", { - response: captchaToken, - secret: appCfg.CAPTCHA_SECRET - }); - - if (!response.data.success) { - throw new BadRequestError({ - name: "Invalid Captcha" - }); - } + if (!userEnc.salt || !userEnc.verifier) { + throw new BadRequestError({ message: "Salt or verifier not found" }); } if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?"); @@ -371,6 +346,80 @@ export const authLoginServiceFactory = ({ return { token, user: userEnc } as const; }; + const login = async ({ + email, + password, + ip, + userAgent, + providerAuthToken, + captchaToken + }: { + email: string; + password: string; + ip: string; + userAgent: string; + providerAuthToken?: string; + captchaToken?: string; + }) => { + const usersByUsername = await userDAL.findUserEncKeyByUsername({ + username: email + }); + const userEnc = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; + + if (!userEnc) throw new BadRequestError({ message: "User not found" }); + + if (userEnc.encryptionVersion !== UserEncryption.V2) { + throw new BadRequestError({ message: "Legacy encryption scheme not supported", name: "LegacyEncryptionScheme" }); + } + + if (!userEnc.hashedPassword) { + if (userEnc.authMethods?.includes(AuthMethod.EMAIL)) { + throw new BadRequestError({ + message: "Legacy encryption scheme not supported", + name: "LegacyEncryptionScheme" + }); + } + + throw new BadRequestError({ message: "No password found" }); + } + + const { authMethod, organizationId } = getAuthMethodAndOrgId(email, providerAuthToken); + await verifyCaptcha(userEnc, captchaToken); + + if (!(await crypto.hashing().compareHash(password, userEnc.hashedPassword))) { + await userDAL.update( + { id: userEnc.userId }, + { + $incr: { + consecutiveFailedPasswordAttempts: 1 + } + } + ); + + throw new BadRequestError({ message: "Invalid username or email" }); + } + + const token = await generateUserTokens({ + user: { + ...userEnc, + id: userEnc.userId + }, + ip, + userAgent, + authMethod, + organizationId + }); + + return { + tokens: { + accessToken: token.access, + refreshToken: token.refresh + }, + user: userEnc + } as const; + }; + const selectOrganization = async ({ userAgent, authJwtToken, @@ -399,15 +448,46 @@ 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); - if (!hasOrganizationMembership) { + if (!selectedOrgMembership) { throw new ForbiddenRequestError({ message: `User does not have access to the organization named ${selectedOrg?.name}` }); } + // Check if authEnforced is true and the current auth method is not an enforced method + if ( + selectedOrg.authEnforced && + !isAuthMethodSaml(decodedToken.authMethod) && + decodedToken.authMethod !== AuthMethod.OIDC && + !(selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin) + ) { + throw new BadRequestError({ + message: "Login with the auth method required by your organization." + }); + } + + 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; @@ -453,7 +533,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, @@ -656,7 +737,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]; @@ -710,6 +791,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" }); @@ -735,7 +818,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({ @@ -775,18 +857,45 @@ export const authLoginServiceFactory = ({ } } - const userEnc = await userDAL.findUserEncKeyByUserId(user.id); + 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, firstName: user.firstName, lastName: user.lastName, - hasExchangedPrivateKey: Boolean(userEnc?.serverEncryptedPrivateKey), + hasExchangedPrivateKey: true, authMethod, isUserCompleted, ...(callbackPort @@ -831,8 +940,7 @@ export const authLoginServiceFactory = ({ const userEnc = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; - if (!userEnc?.serverEncryptedPrivateKey) - throw new BadRequestError({ message: "Key handoff incomplete. Please try logging in again." }); + if (!userEnc) throw new BadRequestError({ message: "User encryption not found" }); const token = await generateUserTokens({ user: { ...userEnc, id: userEnc.userId }, @@ -862,6 +970,7 @@ export const authLoginServiceFactory = ({ resendMfaToken, verifyMfaToken, selectOrganization, - generateUserTokens + generateUserTokens, + login }; }; 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/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 0dbdfaf79..efc8b3cc0 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -1,8 +1,5 @@ -import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; -import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { crypto } from "@app/lib/crypto/cryptography"; -import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; @@ -16,8 +13,6 @@ import { UserEncryption } from "../user/user-types"; import { TAuthDALFactory } from "./auth-dal"; import { ResetPasswordV2Type, - TChangePasswordDTO, - TCreateBackupPrivateKeyDTO, TResetPasswordV2DTO, TResetPasswordViaBackupKeyDTO, TSetupPasswordViaBackupKeyDTO @@ -40,79 +35,6 @@ export const authPaswordServiceFactory = ({ smtpService, totpConfigDAL }: TAuthPasswordServiceFactoryDep) => { - /* - * Pre setup for pass change with srp protocol - * Gets srp server user salt and server public key - */ - const generateServerPubKey = async (userId: string, clientPublicKey: string) => { - const userEnc = await userDAL.findUserEncKeyByUserId(userId); - if (!userEnc) throw new Error("Failed to find user"); - - const serverSrpKey = await generateSrpServerKey(userEnc.salt, userEnc.verifier); - const userEncKeys = await userDAL.updateUserEncryptionByUserId(userEnc.userId, { - clientPublicKey, - serverPrivateKey: serverSrpKey.privateKey - }); - if (!userEncKeys) throw new Error("Failed to update encryption key"); - return { salt: userEncKeys.salt, serverPublicKey: serverSrpKey.pubKey }; - }; - - /* - * Change password to new pass - * */ - const changePassword = async ({ - userId, - clientProof, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt, - verifier, - tokenVersionId, - password - }: TChangePasswordDTO) => { - const userEnc = await userDAL.findUserEncKeyByUserId(userId); - if (!userEnc) throw new Error("Failed to find user"); - - await userDAL.updateUserEncryptionByUserId(userEnc.userId, { - serverPrivateKey: null, - clientPublicKey: null - }); - if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?"); - const isValidClientProof = await srpCheckClientProof( - userEnc.salt, - userEnc.verifier, - userEnc.serverPrivateKey, - userEnc.clientPublicKey, - clientProof - ); - if (!isValidClientProof) throw new Error("Failed to authenticate. Try again?"); - - const appCfg = getConfig(); - const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS); - await userDAL.updateUserEncryptionByUserId(userId, { - encryptionVersion: 2, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - salt, - verifier, - serverPrivateKey: null, - clientPublicKey: null, - hashedPassword - }); - - if (tokenVersionId) { - await tokenService.clearTokenSessionById(userEnc.userId, tokenVersionId); - } - }; - /* * Email password reset flow via email. Step 1 send email */ @@ -193,6 +115,10 @@ export const authPaswordServiceFactory = ({ } if (!user.authMethods?.includes(AuthMethod.EMAIL)) { + logger.error( + { authMethods: user.authMethods }, + "Unable to reset password, no email authentication method is configured" + ); throw new BadRequestError({ message: "Unable to reset password, no email authentication method is configured" }); } @@ -211,58 +137,17 @@ export const authPaswordServiceFactory = ({ } } - const newHashedPassword = await crypto.hashing().createHash(newPassword, cfg.SALT_ROUNDS); - - // we need to get the original private key first for v2 - let privateKey: string; - if ( - user.serverEncryptedPrivateKey && - user.serverEncryptedPrivateKeyTag && - user.serverEncryptedPrivateKeyIV && - user.serverEncryptedPrivateKeyEncoding && - user.encryptionVersion === UserEncryption.V2 - ) { - privateKey = crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - iv: user.serverEncryptedPrivateKeyIV, - tag: user.serverEncryptedPrivateKeyTag, - ciphertext: user.serverEncryptedPrivateKey, - keyEncoding: user.serverEncryptedPrivateKeyEncoding as SecretKeyEncoding - }); - } else { + if (user.encryptionVersion !== UserEncryption.V2) { throw new BadRequestError({ message: "Cannot reset password without current credentials or recovery method", name: "Reset password" }); } - const encKeys = await generateUserSrpKeys(user.username, newPassword, { - publicKey: user.publicKey, - privateKey - }); - - const { tag, iv, ciphertext, encoding } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey); + const newHashedPassword = await crypto.hashing().createHash(newPassword, cfg.SALT_ROUNDS); await userDAL.updateUserEncryptionByUserId(userId, { - hashedPassword: newHashedPassword, - - // srp params - salt: encKeys.salt, - verifier: encKeys.verifier, - - protectedKey: encKeys.protectedKey, - protectedKeyIV: encKeys.protectedKeyIV, - protectedKeyTag: encKeys.protectedKeyTag, - encryptedPrivateKey: encKeys.encryptedPrivateKey, - iv: encKeys.encryptedPrivateKeyIV, - tag: encKeys.encryptedPrivateKeyTag, - - serverEncryptedPrivateKey: ciphertext, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyEncoding: encoding + hashedPassword: newHashedPassword }); await tokenService.revokeAllMySessions(userId); @@ -313,66 +198,6 @@ export const authPaswordServiceFactory = ({ }); }; - /* - * backup key creation to give user's their access back when lost their password - * this also needs to do the generateServerPubKey function to be executed first - * then only client proof can be verified - * */ - const createBackupPrivateKey = async ({ - clientProof, - encryptedPrivateKey, - salt, - verifier, - iv, - tag, - userId - }: TCreateBackupPrivateKeyDTO) => { - const userEnc = await userDAL.findUserEncKeyByUserId(userId); - if (!userEnc || (userEnc && !userEnc.isAccepted)) { - throw new Error("Failed to find user"); - } - - if (!userEnc.clientPublicKey || !userEnc.serverPrivateKey) throw new Error("failed to create backup key"); - const isValidClientProff = await srpCheckClientProof( - userEnc.salt, - userEnc.verifier, - userEnc.serverPrivateKey, - userEnc.clientPublicKey, - clientProof - ); - if (!isValidClientProff) throw new Error("failed to create backup key"); - const backup = await authDAL.transaction(async (tx) => { - const backupKey = await authDAL.upsertBackupKey( - userEnc.userId, - { - encryptedPrivateKey, - iv, - tag, - salt, - verifier, - algorithm: SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: SecretKeyEncoding.UTF8 - }, - tx - ); - - await userDAL.updateUserEncryptionByUserId( - userEnc.userId, - { - serverPrivateKey: null, - clientPublicKey: null - }, - tx - ); - return backupKey; - }); - - return backup; - }; - - /* - * Return user back up - * */ const getBackupPrivateKeyOfUser = async (userId: string) => { const user = await userDAL.findUserEncKeyByUserId(userId); if (!user || (user && !user.isAccepted)) { @@ -416,21 +241,7 @@ export const authPaswordServiceFactory = ({ }); }; - const setupPassword = async ( - { - encryptedPrivateKey, - protectedKeyTag, - protectedKey, - protectedKeyIV, - salt, - verifier, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - password, - token - }: TSetupPasswordViaBackupKeyDTO, - actor: OrgServiceActor - ) => { + const setupPassword = async ({ password, token }: TSetupPasswordViaBackupKeyDTO, actor: OrgServiceActor) => { try { await tokenService.validateTokenForUser({ type: TokenType.TOKEN_EMAIL_PASSWORD_SETUP, @@ -466,15 +277,7 @@ export const authPaswordServiceFactory = ({ await userDAL.updateUserEncryptionByUserId( actor.id, { - encryptionVersion: 2, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - salt, - verifier, + encryptionVersion: UserEncryption.V2, hashedPassword, serverPrivateKey: null, clientPublicKey: null @@ -487,12 +290,9 @@ export const authPaswordServiceFactory = ({ }; return { - generateServerPubKey, - changePassword, resetPasswordByBackupKey, sendPasswordResetEmail, verifyPasswordResetEmail, - createBackupPrivateKey, getBackupPrivateKeyOfUser, sendPasswordSetupEmail, setupPassword, diff --git a/backend/src/services/auth/auth-password-type.ts b/backend/src/services/auth/auth-password-type.ts index b3b14c3b4..ceb70d411 100644 --- a/backend/src/services/auth/auth-password-type.ts +++ b/backend/src/services/auth/auth-password-type.ts @@ -1,18 +1,3 @@ -export type TChangePasswordDTO = { - userId: string; - clientProof: string; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; - salt: string; - verifier: string; - tokenVersionId?: string; - password: string; -}; - export enum ResetPasswordV2Type { Recovery = "recovery", LoggedInReset = "logged-in-reset" @@ -39,14 +24,6 @@ export type TResetPasswordViaBackupKeyDTO = { }; export type TSetupPasswordViaBackupKeyDTO = { - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; - salt: string; - verifier: string; password: string; token: string; }; diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 96cf4121a..7bc3a5ef6 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -1,11 +1,10 @@ -import { OrgMembershipStatus, SecretKeyEncoding, TableName } from "@app/db/schemas"; +import { OrgMembershipStatus, TableName } from "@app/db/schemas"; import { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { getMinExpiresIn } from "@app/lib/fn"; import { isDisposableEmail } from "@app/lib/validator"; @@ -41,7 +40,7 @@ type TAuthSignupDep = { | "findUserGroupMembershipsInProject" >; projectKeyDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; projectBotDAL: Pick; groupProjectDAL: Pick; orgService: Pick; @@ -147,17 +146,8 @@ export const authSignupServiceFactory = ({ firstName, lastName, providerAuthToken, - salt, - verifier, - publicKey, - protectedKey, - protectedKeyIV, - protectedKeyTag, organizationName, // attributionSource, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, ip, userAgent, authorization, @@ -191,98 +181,18 @@ export const authSignupServiceFactory = ({ } const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS); - const privateKey = await getUserPrivateKey(password, { - salt, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - encryptionVersion: UserEncryption.V2 - }); - const { tag, encoding, ciphertext, iv } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey); const updateduser = await authDAL.transaction(async (tx) => { const us = await userDAL.updateById(user.id, { firstName, lastName, isAccepted: true }, tx); if (!us) throw new Error("User not found"); - const systemGeneratedUserEncryptionKey = await userDAL.findUserEncKeyByUserId(us.id, tx); - let userEncKey; - // below condition is true means this is system generated credentials - // the private key is actually system generated password - // thus we will re-encrypt the system generated private key with the new password - // akhilmhdh: you may find this like why? The reason is simple we are moving away from e2ee and these are pieces of it - // without a dummy key in place some things will break and backward compatiability too. 2025 we will be removing all these things - if ( - systemGeneratedUserEncryptionKey && - !systemGeneratedUserEncryptionKey.hashedPassword && - systemGeneratedUserEncryptionKey.serverEncryptedPrivateKey && - systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyTag && - systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyIV && - systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyEncoding - ) { - // get server generated password - const serverGeneratedPassword = crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - iv: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyIV, - tag: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyTag, - ciphertext: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKey, - keyEncoding: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyEncoding as SecretKeyEncoding - }); - const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, { - ...systemGeneratedUserEncryptionKey - }); - const encKeys = await generateUserSrpKeys(email, password, { - publicKey: systemGeneratedUserEncryptionKey.publicKey, - privateKey: serverGeneratedPrivateKey - }); - // now reencrypt server generated key with user provided password - userEncKey = await userDAL.upsertUserEncryptionKey( - us.id, - { - encryptionVersion: UserEncryption.V2, - 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, - hashedPassword, - serverEncryptedPrivateKeyEncoding: encoding, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKey: ciphertext - }, - tx - ); - } else { - userEncKey = await userDAL.upsertUserEncryptionKey( - us.id, - { - encryptionVersion: UserEncryption.V2, - salt, - verifier, - publicKey, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - hashedPassword, - serverEncryptedPrivateKeyEncoding: encoding, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKey: ciphertext - }, - tx - ); - } + const userEncKey = await userDAL.upsertUserEncryptionKey( + us.id, + { + encryptionVersion: UserEncryption.V2, + hashedPassword + }, + tx + ); // If it's SAML Auth and the organization ID is present, we should check if the user has a pending invite for this org, and accept it if ( @@ -400,19 +310,10 @@ export const authSignupServiceFactory = ({ const completeAccountInvite = async ({ email, ip, - salt, password, - verifier, firstName, - publicKey, userAgent, lastName, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, authorization }: TCompleteAccountInviteDTO) => { const sanitizedEmail = email.trim().toLowerCase(); @@ -437,94 +338,17 @@ export const authSignupServiceFactory = ({ const appCfg = getConfig(); const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS); - const privateKey = await getUserPrivateKey(password, { - salt, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - encryptionVersion: 2 - }); - const { tag, encoding, ciphertext, iv } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey); const updateduser = await authDAL.transaction(async (tx) => { const us = await userDAL.updateById(user.id, { firstName, lastName, isAccepted: true }, tx); if (!us) throw new Error("User not found"); - const systemGeneratedUserEncryptionKey = await userDAL.findUserEncKeyByUserId(us.id, tx); - let userEncKey; - // this means this is system generated credentials - // now replace the private key - if ( - systemGeneratedUserEncryptionKey && - !systemGeneratedUserEncryptionKey.hashedPassword && - systemGeneratedUserEncryptionKey.serverEncryptedPrivateKey && - systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyTag && - systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyIV && - systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyEncoding - ) { - // get server generated password - const serverGeneratedPassword = crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - iv: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyIV, - tag: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyTag, - ciphertext: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKey, - keyEncoding: systemGeneratedUserEncryptionKey.serverEncryptedPrivateKeyEncoding as SecretKeyEncoding - }); - const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, { - ...systemGeneratedUserEncryptionKey - }); - const encKeys = await generateUserSrpKeys(sanitizedEmail, password, { - publicKey: systemGeneratedUserEncryptionKey.publicKey, - privateKey: serverGeneratedPrivateKey - }); - // now reencrypt server generated key with user provided password - userEncKey = await userDAL.upsertUserEncryptionKey( - us.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, - hashedPassword, - serverEncryptedPrivateKeyEncoding: encoding, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKey: ciphertext - }, - tx - ); - } else { - userEncKey = await userDAL.upsertUserEncryptionKey( - us.id, - { - encryptionVersion: UserEncryption.V2, - salt, - verifier, - publicKey, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - hashedPassword, - serverEncryptedPrivateKeyEncoding: encoding, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKey: ciphertext - }, - tx - ); - } + const userEncKey = await userDAL.upsertUserEncryptionKey( + us.id, + { + encryptionVersion: 2, + hashedPassword + }, + tx + ); const updatedMembersips = await orgDAL.updateMembership( { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited }, diff --git a/backend/src/services/auth/auth-signup-type.ts b/backend/src/services/auth/auth-signup-type.ts index 8bbf302c5..5c4fdc3c9 100644 --- a/backend/src/services/auth/auth-signup-type.ts +++ b/backend/src/services/auth/auth-signup-type.ts @@ -3,15 +3,6 @@ export type TCompleteAccountSignupDTO = { password: string; firstName: string; lastName?: string; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - publicKey: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; - salt: string; - verifier: string; organizationName?: string; providerAuthToken?: string | null; attributionSource?: string | undefined; @@ -26,15 +17,6 @@ export type TCompleteAccountInviteDTO = { password: string; firstName: string; lastName?: string; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - publicKey: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; - salt: string; - verifier: string; ip: string; userAgent: string; authorization: string; diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts index 9f6fbe752..c4703d49f 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts @@ -1,3 +1,4 @@ export enum AcmeDnsProvider { - Route53 = "route53" + Route53 = "route53", + Cloudflare = "cloudflare" } diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index f6e77ac8e..830378ca8 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -1,19 +1,17 @@ -import { ChangeResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53"; import * as x509 from "@peculiar/x509"; import acme from "acme-client"; import { TableName } from "@app/db/schemas"; -import { CustomAWSHasher } from "@app/lib/aws/hashing"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, CryptographyError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; -import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; -import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; -import { TAwsConnection, TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; +import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; +import { TCloudflareConnection } from "@app/services/app-connection/cloudflare/cloudflare-connection-types"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; @@ -39,6 +37,8 @@ import { TCreateAcmeCertificateAuthorityDTO, TUpdateAcmeCertificateAuthorityDTO } from "./acme-certificate-authority-types"; +import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare"; +import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54"; type TAcmeCertificateAuthorityFnsDeps = { appConnectionDAL: Pick; @@ -64,6 +64,8 @@ type DBConfigurationColumn = { directoryUrl: string; accountEmail: string; hostedZoneId: string; + eabKid?: string; + eabHmacKey?: string; }; export const castDbEntryToAcmeCertificateAuthority = ( @@ -89,80 +91,14 @@ export const castDbEntryToAcmeCertificateAuthority = ( hostedZoneId: dbConfigurationCol.hostedZoneId }, directoryUrl: dbConfigurationCol.directoryUrl, - accountEmail: dbConfigurationCol.accountEmail + accountEmail: dbConfigurationCol.accountEmail, + eabKid: dbConfigurationCol.eabKid, + eabHmacKey: dbConfigurationCol.eabHmacKey }, status: ca.status as CaStatus }; }; -export const route53InsertTxtRecord = async ( - connection: TAwsConnectionConfig, - hostedZoneId: string, - domain: string, - value: string -) => { - const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global - const route53Client = new Route53Client({ - sha256: CustomAWSHasher, - useFipsEndpoint: crypto.isFipsModeEnabled(), - credentials: config.credentials!, - region: config.region - }); - - const command = new ChangeResourceRecordSetsCommand({ - HostedZoneId: hostedZoneId, - ChangeBatch: { - Comment: "Set ACME challenge TXT record", - Changes: [ - { - Action: "UPSERT", - ResourceRecordSet: { - Name: domain, - Type: "TXT", - TTL: 30, - ResourceRecords: [{ Value: value }] - } - } - ] - } - }); - - await route53Client.send(command); -}; - -export const route53DeleteTxtRecord = async ( - connection: TAwsConnectionConfig, - hostedZoneId: string, - domain: string, - value: string -) => { - const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global - const route53Client = new Route53Client({ - credentials: config.credentials!, - region: config.region - }); - - const command = new ChangeResourceRecordSetsCommand({ - HostedZoneId: hostedZoneId, - ChangeBatch: { - Comment: "Delete ACME challenge TXT record", - Changes: [ - { - Action: "DELETE", - ResourceRecordSet: { - Name: domain, - Type: "TXT", - TTL: 30, - ResourceRecords: [{ Value: value }] - } - } - ] - } - }); - - await route53Client.send(command); -}; - export const AcmeCertificateAuthorityFns = ({ appConnectionDAL, appConnectionService, @@ -196,7 +132,7 @@ export const AcmeCertificateAuthorityFns = ({ }); } - const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration; + const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig, eabKid, eabHmacKey } = configuration; const appConnection = await appConnectionDAL.findById(dnsAppConnectionId); if (!appConnection) { @@ -209,6 +145,12 @@ export const AcmeCertificateAuthorityFns = ({ }); } + if (dnsProviderConfig.provider === AcmeDnsProvider.Cloudflare && appConnection.app !== AppConnection.Cloudflare) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not a Cloudflare connection` + }); + } + // validates permission to connect await appConnectionService.connectAppConnectionById(appConnection.app as AppConnection, dnsAppConnectionId, actor); @@ -233,7 +175,9 @@ export const AcmeCertificateAuthorityFns = ({ directoryUrl, accountEmail, dnsProvider: dnsProviderConfig.provider, - hostedZoneId: dnsProviderConfig.hostedZoneId + hostedZoneId: dnsProviderConfig.hostedZoneId, + eabKid, + eabHmacKey } }, tx @@ -276,7 +220,7 @@ export const AcmeCertificateAuthorityFns = ({ }) => { const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { if (configuration) { - const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration; + const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig, eabKid, eabHmacKey } = configuration; const appConnection = await appConnectionDAL.findById(dnsAppConnectionId); if (!appConnection) { @@ -289,6 +233,15 @@ export const AcmeCertificateAuthorityFns = ({ }); } + if ( + dnsProviderConfig.provider === AcmeDnsProvider.Cloudflare && + appConnection.app !== AppConnection.Cloudflare + ) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not a Cloudflare connection` + }); + } + // validates permission to connect await appConnectionService.connectAppConnectionById( appConnection.app as AppConnection, @@ -307,7 +260,9 @@ export const AcmeCertificateAuthorityFns = ({ directoryUrl, accountEmail, dnsProvider: dnsProviderConfig.provider, - hostedZoneId: dnsProviderConfig.hostedZoneId + hostedZoneId: dnsProviderConfig.hostedZoneId, + eabKid, + eabHmacKey } }, tx @@ -407,10 +362,19 @@ export const AcmeCertificateAuthorityFns = ({ await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl); - const acmeClient = new acme.Client({ + const acmeClientOptions: acme.ClientOptions = { directoryUrl: acmeCa.configuration.directoryUrl, accountKey - }); + }; + + if (acmeCa.configuration.eabKid && acmeCa.configuration.eabHmacKey) { + acmeClientOptions.externalAccountBinding = { + kid: acmeCa.configuration.eabKid, + hmacKey: acmeCa.configuration.eabHmacKey + }; + } + + const acmeClient = new acme.Client(acmeClientOptions); const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); @@ -443,26 +407,56 @@ export const AcmeCertificateAuthorityFns = ({ const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" const recordValue = `"${keyAuthorization}"`; // must be double quoted - if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { - await route53InsertTxtRecord( - connection as TAwsConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); + switch (acmeCa.configuration.dnsProviderConfig.provider) { + case AcmeDnsProvider.Route53: { + await route53InsertTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + case AcmeDnsProvider.Cloudflare: { + await cloudflareInsertTxtRecord( + connection as TCloudflareConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + default: { + throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); + } } }, challengeRemoveFn: async (authz, challenge, keyAuthorization) => { const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" const recordValue = `"${keyAuthorization}"`; // must be double quoted - if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { - await route53DeleteTxtRecord( - connection as TAwsConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); + switch (acmeCa.configuration.dnsProviderConfig.provider) { + case AcmeDnsProvider.Route53: { + await route53DeleteTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + case AcmeDnsProvider.Cloudflare: { + await cloudflareDeleteTxtRecord( + connection as TCloudflareConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + default: { + throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); + } } } }); diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts index 56b3118cf..70b0cb0e1 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts @@ -18,7 +18,9 @@ export const AcmeCertificateAuthorityConfigurationSchema = z.object({ hostedZoneId: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.hostedZoneId) }), directoryUrl: z.string().url().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.directoryUrl), - accountEmail: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.accountEmail) + accountEmail: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.accountEmail), + eabKid: z.string().trim().max(64).optional().describe(CertificateAuthorities.CONFIGURATIONS.ACME.eabKid), + eabHmacKey: z.string().trim().max(512).optional().describe(CertificateAuthorities.CONFIGURATIONS.ACME.eabHmacKey) }); export const AcmeCertificateAuthorityCredentialsSchema = z.object({ diff --git a/backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts b/backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts new file mode 100644 index 000000000..f4b12e657 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts @@ -0,0 +1,109 @@ +import axios from "axios"; + +import { request } from "@app/lib/config/request"; +import { TCloudflareConnectionConfig } from "@app/services/app-connection/cloudflare/cloudflare-connection-types"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +export const cloudflareInsertTxtRecord = async ( + connection: TCloudflareConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiToken } + } = connection; + + try { + await request.post( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones/${encodeURIComponent(hostedZoneId)}/dns_records`, + { + type: "TXT", + name: domain, + content: value, + ttl: 60, + proxied: false + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } catch (error) { + if (axios.isAxiosError(error)) { + const firstErrorMessage = ( + error.response?.data as { + errors?: { message: string }[]; + } + )?.errors?.[0]?.message; + if (firstErrorMessage) { + throw new Error(firstErrorMessage); + } + } + throw error; + } +}; + +export const cloudflareDeleteTxtRecord = async ( + connection: TCloudflareConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiToken } + } = connection; + + try { + const listRecordsResponse = await request.get<{ + result: { id: string; type: string; name: string; content: string }[]; + }>(`${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones/${encodeURIComponent(hostedZoneId)}/dns_records`, { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json" + }, + params: { + type: "TXT", + name: domain, + content: value + } + }); + + const dnsRecords = listRecordsResponse.data?.result; + + if (Array.isArray(dnsRecords) && dnsRecords.length > 0) { + const recordToDelete = dnsRecords.find( + (record) => record.type === "TXT" && record.name === domain && record.content === value + ); + + if (recordToDelete) { + await request.delete( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones/${encodeURIComponent(hostedZoneId)}/dns_records/${recordToDelete.id}`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } + } + } catch (error) { + if (axios.isAxiosError(error)) { + const firstErrorMessage = ( + error.response?.data as { + errors?: { message: string }[]; + } + )?.errors?.[0]?.message; + if (firstErrorMessage) { + throw new Error(firstErrorMessage); + } + } + throw error; + } +}; diff --git a/backend/src/services/certificate-authority/acme/dns-providers/route54.ts b/backend/src/services/certificate-authority/acme/dns-providers/route54.ts new file mode 100644 index 000000000..4d235a6e0 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/dns-providers/route54.ts @@ -0,0 +1,75 @@ +import { ChangeResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53"; + +import { CustomAWSHasher } from "@app/lib/aws/hashing"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; +import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; + +export const route53InsertTxtRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global + const route53Client = new Route53Client({ + sha256: CustomAWSHasher, + useFipsEndpoint: crypto.isFipsModeEnabled(), + credentials: config.credentials!, + region: config.region + }); + + const command = new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + ChangeBatch: { + Comment: "Set ACME challenge TXT record", + Changes: [ + { + Action: "UPSERT", + ResourceRecordSet: { + Name: domain, + Type: "TXT", + TTL: 30, + ResourceRecords: [{ Value: value }] + } + } + ] + } + }); + + await route53Client.send(command); +}; + +export const route53DeleteTxtRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global + const route53Client = new Route53Client({ + credentials: config.credentials!, + region: config.region + }); + + const command = new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + ChangeBatch: { + Comment: "Delete ACME challenge TXT record", + Changes: [ + { + Action: "DELETE", + ResourceRecordSet: { + Name: domain, + Type: "TXT", + TTL: 30, + ResourceRecords: [{ Value: value }] + } + } + ] + } + }); + + await route53Client.send(command); +}; diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts new file mode 100644 index 000000000..0e2619a27 --- /dev/null +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts @@ -0,0 +1,1172 @@ +/* eslint-disable no-await-in-loop */ +import * as x509 from "@peculiar/x509"; +import RE2 from "re2"; + +import { TableName } from "@app/db/schemas"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { + createAdcsHttpClient, + getAzureADCSConnectionCredentials +} from "@app/services/app-connection/azure-adcs/azure-adcs-connection-fns"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; +import { + CertExtendedKeyUsage, + CertKeyAlgorithm, + CertKeyUsage, + CertStatus +} from "@app/services/certificate/certificate-types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; +import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal"; +import { CaStatus, CaType } from "../certificate-authority-enums"; +import { keyAlgorithmToAlgCfg } from "../certificate-authority-fns"; +import { TExternalCertificateAuthorityDALFactory } from "../external-certificate-authority-dal"; +import { + TAzureAdCsCertificateAuthority, + TCreateAzureAdCsCertificateAuthorityDTO, + TUpdateAzureAdCsCertificateAuthorityDTO +} from "./azure-ad-cs-certificate-authority-types"; + +type TAzureAdCsCertificateAuthorityFnsDeps = { + appConnectionDAL: Pick; + appConnectionService: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" + >; + externalCertificateAuthorityDAL: Pick; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" + >; + pkiSubscriberDAL: Pick; + projectDAL: Pick; +}; + +type AzureCertificateRequest = { + csr: string; + template: string; + attributes?: Record; +}; + +type AzureCertificateResponse = { + certificateId: string; + certificate: string; + certificateChain?: string; + status: "issued" | "pending" | "denied"; + disposition?: string; +}; + +const buildSubjectDN = (commonName: string, properties?: TPkiSubscriberProperties): string => { + // Validate and sanitize common name - it's required and cannot be empty + if (!commonName || !commonName.trim()) { + throw new BadRequestError({ message: "Common Name is required and cannot be empty" }); + } + + const trimmedCN = commonName.trim(); + + const invalidCharsRegex = new RE2("[,=+<>#;\\\\\\]]"); + if (invalidCharsRegex.test(trimmedCN)) { + throw new BadRequestError({ + message: "Common Name contains invalid characters: , = + < > # ; \\ ]" + }); + } + + let subject = `CN=${trimmedCN}`; + + const validateComponent = (value: string | undefined, componentName: string): string | null => { + if (!value || typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + const componentInvalidCharsRegex = new RE2('[,=+<>#;\\\\"\\/\\r\\n\\t]'); + if (componentInvalidCharsRegex.test(trimmed)) { + throw new BadRequestError({ + message: `${componentName} contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t` + }); + } + + const problematicCharsRegex = new RE2("^[\\\\s\\\\-_.]+|[\\\\s\\\\-_.]+$"); + if (problematicCharsRegex.test(trimmed)) { + throw new BadRequestError({ + message: `${componentName} cannot start or end with spaces, hyphens, underscores, or periods` + }); + } + + return trimmed; + }; + + const emailAddress = validateComponent(properties?.emailAddress, "Email Address"); + if (emailAddress) { + const emailRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/); + if (!emailRegex.test(emailAddress) || emailAddress.length <= 5 || emailAddress.length >= 64) { + throw new BadRequestError({ + message: "Email Address must be a valid email format between 5 and 64 characters" + }); + } + subject += `,E=${emailAddress}`; + } + + const organizationalUnit = validateComponent(properties?.organizationalUnit, "Organizational Unit"); + if (organizationalUnit) { + if (organizationalUnit.length > 64) { + throw new BadRequestError({ + message: "Organizational Unit cannot exceed 64 characters" + }); + } + subject += `,OU=${organizationalUnit}`; + } + + const organization = validateComponent(properties?.organization, "Organization"); + if (organization) { + if (organization.length > 64) { + throw new BadRequestError({ + message: "Organization cannot exceed 64 characters" + }); + } + subject += `,O=${organization}`; + } + + const locality = validateComponent(properties?.locality, "Locality"); + if (locality) { + if (locality.length > 64) { + throw new BadRequestError({ + message: "Locality cannot exceed 64 characters" + }); + } + subject += `,L=${locality}`; + } + + const state = validateComponent(properties?.state, "State"); + if (state) { + if (state.length > 64) { + throw new BadRequestError({ + message: "State cannot exceed 64 characters" + }); + } + subject += `,ST=${state}`; + } + + const country = validateComponent(properties?.country, "Country"); + if (country) { + // Country code must be exactly 2 uppercase letters + const countryCode = country.toUpperCase(); + const invalidCountryRegex = new RE2("[^A-Z]"); + if (invalidCountryRegex.test(countryCode)) { + throw new BadRequestError({ + message: "Country must contain only uppercase letters" + }); + } + if (countryCode.length !== 2) { + throw new BadRequestError({ + message: "Country must be exactly 2 characters" + }); + } + subject += `,C=${countryCode}`; + } + + return subject; +}; + +export const castDbEntryToAzureAdCsCertificateAuthority = ( + ca: Awaited> +): TAzureAdCsCertificateAuthority & { credentials: unknown } => { + if (!ca.externalCa?.id) { + throw new BadRequestError({ message: "Malformed Azure AD Certificate Service certificate authority" }); + } + + if (!ca.externalCa.dnsAppConnectionId) { + throw new BadRequestError({ + message: "Azure ADCS connection ID is missing from certificate authority configuration" + }); + } + + return { + id: ca.id, + type: CaType.AZURE_AD_CS, + enableDirectIssuance: ca.enableDirectIssuance, + name: ca.name, + projectId: ca.projectId, + credentials: ca.externalCa.credentials, + configuration: { + azureAdcsConnectionId: ca.externalCa.dnsAppConnectionId + }, + status: ca.status as CaStatus + }; +}; + +const submitCertificateRequest = async ( + credentials: { username: string; password: string; sslRejectUnauthorized?: boolean; sslCertificate?: string }, + caServiceUrl: string, + certificateRequest: AzureCertificateRequest +): Promise => { + try { + const adcsClient = createAdcsHttpClient( + credentials.username, + credentials.password, + caServiceUrl, + credentials.sslRejectUnauthorized ?? true, + credentials.sslCertificate + ); + + // Clean CSR by removing headers and newlines for ADCS submission + const cleanCsr = certificateRequest.csr + .replace(new RE2("-----BEGIN CERTIFICATE REQUEST-----", "g"), "") + .replace(new RE2("-----END CERTIFICATE REQUEST-----", "g"), "") + .replace(new RE2("\\\\r?\\\\n", "g"), ""); + + // Build certificate attributes including template and validity period + const certAttribParts: string[] = []; + + // Add template - this is required + if (certificateRequest.template && certificateRequest.template.trim()) { + certAttribParts.push(`CertificateTemplate:${certificateRequest.template.trim()}`); + } + + // Add validity period if specified by the user + if (certificateRequest.attributes?.validityPeriod) { + try { + const ttlMs = ms(certificateRequest.attributes.validityPeriod); + const expirationDate = new Date(Date.now() + ttlMs); + + // Format expiration date in RFC 2616 format for ADCS + const rfc2616Date = expirationDate.toUTCString(); + + // Add ExpirationDate attribute (requires EDITF_ATTRIBUTEENDDATE flag on CA) + certAttribParts.push(`ExpirationDate:${rfc2616Date}`); + } catch (error) { + throw new BadRequestError({ + message: "Invalid validity period format" + }); + } + } + + // Join all attributes with proper CRLF ending + const certAttrib = certAttribParts.length > 0 ? `${certAttribParts.join("\r\n")}\r\n` : ""; + + // Prepare form data for ADCS web interface - these parameters are required by Microsoft ADCS + // Mode: "newreq" indicates a new certificate request + // CertRequest: the base64-encoded CSR without headers + // CertAttrib: certificate template and other attributes in CRLF format + // FriendlyType: display name for the certificate type + // TargetStoreFlags: certificate store flags (0 = default) + // SaveCert: "yes" to save the certificate to the server + const formData = new URLSearchParams({ + Mode: "newreq", + CertRequest: cleanCsr, + CertAttrib: certAttrib, + FriendlyType: "Saved-Request Certificate", + TargetStoreFlags: "0", + SaveCert: "yes" + }); + + const response = await adcsClient.post("/certsrv/certfnsh.asp", formData.toString()); + + const responseText = response.data; + + // Parse the HTML response to extract certificate information + let requestId: string | undefined; + let status: "issued" | "pending" | "denied" = "pending"; + let certificate = ""; + + // Look for request ID in various formats + const requestIdMatches = [ + new RE2("reqid[=:](\\d+)", "i"), + new RE2("request\\s+id[:\\s]+(\\d+)", "i"), + new RE2("certificate\\s+request\\s+(\\d+)", "i"), + new RE2("\\breqid=(\\d+)\\b", "i"), + new RE2("requestid[:\\s]*(\\d+)", "i") + ]; + + for (const regex of requestIdMatches) { + const match = responseText.match(regex); + if (match) { + [, requestId] = match; + break; + } + } + + // Check for immediate certificate issuance + const certMatch = responseText.match(new RE2("-----BEGIN CERTIFICATE-----[\\s\\S]*?-----END CERTIFICATE-----")); + if (certMatch) { + // Clean up the certificate format + certificate = certMatch[0].replace(new RE2("\\\\r\\\\n", "g"), "\n").replace(new RE2("\\\\r", "g"), "\n").trim(); + + // Validate the certificate format before using it + try { + status = "issued"; + } catch (error) { + certificate = ""; + status = "pending"; + } + } + + // Check disposition message for status + if (responseText.includes("taken under submission") || responseText.includes("pending")) { + status = "pending"; + } else if (responseText.includes("denied") || responseText.includes("rejected")) { + status = "denied"; + } else if (responseText.includes("issued") || certificate) { + status = "issued"; + } + + // If we couldn't parse a request ID and don't have a certificate, something went wrong + if (!requestId && !certificate) { + // Check for specific error types first + let errorMessage = "Unknown error occurred"; + + // ASN.1 parsing errors (CSR format issues) + if ( + responseText.includes("ASN1") || + responseText.includes("Error Parsing Request") || + responseText.includes("unexpected end of data") || + responseText.includes("bad tag value met") || + responseText.includes("0x80093102") + ) { + const asn1Patterns = [ + new RE2("Error Parsing Request\\s+ASN1[^.]*\\.?", "i"), + new RE2("ASN1[^.]*\\.?", "i"), + new RE2("Error Parsing Request[^.]*\\.?", "i") + ]; + + for (const pattern of asn1Patterns) { + const match = responseText.match(pattern); + if (match) { + errorMessage = match[0].trim(); + break; + } + } + + errorMessage = `Certificate request format error: ${errorMessage}. This indicates the CSR (Certificate Signing Request) format is incompatible with ADCS.`; + } + // Template permission errors (policy denials) + else if ( + responseText.includes("Denied by Policy Module") || + responseText.includes("0x80094800") || + responseText.includes("template that is not supported") + ) { + const policyMatch = + responseText.match(new RE2('Denied by Policy Module[^"]*"([^"]*)')) || + responseText.match(new RE2('The disposition message is "([^"]*)')); + + if (policyMatch) { + errorMessage = policyMatch[1].trim(); + } + + errorMessage = `Certificate template permission error: ${errorMessage}. Verify that the connection account has enrollment permissions for the selected certificate template.`; + } + // General error extraction + else { + const errorPatterns = [ + new RE2('The disposition message is "([^"]*)"', "i"), + new RE2('Denied by Policy Module[^"]*"([^"]*)"', "i"), + new RE2("]*class[^>]*error[^>]*>(.*?)<\\/p>", "i"), + new RE2("]*class[^>]*error[^>]*>(.*?)<\\/div>", "i"), + new RE2("]*class[^>]*error[^>]*>(.*?)<\\/span>", "i"), + new RE2("error[^<]*:([^<]*)", "i"), + new RE2("denied[^<]*:([^<]*)", "i"), + new RE2("The\\s+request\\s+contains\\s+no\\s+certificate\\s+template\\s+information", "i"), + new RE2("The\\s+template\\s+is\\s+missing", "i") + ]; + + // Try each pattern to find the error message + for (const pattern of errorPatterns) { + const match = responseText.match(pattern); + if (match) { + errorMessage = match[1] ? match[1].trim() : match[0].trim(); + break; + } + } + } + + // Clean up HTML entities and tags from error message + errorMessage = errorMessage + .replace(new RE2(""", "g"), '"') + .replace(new RE2("<", "g"), "<") + .replace(new RE2(">", "g"), ">") + .replace(new RE2("&", "g"), "&") + .replace(new RE2("<[^>]*>", "g"), "") // Remove HTML tags + .replace(new RE2("\\r\\n", "g"), " ") + .replace(new RE2("\\n", "g"), " ") + .replace(new RE2("\\r", "g"), " ") + .replace(new RE2("\\s+", "g"), " ") + .replace(new RE2('\\s*[".]*\\s*[".]*\\s*$'), "") // Remove trailing quotes and periods + .replace(new RE2('^\\s*[".]*\\s*'), "") // Remove leading quotes and periods + .trim(); + + // Handle specific Microsoft ADCS OID-related errors + if ( + responseText.includes("Cannot get OID for name type") || + responseText.includes("OID for name type") || + responseText.includes("name type ''") + ) { + errorMessage = + "Certificate template OID resolution error. This may be caused by: " + + "1) Certificate template name doesn't exist or is not published, " + + "2) ADCS OID cache needs refresh, or " + + "3) Template permissions are insufficient. " + + "Please verify the template exists, is published, and you have enrollment permissions."; + } else if (responseText.includes("template") && responseText.includes("not found")) { + errorMessage = `Certificate template not found. Please verify the template name '${certificateRequest.template}' exists and is published on the ADCS server.`; + } else if (responseText.includes("access denied") || responseText.includes("permission")) { + errorMessage = "Access denied. You may not have permission to request certificates with this template."; + } else if (responseText.includes("subject") || responseText.includes("DN")) { + errorMessage = + "Invalid subject DN format. Please check that all subject field values contain only valid characters."; + } else if (responseText.includes("Computer") && responseText.includes("Machine")) { + errorMessage = + "Computer/Machine template error. These templates may require domain-joined machines or specific subject name formats."; + } else if ( + certificateRequest.template.toLowerCase().includes("computer") || + certificateRequest.template.toLowerCase().includes("machine") + ) { + errorMessage = + `Template '${certificateRequest.template}' failed because it requires domain authentication and automatic enrollment. ` + + `Computer/Machine templates are designed for domain-joined computers, not manual requests. ` + + `Solutions: 1) Use 'User' or 'WebServer' templates instead, 2) Create a custom template based on ${certificateRequest.template} but configured for manual enrollment, ` + + `3) Ask your ADCS administrator to modify the template to allow manual enrollment and 'Supply subject in request'.`; + } else if (errorMessage.length < 10 || errorMessage === certificateRequest.template) { + errorMessage = `Certificate request failed with template '${certificateRequest.template}'. This may indicate a template configuration issue, permission problem, or invalid subject information.`; + } + + throw new BadRequestError({ + message: `Certificate request failed: ${errorMessage}` + }); + } + + return { + certificateId: requestId || "immediate", + certificate, + certificateChain: "", + status + }; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + if (error instanceof Error) { + let errorMessage = `Failed to submit certificate request to ADCS: ${error.message}`; + + if (error.message.includes("401") || error.message.includes("Unauthorized")) { + errorMessage = "Authentication failed. Please verify your username and password are correct."; + } else if (error.message.includes("403") || error.message.includes("Forbidden")) { + errorMessage = "Access denied. You may not have permission to request certificates with this template."; + } else if (error.message.includes("404") || error.message.includes("Not Found")) { + errorMessage = "ADCS endpoint not found. Please verify the ADCS URL is correct."; + } else if (error.message.includes("ENOTFOUND")) { + errorMessage = "Cannot connect to ADCS server. Please verify the server URL and network connectivity."; + } else if (error.message.includes("ETIMEDOUT")) { + errorMessage = "Request timed out. The ADCS server may be overloaded or unreachable."; + } + + throw new BadRequestError({ message: errorMessage }); + } + + throw new BadRequestError({ + message: `Failed to submit certificate request to ADCS: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } +}; + +const retrieveCertificate = async ( + credentials: { username: string; password: string; sslRejectUnauthorized?: boolean; sslCertificate?: string }, + caServiceUrl: string, + certificateId: string +): Promise => { + try { + const adcsClient = createAdcsHttpClient( + credentials.username, + credentials.password, + caServiceUrl, + credentials.sslRejectUnauthorized ?? true, + credentials.sslCertificate + ); + + const response = await adcsClient.get(`/certsrv/certnew.cer?ReqID=${certificateId}&Enc=b64`, { + Accept: "application/pkix-cert,application/x-x509-ca-cert,application/octet-stream,*/*" + }); + + const certData = response.data; + + // Check if the response contains HTML indicating the certificate is not ready + if (certData.includes("") || certData.includes("taken under submission") || certData.includes("pending")) { + throw new BadRequestError({ + message: `Certificate with ID ${certificateId} is still pending approval or processing` + }); + } + + // If certificate is already in PEM format, return as-is + if (certData.includes("-----BEGIN CERTIFICATE-----")) { + return certData.trim(); + } + + // Handle base64-encoded certificate data + let cleanCertData = certData.trim(); + + // Remove any HTML artifacts or unwanted characters, keeping only base64 + cleanCertData = cleanCertData.replace(new RE2("[^A-Za-z0-9+/=\\s]", "g"), "").replace(new RE2("\\s", "g"), ""); + + if (cleanCertData.length < 100) { + throw new BadRequestError({ + message: `Certificate data appears invalid or too short (${cleanCertData.length} characters). The certificate may still be pending.` + }); + } + + // Format as proper PEM certificate with 64 character lines + const formattedCert = cleanCertData.replace(new RE2("(.{64})", "g"), "$1\n").trim(); + const pemCert = `-----BEGIN CERTIFICATE-----\n${formattedCert}\n-----END CERTIFICATE-----`; + + // Validate the constructed PEM before returning + try { + // Test parse to ensure it's valid + const testCert = new x509.X509Certificate(pemCert); + // If we get here, the certificate is valid + if (testCert) { + return pemCert; + } + throw new Error("Certificate validation failed"); + } catch (error) { + throw new BadRequestError({ + message: `Failed to format certificate data from ADCS into valid PEM format: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + if (error instanceof Error) { + let errorMessage = `Failed to retrieve certificate with ID ${certificateId} from ADCS: ${error.message}`; + + if (error.message.includes("404") || error.message.includes("Not Found")) { + errorMessage = `Certificate with ID ${certificateId} not found. It may have been rejected or the ID is invalid.`; + } else if (error.message.includes("401") || error.message.includes("Unauthorized")) { + errorMessage = "Authentication failed while retrieving certificate. Please verify your credentials."; + } else if (error.message.includes("403") || error.message.includes("Forbidden")) { + errorMessage = + "Access denied while retrieving certificate. You may not have permission to access this certificate."; + } else if (error.message.includes("ETIMEDOUT")) { + errorMessage = "Timeout while retrieving certificate. The ADCS server may be overloaded."; + } + + throw new BadRequestError({ message: errorMessage }); + } + + throw new BadRequestError({ + message: `Failed to retrieve certificate with ID ${certificateId} from ADCS: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } +}; + +export const AzureAdCsCertificateAuthorityFns = ({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + pkiSubscriberDAL +}: TAzureAdCsCertificateAuthorityFnsDeps) => { + const createCertificateAuthority = async ({ + name, + projectId, + configuration, + enableDirectIssuance, + actor, + status + }: { + status: CaStatus; + name: string; + projectId: string; + configuration: TCreateAzureAdCsCertificateAuthorityDTO["configuration"]; + enableDirectIssuance: boolean; + actor: OrgServiceActor; + }) => { + // Azure ADCS does not support direct issuance - enforce this restriction + if (enableDirectIssuance) { + throw new BadRequestError({ + message: "Azure ADCS Certificate Authorities do not support direct issuance" + }); + } + + const { azureAdcsConnectionId } = configuration; + const appConnection = await appConnectionDAL.findById(azureAdcsConnectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${azureAdcsConnectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureADCS) { + throw new BadRequestError({ + message: `App connection with ID '${azureAdcsConnectionId}' is not an Azure ADCS connection` + }); + } + + await appConnectionService.connectAppConnectionById( + appConnection.app as AppConnection, + azureAdcsConnectionId, + actor + ); + + const caEntity = await certificateAuthorityDAL.transaction(async (tx) => { + try { + const ca = await certificateAuthorityDAL.create( + { + projectId, + enableDirectIssuance: false, // Always false for Azure ADCS CAs + name, + status + }, + tx + ); + + await externalCertificateAuthorityDAL.create( + { + caId: ca.id, + dnsAppConnectionId: azureAdcsConnectionId, + type: CaType.AZURE_AD_CS, + configuration: {} + }, + tx + ); + + return await certificateAuthorityDAL.findByIdWithAssociatedCa(ca.id, tx); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + if ((error as any)?.error?.code === "23505") { + throw new BadRequestError({ + message: "Certificate authority with the same name already exists in your project" + }); + } + throw error; + } + }); + + if (!caEntity.externalCa?.id) { + throw new BadRequestError({ message: "Failed to create external certificate authority" }); + } + + return castDbEntryToAzureAdCsCertificateAuthority(caEntity); + }; + + const updateCertificateAuthority = async ({ + id, + status, + configuration, + enableDirectIssuance, + actor, + name + }: { + id: string; + status?: CaStatus; + configuration: TUpdateAzureAdCsCertificateAuthorityDTO["configuration"]; + enableDirectIssuance?: boolean; + actor: OrgServiceActor; + name?: string; + }) => { + // Azure ADCS does not support direct issuance - enforce this restriction + if (enableDirectIssuance) { + throw new BadRequestError({ + message: "Azure ADCS Certificate Authorities do not support direct issuance" + }); + } + + const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { + if (configuration) { + const { azureAdcsConnectionId } = configuration; + const appConnection = await appConnectionDAL.findById(azureAdcsConnectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${azureAdcsConnectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureADCS) { + throw new BadRequestError({ + message: `App connection with ID '${azureAdcsConnectionId}' is not an Azure ADCS connection` + }); + } + + await appConnectionService.connectAppConnectionById( + appConnection.app as AppConnection, + azureAdcsConnectionId, + actor + ); + + await externalCertificateAuthorityDAL.update( + { + caId: id, + type: CaType.AZURE_AD_CS + }, + { + appConnectionId: azureAdcsConnectionId, + configuration: {} + }, + tx + ); + } + + if (name || status || enableDirectIssuance !== undefined) { + await certificateAuthorityDAL.updateById( + id, + { + name, + status, + enableDirectIssuance: false // Always false for Azure ADCS CAs + }, + tx + ); + } + + return certificateAuthorityDAL.findByIdWithAssociatedCa(id, tx); + }); + + if (!updatedCa.externalCa?.id) { + throw new BadRequestError({ message: "Failed to update external certificate authority" }); + } + + return castDbEntryToAzureAdCsCertificateAuthority(updatedCa); + }; + + const listCertificateAuthorities = async ({ projectId }: { projectId: string }) => { + const cas = await certificateAuthorityDAL.findWithAssociatedCa({ + [`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId, + [`${TableName.ExternalCertificateAuthority}.type` as "type"]: CaType.AZURE_AD_CS + }); + + return cas.map(castDbEntryToAzureAdCsCertificateAuthority); + }; + + const orderSubscriberCertificate = async (subscriberId: string) => { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber.caId) { + throw new BadRequestError({ message: "Subscriber does not have a CA" }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); + if (!ca.externalCa || ca.externalCa.type !== CaType.AZURE_AD_CS) { + throw new BadRequestError({ message: "CA is not an Azure AD Certificate Service CA" }); + } + + const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca); + if (azureCa.status !== CaStatus.ACTIVE) { + throw new BadRequestError({ message: "CA is disabled" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + // Get credentials from the Azure ADCS connection + const { username, password, adcsUrl, sslRejectUnauthorized, sslCertificate } = + await getAzureADCSConnectionCredentials( + azureCa.configuration.azureAdcsConnectionId, + appConnectionDAL, + kmsService + ); + + const credentials: { + username: string; + password: string; + sslRejectUnauthorized?: boolean; + sslCertificate?: string; + } = { + username, + password, + sslRejectUnauthorized, + sslCertificate + }; + + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const subjectDN = buildSubjectDN( + subscriber.commonName, + subscriber.properties as TPkiSubscriberProperties | undefined + ); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: subjectDN, + keys: leafKeys, + signingAlgorithm: alg + }); + + const csrPem = csrObj.toString("pem"); + + const properties = subscriber.properties as TPkiSubscriberProperties | undefined; + const azureTemplateType = properties?.azureTemplateType; + if (!azureTemplateType || typeof azureTemplateType !== "string") { + throw new BadRequestError({ + message: "Subscriber must have an Azure certificate template configured for Azure ADCS CA" + }); + } + + const templateInput = azureTemplateType.trim(); + if (!templateInput || templateInput.length === 0) { + throw new BadRequestError({ + message: "Certificate template name cannot be empty" + }); + } + + const templateValue = templateInput; + + const certificateRequest: AzureCertificateRequest = { + csr: csrPem, + template: templateValue, + attributes: { + subject: buildSubjectDN(subscriber.commonName, subscriber.properties as TPkiSubscriberProperties | undefined), + subjectAlternativeName: subscriber.subjectAlternativeNames.join(","), + ...(subscriber.ttl && { validityPeriod: subscriber.ttl }) + } + }; + + // Add retry logic for OID caching issues + let submissionResponse; + const maxOidRetries = 3; + let oidRetryCount = 0; + + while (oidRetryCount <= maxOidRetries) { + try { + submissionResponse = await submitCertificateRequest(credentials, adcsUrl, certificateRequest); + break; // Success, exit retry loop + } catch (error) { + const isOidError = + error instanceof BadRequestError && + (error.message.includes("OID resolution error") || error.message.includes("Cannot get OID for name type")); + + if (isOidError && oidRetryCount < maxOidRetries) { + oidRetryCount += 1; + + // Wait before retry with increasing delays: 3s, 6s, 9s + const delay = 3000 * oidRetryCount; + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + // eslint-disable-next-line no-continue + continue; + } + + // If not an OID error or we've exhausted retries, re-throw the error + throw error; + } + } + + if (!submissionResponse) { + throw new BadRequestError({ + message: "Failed to submit certificate request after multiple attempts due to OID resolution issues" + }); + } + + // Handle both "issued" and "pending" status - ADCS may auto-approve or require manual approval + if (submissionResponse.status === "denied") { + throw new BadRequestError({ message: "Certificate request was denied by ADCS" }); + } + + let certificatePem = ""; + + if (submissionResponse.status === "issued" && submissionResponse.certificate) { + certificatePem = submissionResponse.certificate; + } else { + // For pending certificates, implement a retry mechanism with exponential backoff + const maxRetries = 5; + const initialDelay = 2000; // 2 seconds + let retryCount = 0; + let lastError: Error | null = null; + + // eslint-disable-next-line no-await-in-loop + while (retryCount < maxRetries) { + try { + // eslint-disable-next-line no-await-in-loop + certificatePem = await retrieveCertificate(credentials, adcsUrl, submissionResponse.certificateId); + break; // Success, exit retry loop + } catch (error) { + lastError = error as Error; + // eslint-disable-next-line no-plusplus + retryCount++; + + if (retryCount < maxRetries) { + // Wait with exponential backoff: 2s, 4s, 8s, 16s, 32s + const delay = initialDelay * 2 ** (retryCount - 1); + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + } + } + } + + if (retryCount === maxRetries) { + throw new BadRequestError({ + message: `Certificate request submitted with ID ${submissionResponse.certificateId} but failed to retrieve after ${maxRetries} attempts. The certificate may still be pending approval or processing. Last error: ${lastError?.message || "Unknown error"}.` + }); + } + } + + // Ensure we have a valid certificate before proceeding + if (!certificatePem) { + throw new BadRequestError({ + message: "Failed to obtain certificate from ADCS. The certificate may still be pending processing." + }); + } + + // Clean and validate the certificate PEM format + let cleanedCertificatePem = certificatePem.trim(); + + // Ensure proper PEM format + if (!cleanedCertificatePem.includes("-----BEGIN CERTIFICATE-----")) { + throw new BadRequestError({ + message: "Invalid certificate format received from ADCS. Expected PEM format." + }); + } + + // Remove any extra whitespace and ensure proper line endings + cleanedCertificatePem = cleanedCertificatePem + .replace(new RE2("\\r\\n", "g"), "\n") + .replace(new RE2("\\r", "g"), "\n") + .trim(); + + // Validate that we have both begin and end markers + if (!cleanedCertificatePem.includes("-----END CERTIFICATE-----")) { + throw new BadRequestError({ + message: "Invalid certificate format received from ADCS. Missing end marker." + }); + } + + let certObj: x509.X509Certificate; + try { + certObj = new x509.X509Certificate(cleanedCertificatePem); + } catch (error) { + throw new BadRequestError({ + message: `Failed to parse certificate from ADCS: ${error instanceof Error ? error.message : "Unknown error"}. Certificate data may be corrupted.` + }); + } + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const certificateChainPem = submissionResponse.certificateChain || ""; + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(skLeaf) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + pkiSubscriberId: subscriber.id, + status: CertStatus.ACTIVE, + friendlyName: subscriber.commonName, + commonName: subscriber.commonName, + altNames: subscriber.subjectAlternativeNames.join(","), + serialNumber: certObj.serialNumber, + notBefore: certObj.notBefore, + notAfter: certObj.notAfter, + keyUsages: subscriber.keyUsages as CertKeyUsage[], + extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[], + projectId: ca.projectId + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + tx + ); + }); + + return { + certificate: certificatePem, + certificateChain: certificateChainPem, + privateKey: skLeaf, + serialNumber: certObj.serialNumber, + ca: azureCa, + subscriber + }; + }; + + const getTemplates = async ({ caId, projectId }: { caId: string; projectId: string }) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca || ca.projectId !== projectId) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca); + const { azureAdcsConnectionId } = azureCa.configuration; + + const appConnection = await appConnectionDAL.findById(azureAdcsConnectionId); + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${azureAdcsConnectionId}' not found` }); + } + + // Get credentials from the Azure ADCS connection + const { username, password, adcsUrl, sslRejectUnauthorized, sslCertificate } = + await getAzureADCSConnectionCredentials(azureAdcsConnectionId, appConnectionDAL, kmsService); + + const credentials: { + username: string; + password: string; + sslRejectUnauthorized?: boolean; + sslCertificate?: string; + } = { + username, + password, + sslRejectUnauthorized, + sslCertificate + }; + + const client = createAdcsHttpClient( + credentials.username, + credentials.password, + adcsUrl, + credentials.sslRejectUnauthorized ?? true, + credentials.sslCertificate + ); + + try { + // Get available templates from ADCS web interface and filter to only usable ones + let availableTemplates: Array<{ id: string; name: string; description: string }> = []; + + try { + const requestFormResponse = await client.get("/certsrv/certrqxt.asp"); + const responseText = requestFormResponse.data; + + // ADCS returns JavaScript-based template info instead of HTML options + // Look for patterns like: getTemplateStringInfo(CTINFO_INDEX_REALNAME, null) and sRealName assignments + const parsedTemplates: Array<{ id: string; name: string }> = []; + + // Extract template names from JavaScript variable assignments like sRealName="WebServer" + const nameRegex = new RE2('sRealName\\s*=\\s*"([^"]+)"', "gi"); + const names: string[] = []; + let nameMatch = nameRegex.exec(responseText); + while (nameMatch !== null) { + names.push(nameMatch[1]); + nameMatch = nameRegex.exec(responseText); + } + + // Extract template IDs/values from encoded strings or other patterns + const valueRegex = new RE2('CertificateTemplate\\s*\\+\\s*"([^"]+)"', "gi"); + const values: string[] = []; + let valueMatch = valueRegex.exec(responseText); + while (valueMatch !== null) { + values.push(valueMatch[1]); + valueMatch = valueRegex.exec(responseText); + } + + // Also look for any remaining HTML option patterns as fallback + const optionRegex = new RE2(']+value="([^"]*)"[^>]*>([^<]*)', "gi"); + let optionMatch = optionRegex.exec(responseText); + while (optionMatch !== null) { + const templateValue = optionMatch[1].trim(); + const templateDisplayName = optionMatch[2].trim(); + + if ( + templateValue && + templateDisplayName && + templateValue !== "" && + templateValue !== "0" && + !templateDisplayName.toLowerCase().includes("select") + ) { + // Parse the encoded template value format: "E;User;1;1;41;16;-1509949440;0;..." + // The template ID is the second semicolon-delimited value + const templateParts = templateValue.split(";"); + const templateId = templateParts.length >= 2 ? templateParts[1] : templateDisplayName; + + parsedTemplates.push({ + id: templateId, + name: templateDisplayName + }); + } + optionMatch = optionRegex.exec(responseText); + } + + // Combine JavaScript-extracted names with any found values + if (names.length > 0) { + names.forEach((name) => { + parsedTemplates.push({ + id: name, + name + }); + }); + } + + // If we successfully parsed templates, use them; otherwise fall back to common ones + if (parsedTemplates.length > 0) { + availableTemplates = parsedTemplates.map((template) => ({ + id: template.id, + name: template.name, + description: `Certificate template: ${template.name}` + })); + } else { + // Fallback to known working templates + availableTemplates = [ + { id: "User", name: "User", description: "User authentication certificate" }, + { id: "WebServer", name: "Web Server", description: "Web server certificate" } + ]; + } + } catch (requestError) { + // Fallback to known working templates if we can't parse the form + availableTemplates = [ + { id: "User", name: "User", description: "User authentication certificate" }, + { id: "WebServer", name: "Web Server", description: "Web server certificate" } + ]; + } + + // Return all available templates - let user decide what to use + return availableTemplates; + } catch (error) { + throw new BadRequestError({ message: "Failed to retrieve certificate templates from Azure ADCS" }); + } + }; + + return { + createCertificateAuthority, + updateCertificateAuthority, + listCertificateAuthorities, + orderSubscriberCertificate, + getTemplates + }; +}; diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts new file mode 100644 index 000000000..2c2dfe484 --- /dev/null +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import { CaType } from "../certificate-authority-enums"; +import { + BaseCertificateAuthoritySchema, + GenericCreateCertificateAuthorityFieldsSchema, + GenericUpdateCertificateAuthorityFieldsSchema +} from "../certificate-authority-schemas"; + +export const AzureAdCsCertificateAuthorityConfigurationSchema = z.object({ + azureAdcsConnectionId: z.string().uuid().trim().describe("Azure ADCS Connection ID") +}); + +export const AzureAdCsCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({ + type: z.literal(CaType.AZURE_AD_CS), + configuration: AzureAdCsCertificateAuthorityConfigurationSchema +}); + +export const CreateAzureAdCsCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema( + CaType.AZURE_AD_CS +).extend({ + configuration: AzureAdCsCertificateAuthorityConfigurationSchema +}); + +export const UpdateAzureAdCsCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema( + CaType.AZURE_AD_CS +).extend({ + configuration: AzureAdCsCertificateAuthorityConfigurationSchema.optional() +}); diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-types.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-types.ts new file mode 100644 index 000000000..1c4b3699d --- /dev/null +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-types.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { + AzureAdCsCertificateAuthoritySchema, + CreateAzureAdCsCertificateAuthoritySchema, + UpdateAzureAdCsCertificateAuthoritySchema +} from "./azure-ad-cs-certificate-authority-schemas"; + +export type TAzureAdCsCertificateAuthority = z.infer; + +export type TCreateAzureAdCsCertificateAuthorityDTO = z.infer; + +export type TUpdateAzureAdCsCertificateAuthorityDTO = z.infer; diff --git a/backend/src/services/certificate-authority/certificate-authority-enums.ts b/backend/src/services/certificate-authority/certificate-authority-enums.ts index 8de80495e..f9b769a86 100644 --- a/backend/src/services/certificate-authority/certificate-authority-enums.ts +++ b/backend/src/services/certificate-authority/certificate-authority-enums.ts @@ -1,6 +1,7 @@ export enum CaType { INTERNAL = "internal", - ACME = "acme" + ACME = "acme", + AZURE_AD_CS = "azure-ad-cs" } export enum InternalCaType { @@ -17,3 +18,9 @@ export enum CaStatus { export enum CaRenewalType { EXISTING = "existing" } + +export enum CaCapability { + ISSUE_CERTIFICATES = "issue-certificates", + REVOKE_CERTIFICATES = "revoke-certificates", + RENEW_CERTIFICATES = "renew-certificates" +} diff --git a/backend/src/services/certificate-authority/certificate-authority-maps.ts b/backend/src/services/certificate-authority/certificate-authority-maps.ts index d13f65138..ef844a1ed 100644 --- a/backend/src/services/certificate-authority/certificate-authority-maps.ts +++ b/backend/src/services/certificate-authority/certificate-authority-maps.ts @@ -1,6 +1,29 @@ -import { CaType } from "./certificate-authority-enums"; +import { CaCapability, CaType } from "./certificate-authority-enums"; export const CERTIFICATE_AUTHORITIES_TYPE_MAP: Record = { [CaType.INTERNAL]: "Internal", - [CaType.ACME]: "ACME" + [CaType.ACME]: "ACME", + [CaType.AZURE_AD_CS]: "Azure AD Certificate Service" +}; + +export const CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP: Record = { + [CaType.INTERNAL]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.REVOKE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + ], + [CaType.ACME]: [CaCapability.ISSUE_CERTIFICATES, CaCapability.REVOKE_CERTIFICATES, CaCapability.RENEW_CERTIFICATES], + [CaType.AZURE_AD_CS]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + // Note: REVOKE_CERTIFICATES intentionally omitted - not supported by ADCS connector + ] +}; + +/** + * Check if a certificate authority type supports a specific capability + */ +export const caSupportsCapability = (caType: CaType, capability: CaCapability): boolean => { + const capabilities = CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP[caType] || []; + return capabilities.includes(capability); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-queue.ts b/backend/src/services/certificate-authority/certificate-authority-queue.ts index afe17ec5f..0e015da03 100644 --- a/backend/src/services/certificate-authority/certificate-authority-queue.ts +++ b/backend/src/services/certificate-authority/certificate-authority-queue.ts @@ -21,6 +21,7 @@ import { TCertificateSecretDALFactory } from "../certificate/certificate-secret- import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; import { SubscriberOperationStatus } from "../pki-subscriber/pki-subscriber-types"; import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns"; +import { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { CaType } from "./certificate-authority-enums"; import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; @@ -33,7 +34,7 @@ import { type TCertificateAuthorityQueueFactoryDep = { certificateAuthorityDAL: TCertificateAuthorityDALFactory; - appConnectionDAL: Pick; + appConnectionDAL: Pick; appConnectionService: Pick; externalCertificateAuthorityDAL: Pick; keyStore: Pick; @@ -82,6 +83,19 @@ export const certificateAuthorityQueueFactory = ({ projectDAL }); + const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL, + projectDAL + }); + // TODO 1: auto-periodic rotation // TODO 2: manual rotation @@ -158,6 +172,13 @@ export const certificateAuthorityQueueFactory = ({ lastOperationMessage: "Certificate ordered successfully", lastOperationAt: new Date() }); + } else if (caType === CaType.AZURE_AD_CS) { + await azureAdCsFns.orderSubscriberCertificate(subscriberId); + await pkiSubscriberDAL.updateById(subscriberId, { + lastOperationStatus: SubscriberOperationStatus.SUCCESS, + lastOperationMessage: "Certificate ordered successfully", + lastOperationAt: new Date() + }); } } catch (e: unknown) { if (e instanceof Error) { diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index fa0fe017f..6cf55fc52 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -22,6 +22,14 @@ import { TCreateAcmeCertificateAuthorityDTO, TUpdateAcmeCertificateAuthorityDTO } from "./acme/acme-certificate-authority-types"; +import { + AzureAdCsCertificateAuthorityFns, + castDbEntryToAzureAdCsCertificateAuthority +} from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns"; +import { + TCreateAzureAdCsCertificateAuthorityDTO, + TUpdateAzureAdCsCertificateAuthorityDTO +} from "./azure-ad-cs/azure-ad-cs-certificate-authority-types"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { CaType } from "./certificate-authority-enums"; import { @@ -34,7 +42,7 @@ import { TInternalCertificateAuthorityServiceFactory } from "./internal/internal import { TCreateInternalCertificateAuthorityDTO } from "./internal/internal-certificate-authority-types"; type TCertificateAuthorityServiceFactoryDep = { - appConnectionDAL: Pick; + appConnectionDAL: Pick; appConnectionService: Pick; certificateAuthorityDAL: Pick< TCertificateAuthorityDALFactory, @@ -91,6 +99,19 @@ export const certificateAuthorityServiceFactory = ({ projectDAL }); + const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL, + projectDAL + }); + const createCertificateAuthority = async ( { type, projectId, name, enableDirectIssuance, configuration, status }: TCreateCertificateAuthorityDTO, actor: OrgServiceActor @@ -146,6 +167,17 @@ export const certificateAuthorityServiceFactory = ({ }); } + if (type === CaType.AZURE_AD_CS) { + return azureAdCsFns.createCertificateAuthority({ + name, + projectId, + configuration: configuration as TCreateAzureAdCsCertificateAuthorityDTO["configuration"], + enableDirectIssuance, + status, + actor + }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -205,6 +237,10 @@ export const certificateAuthorityServiceFactory = ({ return castDbEntryToAcmeCertificateAuthority(certificateAuthority); } + if (type === CaType.AZURE_AD_CS) { + return castDbEntryToAzureAdCsCertificateAuthority(certificateAuthority); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -249,6 +285,10 @@ export const certificateAuthorityServiceFactory = ({ return acmeFns.listCertificateAuthorities({ projectId }); } + if (type === CaType.AZURE_AD_CS) { + return azureAdCsFns.listCertificateAuthorities({ projectId }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -323,6 +363,17 @@ export const certificateAuthorityServiceFactory = ({ }); } + if (type === CaType.AZURE_AD_CS) { + return azureAdCsFns.updateCertificateAuthority({ + id: certificateAuthority.id, + configuration: configuration as TUpdateAzureAdCsCertificateAuthorityDTO["configuration"], + enableDirectIssuance, + actor, + status, + name + }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -384,14 +435,54 @@ export const certificateAuthorityServiceFactory = ({ return castDbEntryToAcmeCertificateAuthority(certificateAuthority); } + if (type === CaType.AZURE_AD_CS) { + return castDbEntryToAzureAdCsCertificateAuthority(certificateAuthority); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; + const getAzureAdcsTemplates = async ({ + caId, + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: { + caId: string; + projectId: string; + actor: OrgServiceActor["type"]; + actorId: string; + actorAuthMethod: OrgServiceActor["authMethod"]; + actorOrgId?: string; + }) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + return azureAdCsFns.getTemplates({ + caId, + projectId + }); + }; + return { createCertificateAuthority, findCertificateAuthorityByNameAndProjectId, listCertificateAuthoritiesByProjectId, updateCertificateAuthority, - deleteCertificateAuthority + deleteCertificateAuthority, + getAzureAdcsTemplates }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index d76330bd8..13b5cec40 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -1,13 +1,23 @@ import { TAcmeCertificateAuthority, TAcmeCertificateAuthorityInput } from "./acme/acme-certificate-authority-types"; +import { + TAzureAdCsCertificateAuthority, + TCreateAzureAdCsCertificateAuthorityDTO +} from "./azure-ad-cs/azure-ad-cs-certificate-authority-types"; import { CaType } from "./certificate-authority-enums"; import { TInternalCertificateAuthority, TInternalCertificateAuthorityInput } from "./internal/internal-certificate-authority-types"; -export type TCertificateAuthority = TInternalCertificateAuthority | TAcmeCertificateAuthority; +export type TCertificateAuthority = + | TInternalCertificateAuthority + | TAcmeCertificateAuthority + | TAzureAdCsCertificateAuthority; -export type TCertificateAuthorityInput = TInternalCertificateAuthorityInput | TAcmeCertificateAuthorityInput; +export type TCertificateAuthorityInput = + | TInternalCertificateAuthorityInput + | TAcmeCertificateAuthorityInput + | TCreateAzureAdCsCertificateAuthorityDTO; export type TCreateCertificateAuthorityDTO = Omit; diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts index fab62ddbf..908741bde 100644 --- a/backend/src/services/certificate-authority/certificate-authority-validators.ts +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { isValidIp } from "@app/lib/ip"; import { isFQDN } from "@app/lib/validator/validate-url"; +import { TAltNameMapping, TAltNameType } from "@app/services/certificate/certificate-types"; const isValidDate = (dateString: string) => { const date = new Date(dateString); @@ -15,10 +16,15 @@ export const validateAltNameField = z .trim() .refine( (name) => { - return isFQDN(name, { allow_wildcard: true }) || z.string().email().safeParse(name).success || isValidIp(name); + return ( + isFQDN(name, { allow_wildcard: true, require_tld: false }) || + z.string().url().safeParse(name).success || + z.string().email().safeParse(name).success || + isValidIp(name) + ); }, { - message: "SAN must be a valid hostname, email address, or IP address" + message: "SAN must be a valid hostname, email address, IP address or URL" } ); @@ -39,10 +45,31 @@ export const validateAltNamesField = z if (data === "") return true; // Split and validate each alt name return data.split(", ").every((name) => { - return isFQDN(name, { allow_wildcard: true }) || z.string().email().safeParse(name).success || isValidIp(name); + return ( + isFQDN(name, { allow_wildcard: true, require_tld: false }) || + z.string().url().safeParse(name).success || + z.string().email().safeParse(name).success || + isValidIp(name) + ); }); }, { - message: "Each alt name must be a valid hostname or email address" + message: "Each alt name must be a valid hostname, email address, IP address or URL" } ); + +export const validateAndMapAltNameType = (name: string): TAltNameMapping | null => { + if (isFQDN(name, { allow_wildcard: true, require_tld: false })) { + return { type: TAltNameType.DNS, value: name }; + } + if (z.string().url().safeParse(name).success) { + return { type: TAltNameType.URL, value: name }; + } + if (z.string().email().safeParse(name).success) { + return { type: TAltNameType.EMAIL, value: name }; + } + if (isValidIp(name)) { + return { type: TAltNameType.IP, value: name }; + } + return null; +}; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 80d2842fa..9f1a5e5c2 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -1,7 +1,6 @@ /* eslint-disable no-bitwise */ import * as x509 from "@peculiar/x509"; import RE2 from "re2"; -import { z } from "zod"; import { TCertificateTemplates, TPkiSubscribers } from "@app/db/schemas"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; @@ -9,7 +8,6 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; -import { isFQDN } from "@app/lib/validator/validate-url"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; @@ -17,7 +15,8 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage, - CertStatus + CertStatus, + TAltNameMapping } from "@app/services/certificate/certificate-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -33,15 +32,22 @@ import { keyAlgorithmToAlgCfg } from "../certificate-authority-fns"; import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; +import { validateAndMapAltNameType } from "../certificate-authority-validators"; import { TIssueCertWithTemplateDTO } from "./internal-certificate-authority-types"; type TInternalCertificateAuthorityFnsDeps = { - certificateAuthorityDAL: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "findByIdWithAssociatedCa" | "findById" | "create" | "transaction" | "updateById" | "findWithAssociatedCa" + >; certificateAuthorityCertDAL: Pick; certificateAuthoritySecretDAL: Pick; certificateAuthorityCrlDAL: Pick; projectDAL: Pick; - kmsService: Pick; + kmsService: Pick< + TKmsServiceFactory, + "decryptWithKmsKey" | "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" + >; certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; @@ -152,19 +158,15 @@ export const InternalCertificateAuthorityFns = ({ extensions.push(extendedKeyUsagesExtension); } - let altNamesArray: { type: "email" | "dns"; value: string }[] = []; + let altNamesArray: TAltNameMapping[] = []; if (subscriber.subjectAlternativeNames?.length) { altNamesArray = subscriber.subjectAlternativeNames.map((altName) => { - if (z.string().email().safeParse(altName).success) { - return { type: "email", value: altName }; + const altNameType = validateAndMapAltNameType(altName); + if (!altNameType) { + throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); } - - if (isFQDN(altName, { allow_wildcard: true })) { - return { type: "dns", value: altName }; - } - - throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); + return altNameType; }); const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); @@ -418,19 +420,15 @@ export const InternalCertificateAuthorityFns = ({ ); } - let altNamesArray: { type: "email" | "dns"; value: string }[] = []; + let altNamesArray: TAltNameMapping[] = []; if (altNames) { altNamesArray = altNames.split(",").map((altName) => { - if (z.string().email().safeParse(altName).success) { - return { type: "email", value: altName }; + const altNameType = validateAndMapAltNameType(altName); + if (!altNameType) { + throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); } - - if (isFQDN(altName, { allow_wildcard: true })) { - return { type: "dns", value: altName }; - } - - throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); + return altNameType; }); const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index dd30cc62e..ab89ea996 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -2,7 +2,6 @@ import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import slugify from "@sindresorhus/slugify"; -import { z } from "zod"; import { ActionProjectType, TableName, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -18,7 +17,6 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { isFQDN } from "@app/lib/validator/validate-url"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -34,7 +32,8 @@ import { CertExtendedKeyUsageOIDToName, CertKeyAlgorithm, CertKeyUsage, - CertStatus + CertStatus, + TAltNameMapping } from "../../certificate/certificate-types"; import { TCertificateTemplateDALFactory } from "../../certificate-template/certificate-template-dal"; import { validateCertificateDetailsAgainstTemplate } from "../../certificate-template/certificate-template-fns"; @@ -53,6 +52,7 @@ import { } from "../certificate-authority-fns"; import { TCertificateAuthorityQueueFactory } from "../certificate-authority-queue"; import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; +import { validateAndMapAltNameType } from "../certificate-authority-validators"; import { TInternalCertificateAuthorityDALFactory } from "./internal-certificate-authority-dal"; import { TCreateCaDTO, @@ -1190,7 +1190,9 @@ export const internalCertificateAuthorityServiceFactory = ({ }); } - collectionId = certificateTemplate.pkiCollectionId as string; + if (!collectionId) { + collectionId = certificateTemplate.pkiCollectionId as string; + } ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId); } @@ -1364,34 +1366,18 @@ export const internalCertificateAuthorityServiceFactory = ({ ); } - let altNamesArray: { - type: "email" | "dns"; - value: string; - }[] = []; + let altNamesArray: TAltNameMapping[] = []; if (altNames) { altNamesArray = altNames .split(",") .map((name) => name.trim()) - .map((altName) => { - // check if the altName is a valid email - if (z.string().email().safeParse(altName).success) { - return { - type: "email", - value: altName - }; + .map((altName): TAltNameMapping => { + const altNameType = validateAndMapAltNameType(altName); + if (!altNameType) { + throw new Error(`Invalid altName: ${altName}`); } - - // check if the altName is a valid hostname - if (isFQDN(altName, { allow_wildcard: true })) { - return { - type: "dns", - value: altName - }; - } - - // If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly - throw new Error(`Invalid altName: ${altName}`); + return altNameType; }); const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); @@ -1766,34 +1752,22 @@ export const internalCertificateAuthorityServiceFactory = ({ } let altNamesFromCsr: string = ""; - let altNamesArray: { - type: "email" | "dns"; - value: string; - }[] = []; + let altNamesArray: TAltNameMapping[] = []; + if (altNames) { altNamesArray = altNames .split(",") .map((name) => name.trim()) - .map((altName) => { - // check if the altName is a valid email - if (z.string().email().safeParse(altName).success) { - return { - type: "email", - value: altName - }; + .map((altName): TAltNameMapping => { + const altNameType = validateAndMapAltNameType(altName); + if (!altNameType) { + throw new Error(`Invalid altName: ${altName}`); } - - // check if the altName is a valid hostname - if (isFQDN(altName, { allow_wildcard: true })) { - return { - type: "dns", - value: altName - }; - } - - // If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly - throw new Error(`Invalid altName: ${altName}`); + return altNameType; }); + + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); } else { // attempt to read from CSR if altNames is not explicitly provided const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); @@ -1801,11 +1775,16 @@ export const internalCertificateAuthorityServiceFactory = ({ const sanNames = new x509.GeneralNames(sanExtension.value); altNamesArray = sanNames.items - .filter((value) => value.type === "email" || value.type === "dns") - .map((name) => ({ - type: name.type as "email" | "dns", - value: name.value - })); + .filter( + (value) => value.type === "email" || value.type === "dns" || value.type === "url" || value.type === "ip" + ) + .map((name): TAltNameMapping => { + const altNameType = validateAndMapAltNameType(name.value); + if (!altNameType) { + throw new Error(`Invalid altName from CSR: ${name.value}`); + } + return altNameType; + }); altNamesFromCsr = sanNames.items.map((item) => item.value).join(","); } diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index de6da16ee..7adc60d52 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -14,6 +14,8 @@ import { TCertificateBodyDALFactory } from "@app/services/certificate/certificat import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { CaCapability, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { caSupportsCapability } from "@app/services/certificate-authority/certificate-authority-maps"; import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; @@ -184,9 +186,11 @@ export const certificateServiceFactory = ({ const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(cert.caId); - if (ca.externalCa?.id) { + // Check if the CA type supports revocation + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + if (!caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES)) { throw new BadRequestError({ - message: "Cannot revoke external certificates" + message: "Certificate revocation is not supported by this certificate authority type" }); } @@ -218,18 +222,37 @@ export const certificateServiceFactory = ({ } ); - // rebuild CRL (TODO: move to interval-based cron job) - await rebuildCaCrl({ - caId: ca.id, - certificateAuthorityDAL, - certificateAuthorityCrlDAL, - certificateAuthoritySecretDAL, - projectDAL, - certificateDAL, - kmsService - }); + // Note: External CA revocation handling would go here for supported CA types + // Currently, only internal CAs and ACME CAs support revocation - return { revokedAt, cert, ca: expandInternalCa(ca) }; + // rebuild CRL (TODO: move to interval-based cron job) + // Only rebuild CRL for internal CAs - external CAs manage their own CRLs + if (!ca.externalCa?.id) { + await rebuildCaCrl({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + certificateDAL, + kmsService + }); + } + + // Return appropriate CA format based on CA type + const caResult = ca.externalCa?.id + ? { + id: ca.id, + name: ca.name, + projectId: ca.projectId, + status: ca.status, + enableDirectIssuance: ca.enableDirectIssuance, + type: ca.externalCa.type, + externalCa: ca.externalCa + } + : expandInternalCa(ca); + + return { revokedAt, cert, ca: caResult }; }; /** diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index f1c79a36f..527df2a39 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -104,3 +104,14 @@ export type TGetCertificateCredentialsDTO = { projectDAL: Pick; kmsService: Pick; }; + +export enum TAltNameType { + EMAIL = "email", + DNS = "dns", + IP = "ip", + URL = "url" +} +export type TAltNameMapping = { + type: TAltNameType; + value: string; +}; diff --git a/backend/src/services/external-migration/external-migration-fns/vault.ts b/backend/src/services/external-migration/external-migration-fns/vault.ts index 197102f82..ca6d2b0b0 100644 --- a/backend/src/services/external-migration/external-migration-fns/vault.ts +++ b/backend/src/services/external-migration/external-migration-fns/vault.ts @@ -1,12 +1,21 @@ +import https from "node:https"; + import axios, { AxiosInstance } from "axios"; import { v4 as uuidv4 } from "uuid"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { InfisicalImportData, VaultMappingType } from "../external-migration-types"; +enum KvVersion { + V1 = "1", + V2 = "2" +} + type VaultData = { namespace: string; mount: string; @@ -14,37 +23,76 @@ type VaultData = { secretData: Record; }; -const vaultFactory = () => { +const vaultFactory = (gatewayService: Pick) => { + const $gatewayProxyWrapper = async ( + inputs: { + gatewayId: string; + targetHost?: string; + targetPort?: number; + }, + gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise + ): Promise => { + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + const callbackResult = await withGatewayProxy( + async (port, httpsAgent) => { + const res = await gatewayCallback("http://localhost", port, httpsAgent); + return res; + }, + { + protocol: GatewayProxyProtocol.Http, + targetHost: inputs.targetHost, + targetPort: inputs.targetPort, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + + return callbackResult; + }; + const getMounts = async (request: AxiosInstance) => { const response = await request - .get< - Record< - string, - { - accessor: string; - options: { - version?: string; - } | null; - type: string; - } - > - >("/v1/sys/mounts") + .get<{ + data: Record; + }>("/v1/sys/mounts") .catch((err) => { if (axios.isAxiosError(err)) { logger.error(err.response?.data, "External migration: Failed to get Vault mounts"); } throw err; }); - return response.data; + return response.data.data; }; const getPaths = async ( request: AxiosInstance, - { mountPath, secretPath = "" }: { mountPath: string; secretPath?: string } + { mountPath, secretPath = "" }: { mountPath: string; secretPath?: string }, + kvVersion: KvVersion ) => { try { - // For KV v2: /v1/{mount}/metadata/{path}?list=true - const path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`; + if (kvVersion === KvVersion.V2) { + // For KV v2: /v1/{mount}/metadata/{path}?list=true + const path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`; + const response = await request.get<{ + data: { + keys: string[]; + }; + }>(`/v1/${path}?list=true`); + + return response.data.data.keys; + } + + // kv version v1: /v1/{mount}?list=true + const path = secretPath ? `${mountPath}/${secretPath}` : mountPath; const response = await request.get<{ data: { keys: string[]; @@ -65,21 +113,42 @@ const vaultFactory = () => { const getSecrets = async ( request: AxiosInstance, - { mountPath, secretPath }: { mountPath: string; secretPath: string } + { mountPath, secretPath }: { mountPath: string; secretPath: string }, + kvVersion: KvVersion ) => { - // For KV v2: /v1/{mount}/data/{path} + if (kvVersion === KvVersion.V2) { + // For KV v2: /v1/{mount}/data/{path} + const response = await request + .get<{ + data: { + data: Record; // KV v2 has nested data structure + metadata: { + created_time: string; + deletion_time: string; + destroyed: boolean; + version: number; + }; + }; + }>(`/v1/${mountPath}/data/${secretPath}`) + .catch((err) => { + if (axios.isAxiosError(err)) { + logger.error(err.response?.data, "External migration: Failed to get Vault secret"); + } + throw err; + }); + + return response.data.data.data; + } + + // kv version v1 + const response = await request .get<{ - data: { - data: Record; // KV v2 has nested data structure - metadata: { - created_time: string; - deletion_time: string; - destroyed: boolean; - version: number; - }; - }; - }>(`/v1/${mountPath}/data/${secretPath}`) + data: Record; // KV v1 has flat data structure + lease_duration: number; + lease_id: string; + renewable: boolean; + }>(`/v1/${mountPath}/${secretPath}`) .catch((err) => { if (axios.isAxiosError(err)) { logger.error(err.response?.data, "External migration: Failed to get Vault secret"); @@ -87,7 +156,7 @@ const vaultFactory = () => { throw err; }); - return response.data.data.data; + return response.data.data; }; // helper function to check if a mount is KV v2 (will be useful if we add support for Vault KV v1) @@ -98,9 +167,10 @@ const vaultFactory = () => { const recursivelyGetAllPaths = async ( request: AxiosInstance, mountPath: string, + kvVersion: KvVersion, currentPath: string = "" ): Promise => { - const paths = await getPaths(request, { mountPath, secretPath: currentPath }); + const paths = await getPaths(request, { mountPath, secretPath: currentPath }, kvVersion); if (paths === null || paths.length === 0) { return []; @@ -114,7 +184,7 @@ const vaultFactory = () => { if (path.endsWith("/")) { // it's a folder so we recurse into it - const subSecrets = await recursivelyGetAllPaths(request, mountPath, fullItemPath); + const subSecrets = await recursivelyGetAllPaths(request, mountPath, kvVersion, fullItemPath); allSecrets.push(...subSecrets); } else { // it's a secret so we add it to our results @@ -128,60 +198,93 @@ const vaultFactory = () => { async function collectVaultData({ baseUrl, namespace, - accessToken + accessToken, + gatewayId }: { baseUrl: string; namespace?: string; accessToken: string; + gatewayId?: string; }): Promise { - const request = axios.create({ - baseURL: baseUrl, - headers: { - "X-Vault-Token": accessToken, - ...(namespace ? { "X-Vault-Namespace": namespace } : {}) + const getData = async (host: string, port?: number, httpsAgent?: https.Agent) => { + const allData: VaultData[] = []; + + const request = axios.create({ + baseURL: port ? `${host}:${port}` : host, + headers: { + "X-Vault-Token": accessToken, + ...(namespace ? { "X-Vault-Namespace": namespace } : {}) + }, + httpsAgent + }); + + // Get all mounts in this namespace + const mounts = await getMounts(request); + + for (const mount of Object.keys(mounts)) { + if (!mount.endsWith("/")) { + delete mounts[mount]; + } } - }); - const allData: VaultData[] = []; + for await (const [mountPath, mountInfo] of Object.entries(mounts)) { + // skip non-KV mounts + if (!mountInfo.type.startsWith("kv")) { + // eslint-disable-next-line no-continue + continue; + } - // Get all mounts in this namespace - const mounts = await getMounts(request); + const kvVersion = mountInfo.options?.version === "2" ? KvVersion.V2 : KvVersion.V1; - for (const mount of Object.keys(mounts)) { - if (!mount.endsWith("/")) { - delete mounts[mount]; + // get all paths in this mount + const paths = await recursivelyGetAllPaths(request, `${mountPath.replace(/\/$/, "")}`, kvVersion); + + const cleanMountPath = mountPath.replace(/\/$/, ""); + + for await (const secretPath of paths) { + // get the actual secret data + const secretData = await getSecrets( + request, + { + mountPath: cleanMountPath, + secretPath: secretPath.replace(`${cleanMountPath}/`, "") + }, + kvVersion + ); + + allData.push({ + namespace: namespace || "", + mount: mountPath.replace(/\/$/, ""), + path: secretPath.replace(`${cleanMountPath}/`, ""), + secretData + }); + } } + + return allData; + }; + + let data; + + if (gatewayId) { + const url = new URL(baseUrl); + + const { port, protocol, hostname } = url; + const cleanedProtocol = protocol.slice(0, -1); + + data = await $gatewayProxyWrapper( + { + gatewayId, + targetHost: `${cleanedProtocol}://${hostname}`, + targetPort: port ? Number(port) : 8200 // 8200, default port for Vault self-hosted/dedicated + }, + getData + ); + } else { + data = await getData(baseUrl); } - for await (const [mountPath, mountInfo] of Object.entries(mounts)) { - // skip non-KV mounts - if (!mountInfo.type.startsWith("kv")) { - // eslint-disable-next-line no-continue - continue; - } - - // get all paths in this mount - const paths = await recursivelyGetAllPaths(request, `${mountPath.replace(/\/$/, "")}`); - - const cleanMountPath = mountPath.replace(/\/$/, ""); - - for await (const secretPath of paths) { - // get the actual secret data - const secretData = await getSecrets(request, { - mountPath: cleanMountPath, - secretPath: secretPath.replace(`${cleanMountPath}/`, "") - }); - - allData.push({ - namespace: namespace || "", - mount: mountPath.replace(/\/$/, ""), - path: secretPath.replace(`${cleanMountPath}/`, ""), - secretData - }); - } - } - - return allData; + return data; } return { @@ -263,29 +366,26 @@ export const transformToInfisicalFormatNamespaceToProjects = ( let currentFolderId: string | undefined; let currentPath = ""; - if (path.includes("/")) { - const pathParts = path.split("/").filter(Boolean); + const pathParts = path.split("/").filter(Boolean); + const folderParts = pathParts; - const folderParts = pathParts; + // create nested folder structure for the entire path + for (const folderName of folderParts) { + currentPath = currentPath ? `${currentPath}/${folderName}` : folderName; + const folderKey = `${namespace}:${mount}:${currentPath}`; - // create nested folder structure for the entire path - for (const folderName of folderParts) { - currentPath = currentPath ? `${currentPath}/${folderName}` : folderName; - const folderKey = `${namespace}:${mount}:${currentPath}`; - - if (!folderMap.has(folderKey)) { - const folderId = uuidv4(); - folderMap.set(folderKey, folderId); - folders.push({ - id: folderId, - name: folderName, - environmentId, - parentFolderId: currentFolderId || environmentId - }); - currentFolderId = folderId; - } else { - currentFolderId = folderMap.get(folderKey)!; - } + if (!folderMap.has(folderKey)) { + const folderId = uuidv4(); + folderMap.set(folderKey, folderId); + folders.push({ + id: folderId, + name: folderName, + environmentId, + parentFolderId: currentFolderId || environmentId + }); + currentFolderId = folderId; + } else { + currentFolderId = folderMap.get(folderKey)!; } } @@ -308,17 +408,126 @@ export const transformToInfisicalFormatNamespaceToProjects = ( }; }; -export const importVaultDataFn = async ({ - vaultAccessToken, - vaultNamespace, - vaultUrl, - mappingType -}: { - vaultAccessToken: string; - vaultNamespace?: string; - vaultUrl: string; - mappingType: VaultMappingType; -}) => { +export const transformToInfisicalFormatKeyVaultToProjectsCustomC1 = (vaultData: VaultData[]): InfisicalImportData => { + const projects: Array<{ name: string; id: string }> = []; + const environments: Array<{ name: string; id: string; projectId: string; envParentId?: string }> = []; + const folders: Array<{ id: string; name: string; environmentId: string; parentFolderId?: string }> = []; + const secrets: Array<{ id: string; name: string; environmentId: string; value: string; folderId?: string }> = []; + + // track created entities to avoid duplicates + const projectMap = new Map(); // team name -> projectId + const environmentMap = new Map(); // team-name:envName -> environmentId + const folderMap = new Map(); // team-name:envName:folderPath -> folderId + + for (const data of vaultData) { + const { path, secretData } = data; + + const pathParts = path.split("/").filter(Boolean); + if (pathParts.length < 2) { + // eslint-disable-next-line no-continue + continue; + } + + // first level: environment (dev, prod, staging, etc.) + const environmentName = pathParts[0]; + // second level: team name (team1, team2, etc.) + const teamName = pathParts[1]; + // remaining parts: folder structure + const folderParts = pathParts.slice(2); + + // create project (team) if if doesn't exist + if (!projectMap.has(teamName)) { + const projectId = uuidv4(); + projectMap.set(teamName, projectId); + projects.push({ + name: teamName, + id: projectId + }); + } + const projectId = projectMap.get(teamName)!; + + // create environment (dev, prod, etc.) for team + const envKey = `${teamName}:${environmentName}`; + if (!environmentMap.has(envKey)) { + const environmentId = uuidv4(); + environmentMap.set(envKey, environmentId); + environments.push({ + name: environmentName, + id: environmentId, + projectId + }); + } + const environmentId = environmentMap.get(envKey)!; + + // create folder structure for path segments + let currentFolderId: string | undefined; + let currentPath = ""; + + for (const folderName of folderParts) { + currentPath = currentPath ? `${currentPath}/${folderName}` : folderName; + const folderKey = `${teamName}:${environmentName}:${currentPath}`; + + if (!folderMap.has(folderKey)) { + const folderId = uuidv4(); + folderMap.set(folderKey, folderId); + folders.push({ + id: folderId, + name: folderName, + environmentId, + parentFolderId: currentFolderId || environmentId + }); + currentFolderId = folderId; + } else { + currentFolderId = folderMap.get(folderKey)!; + } + } + + for (const [key, value] of Object.entries(secretData)) { + secrets.push({ + id: uuidv4(), + name: key, + environmentId, + value: String(value), + folderId: currentFolderId + }); + } + } + + return { + projects, + environments, + folders, + secrets + }; +}; + +// refer to internal doc for more details on which ID's belong to which orgs. +// when its a custom migration, then it doesn't matter which mapping type is used (as of now). +export const vaultMigrationTransformMappings: Record< + string, + (vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData +> = { + "68c57ab3-cea5-41fc-ae38-e156b10c14d2": transformToInfisicalFormatKeyVaultToProjectsCustomC1 +} as const; + +export const importVaultDataFn = async ( + { + vaultAccessToken, + vaultNamespace, + vaultUrl, + mappingType, + gatewayId, + orgId + }: { + vaultAccessToken: string; + vaultNamespace?: string; + vaultUrl: string; + mappingType: VaultMappingType; + gatewayId?: string; + orgId: string; + }, + { gatewayService }: { gatewayService: Pick } +) => { await blockLocalAndPrivateIpAddresses(vaultUrl); if (mappingType === VaultMappingType.Namespace && !vaultNamespace) { @@ -327,15 +536,33 @@ export const importVaultDataFn = async ({ }); } - const vaultApi = vaultFactory(); + let transformFn: (vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData; + + if (mappingType === VaultMappingType.Custom) { + transformFn = vaultMigrationTransformMappings[orgId]; + + if (!transformFn) { + throw new BadRequestError({ + message: "Please contact our sales team to enable custom vault migrations." + }); + } + } else { + transformFn = transformToInfisicalFormatNamespaceToProjects; + } + + logger.info( + { orgId, mappingType }, + `[importVaultDataFn]: Running ${orgId in vaultMigrationTransformMappings ? "custom" : "default"} transform` + ); + + const vaultApi = vaultFactory(gatewayService); const vaultData = await vaultApi.collectVaultData({ accessToken: vaultAccessToken, baseUrl: vaultUrl, - namespace: vaultNamespace + namespace: vaultNamespace, + gatewayId }); - const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType); - - return infisicalData; + return transformFn(vaultData, mappingType); }; diff --git a/backend/src/services/external-migration/external-migration-queue.ts b/backend/src/services/external-migration/external-migration-queue.ts index 8bde91aa5..b4974d2ae 100644 --- a/backend/src/services/external-migration/external-migration-queue.ts +++ b/backend/src/services/external-migration/external-migration-queue.ts @@ -19,7 +19,7 @@ import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-d import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { importDataIntoInfisicalFn } from "./external-migration-fns"; -import { ExternalPlatforms, ImportType, TImportInfisicalDataCreate } from "./external-migration-types"; +import { ExternalPlatforms, TImportInfisicalDataCreate } from "./external-migration-types"; export type TExternalMigrationQueueFactoryDep = { smtpService: TSmtpService; @@ -66,8 +66,8 @@ export const externalMigrationQueueFactory = ({ }: TExternalMigrationQueueFactoryDep) => { const startImport = async (dto: { actorEmail: string; + importType: ExternalPlatforms; data: { - importType: ImportType; iv: string; tag: string; ciphertext: string; @@ -87,14 +87,14 @@ export const externalMigrationQueueFactory = ({ }; queueService.start(QueueName.ImportSecretsFromExternalSource, async (job) => { - try { - const { data, actorEmail } = job.data; + const { data, actorEmail, importType } = job.data; + try { await smtpService.sendMail({ recipients: [actorEmail], subjectLine: "Infisical import started", substitutions: { - provider: ExternalPlatforms.EnvKey + provider: importType }, template: SmtpTemplates.ExternalImportStarted }); @@ -141,7 +141,7 @@ export const externalMigrationQueueFactory = ({ recipients: [actorEmail], subjectLine: "Infisical import successful", substitutions: { - provider: ExternalPlatforms.EnvKey + provider: importType }, template: SmtpTemplates.ExternalImportSuccessful }); @@ -150,7 +150,7 @@ export const externalMigrationQueueFactory = ({ recipients: [job.data.actorEmail], subjectLine: "Infisical import failed", substitutions: { - provider: ExternalPlatforms.EnvKey, + provider: importType, // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment error: (err as any)?.message || "Unknown error" }, diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 766b6cef4..73fac00b9 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -1,17 +1,30 @@ import { OrgMembershipRole } from "@app/db/schemas"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { TUserDALFactory } from "../user/user-dal"; -import { decryptEnvKeyDataFn, importVaultDataFn, parseEnvKeyDataFn } from "./external-migration-fns"; +import { + decryptEnvKeyDataFn, + importVaultDataFn, + parseEnvKeyDataFn, + vaultMigrationTransformMappings +} from "./external-migration-fns"; import { TExternalMigrationQueueFactory } from "./external-migration-queue"; -import { ImportType, TImportEnvKeyDataDTO, TImportVaultDataDTO } from "./external-migration-types"; +import { + ExternalMigrationProviders, + ExternalPlatforms, + THasCustomVaultMigrationDTO, + TImportEnvKeyDataDTO, + TImportVaultDataDTO +} from "./external-migration-types"; type TExternalMigrationServiceFactoryDep = { permissionService: TPermissionServiceFactory; externalMigrationQueue: TExternalMigrationQueueFactory; userDAL: Pick; + gatewayService: Pick; }; export type TExternalMigrationServiceFactory = ReturnType; @@ -19,7 +32,8 @@ export type TExternalMigrationServiceFactory = ReturnType { const importEnvKeyData = async ({ decryptionKey, @@ -60,8 +74,8 @@ export const externalMigrationServiceFactory = ({ await externalMigrationQueue.startImport({ actorEmail: user.email!, + importType: ExternalPlatforms.EnvKey, data: { - importType: ImportType.EnvKey, ...encrypted } }); @@ -72,6 +86,7 @@ export const externalMigrationServiceFactory = ({ vaultNamespace, mappingType, vaultUrl, + gatewayId, actor, actorId, actorOrgId, @@ -91,12 +106,19 @@ export const externalMigrationServiceFactory = ({ const user = await userDAL.findById(actorId); - const vaultData = await importVaultDataFn({ - vaultAccessToken, - vaultNamespace, - vaultUrl, - mappingType - }); + const vaultData = await importVaultDataFn( + { + vaultAccessToken, + vaultNamespace, + vaultUrl, + mappingType, + gatewayId, + orgId: actorOrgId + }, + { + gatewayService + } + ); const stringifiedJson = JSON.stringify({ data: vaultData, @@ -110,15 +132,44 @@ export const externalMigrationServiceFactory = ({ await externalMigrationQueue.startImport({ actorEmail: user.email!, + importType: ExternalPlatforms.Vault, data: { - importType: ImportType.Vault, ...encrypted } }); }; + const hasCustomVaultMigration = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + provider + }: THasCustomVaultMigrationDTO) => { + const { membership } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + if (membership.role !== OrgMembershipRole.Admin) { + throw new ForbiddenRequestError({ message: "Only admins can check custom migration status" }); + } + + if (provider !== ExternalMigrationProviders.Vault) { + throw new BadRequestError({ + message: "Invalid provider. Vault is the only supported provider for custom migrations." + }); + } + + return actorOrgId in vaultMigrationTransformMappings; + }; + return { importEnvKeyData, - importVaultData + importVaultData, + hasCustomVaultMigration }; }; diff --git a/backend/src/services/external-migration/external-migration-types.ts b/backend/src/services/external-migration/external-migration-types.ts index 89589d674..804444172 100644 --- a/backend/src/services/external-migration/external-migration-types.ts +++ b/backend/src/services/external-migration/external-migration-types.ts @@ -2,14 +2,10 @@ import { TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; -export enum ImportType { - EnvKey = "envkey", - Vault = "vault" -} - export enum VaultMappingType { Namespace = "namespace", - KeyVault = "key-vault" + KeyVault = "key-vault", + Custom = "custom" } export type InfisicalImportData = { @@ -31,11 +27,16 @@ export type TImportEnvKeyDataDTO = { encryptedJson: { nonce: string; data: string }; } & Omit; +export type THasCustomVaultMigrationDTO = { + provider: ExternalMigrationProviders; +} & Omit; + export type TImportVaultDataDTO = { vaultAccessToken: string; vaultNamespace?: string; mappingType: VaultMappingType; vaultUrl: string; + gatewayId?: string; } & Omit; export type TImportInfisicalDataCreate = { @@ -112,5 +113,11 @@ export type TEnvKeyExportJSON = { }; export enum ExternalPlatforms { - EnvKey = "EnvKey" + EnvKey = "EnvKey", + Vault = "Vault" +} + +export enum ExternalMigrationProviders { + Vault = "vault", + EnvKey = "env-key" } 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/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts index 50a08e94f..838d89437 100644 --- a/backend/src/services/group-project/group-project-service.ts +++ b/backend/src/services/group-project/group-project-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { ActionProjectType, ProjectMembershipRole, SecretKeyEncoding, TGroups } from "@app/db/schemas"; +import { ActionProjectType, ProjectMembershipRole, ProjectVersion, SecretKeyEncoding, TGroups } from "@app/db/schemas"; import { TListProjectGroupUsersDTO } from "@app/ee/services/group/group-types"; import { constructPermissionErrorMessage, @@ -188,7 +188,7 @@ export const groupProjectServiceFactory = ({ // other groups that are in the project const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group!.id, project.id, tx); - if (groupMembers.length) { + if (groupMembers.length && (project.version === ProjectVersion.V1 || project.version === ProjectVersion.V2)) { const ghostUser = await projectDAL.findProjectGhostUser(project.id, tx); if (!ghostUser) { @@ -205,6 +205,12 @@ export const groupProjectServiceFactory = ({ }); } + if (!ghostUserLatestKey.sender.publicKey) { + throw new NotFoundError({ + message: `Failed to find project owner's latest key in project with name ${project.name}` + }); + } + const bot = await projectBotDAL.findOne({ projectId: project.id }, tx); if (!bot) { @@ -231,6 +237,12 @@ export const groupProjectServiceFactory = ({ }); const projectKeyData = groupMembers.map(({ user: { publicKey, id } }) => { + if (!publicKey) { + throw new NotFoundError({ + message: `Failed to find user's public key in project with name ${project.name}` + }); + } + const { ciphertext: encryptedKey, nonce } = crypto .encryption() .asymmetric() diff --git a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts index 819329a22..af94c79c9 100644 --- a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts +++ b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts @@ -38,7 +38,7 @@ type TIdentityAliCloudAuthServiceFactoryDep = { TIdentityAliCloudAuthDALFactory, "findOne" | "transaction" | "create" | "updateById" | "delete" >; - identityOrgMembershipDAL: Pick; + identityOrgMembershipDAL: Pick; licenseService: Pick; permissionService: Pick; }; @@ -64,6 +64,8 @@ export const identityAliCloudAuthServiceFactory = ({ identityId: identityAliCloudAuth.identityId }); + if (!identityMembershipOrg) throw new UnauthorizedError({ message: "Identity not attached to a organization" }); + const requestUrl = new URL("https://sts.aliyuncs.com"); for (const key of Object.keys(params)) { @@ -87,6 +89,14 @@ export const identityAliCloudAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH, + lastLoginTime: new Date() + }, + tx + ); const newToken = await identityAccessTokenDAL.create( { identityId: identityAliCloudAuth.identityId, diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index b5035946e..3dff47403 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -36,7 +36,7 @@ import { type TIdentityAwsAuthServiceFactoryDep = { identityAccessTokenDAL: Pick; identityAwsAuthDAL: Pick; - identityOrgMembershipDAL: Pick; + identityOrgMembershipDAL: Pick; licenseService: Pick; permissionService: Pick; }; @@ -91,6 +91,7 @@ export const identityAwsAuthServiceFactory = ({ } const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAwsAuth.identityId }); + if (!identityMembershipOrg) throw new UnauthorizedError({ message: "Identity not attached to a organization" }); const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); const body: string = Buffer.from(iamRequestBody, "base64").toString(); @@ -152,6 +153,14 @@ export const identityAwsAuthServiceFactory = ({ } const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH, + lastLoginTime: new Date() + }, + tx + ); const newToken = await identityAccessTokenDAL.create( { identityId: identityAwsAuth.identityId, diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts index 35103c8cf..9a2426a7c 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -33,7 +33,7 @@ type TIdentityAzureAuthServiceFactoryDep = { TIdentityAzureAuthDALFactory, "findOne" | "transaction" | "create" | "updateById" | "delete" >; - identityOrgMembershipDAL: Pick; + identityOrgMembershipDAL: Pick; identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; @@ -80,6 +80,14 @@ export const identityAzureAuthServiceFactory = ({ } const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH, + lastLoginTime: new Date() + }, + tx + ); const newToken = await identityAccessTokenDAL.create( { identityId: identityAzureAuth.identityId, diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts index b83697e52..388c24d48 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -31,7 +31,7 @@ import { type TIdentityGcpAuthServiceFactoryDep = { identityGcpAuthDAL: Pick; - identityOrgMembershipDAL: Pick; + identityOrgMembershipDAL: Pick; identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; @@ -119,6 +119,14 @@ export const identityGcpAuthServiceFactory = ({ } const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH, + lastLoginTime: new Date() + }, + tx + ); const newToken = await identityAccessTokenDAL.create( { identityId: identityGcpAuth.identityId, 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 35063ae70..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"; @@ -43,7 +43,7 @@ import { type TIdentityJwtAuthServiceFactoryDep = { identityJwtAuthDAL: TIdentityJwtAuthDALFactory; - identityOrgMembershipDAL: Pick; + identityOrgMembershipDAL: Pick; identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; @@ -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}` }); @@ -209,6 +207,14 @@ export const identityJwtAuthServiceFactory = ({ } const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, + lastLoginTime: new Date() + }, + tx + ); const newToken = await identityAccessTokenDAL.create( { identityId: identityJwtAuth.identityId, diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 41491ab2f..9584b122a 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -49,7 +49,7 @@ type TIdentityKubernetesAuthServiceFactoryDep = { "create" | "findOne" | "transaction" | "updateById" | "delete" >; identityAccessTokenDAL: Pick; - identityOrgMembershipDAL: Pick; + identityOrgMembershipDAL: Pick; permissionService: Pick; licenseService: Pick; kmsService: Pick; @@ -380,6 +380,14 @@ export const identityKubernetesAuthServiceFactory = ({ } const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => { + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, + lastLoginTime: new Date() + }, + tx + ); const newToken = await identityAccessTokenDAL.create( { identityId: identityKubernetesAuth.identityId, diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index 399ef7da9..47188e26d 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -2,9 +2,14 @@ import { ForbiddenError } from "@casl/ability"; import { IdentityAuthMethod } from "@app/db/schemas"; +import { TIdentityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template"; import { testLDAPConfig } from "@app/ee/services/ldap-config/ldap-fns"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + OrgPermissionIdentityActions, + OrgPermissionMachineIdentityAuthTemplateActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; import { constructPermissionErrorMessage, validatePrivilegeChangeOperation @@ -39,11 +44,12 @@ type TIdentityLdapAuthServiceFactoryDep = { TIdentityLdapAuthDALFactory, "findOne" | "transaction" | "create" | "updateById" | "delete" >; - identityOrgMembershipDAL: Pick; + identityOrgMembershipDAL: Pick; licenseService: Pick; permissionService: Pick; kmsService: TKmsServiceFactory; identityDAL: TIdentityDALFactory; + identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; }; export type TIdentityLdapAuthServiceFactory = ReturnType; @@ -55,7 +61,8 @@ export const identityLdapAuthServiceFactory = ({ identityOrgMembershipDAL, licenseService, permissionService, - kmsService + kmsService, + identityAuthTemplateDAL }: TIdentityLdapAuthServiceFactoryDep) => { const getLdapConfig = async (identityId: string) => { const identity = await identityDAL.findOne({ id: identityId }); @@ -137,6 +144,14 @@ export const identityLdapAuthServiceFactory = ({ } const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => { + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, + lastLoginTime: new Date() + }, + tx + ); const newToken = await identityAccessTokenDAL.create( { identityId: identityLdapAuth.identityId, @@ -173,6 +188,7 @@ export const identityLdapAuthServiceFactory = ({ const attachLdapAuth = async ({ identityId, + templateId, url, searchBase, searchFilter, @@ -213,6 +229,14 @@ export const identityLdapAuthServiceFactory = ({ actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + + if (templateId) { + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + } + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); if (!plan.ldap) { @@ -241,33 +265,55 @@ export const identityLdapAuthServiceFactory = ({ if (allowedFields) AllowedFieldsSchema.array().parse(allowedFields); const identityLdapAuth = await identityLdapAuthDAL.transaction(async (tx) => { - const { encryptor } = await kmsService.createCipherPairWithDataKey({ + const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityMembershipOrg.orgId }); + const template = templateId + ? await identityAuthTemplateDAL.findByIdAndOrgId(templateId, identityMembershipOrg.orgId) + : undefined; + + let ldapConfig: { bindDN: string; bindPass: string; searchBase: string; url: string; ldapCaCertificate?: string }; + if (template) { + ldapConfig = JSON.parse(decryptor({ cipherTextBlob: template.templateFields }).toString()); + } else { + if (!bindDN || !bindPass || !searchBase || !url) { + throw new BadRequestError({ + message: "Invalid request. Missing bind DN, bind pass, search base, or URL." + }); + } + ldapConfig = { + bindDN, + bindPass, + searchBase, + url, + ldapCaCertificate + }; + } + const { cipherTextBlob: encryptedBindPass } = encryptor({ - plainText: Buffer.from(bindPass) + plainText: Buffer.from(ldapConfig.bindPass) + }); + + const { cipherTextBlob: encryptedBindDN } = encryptor({ + plainText: Buffer.from(ldapConfig.bindDN) }); let encryptedLdapCaCertificate: Buffer | undefined; - if (ldapCaCertificate) { + if (ldapConfig.ldapCaCertificate) { const { cipherTextBlob: encryptedCertificate } = encryptor({ - plainText: Buffer.from(ldapCaCertificate) + plainText: Buffer.from(ldapConfig.ldapCaCertificate) }); encryptedLdapCaCertificate = encryptedCertificate; } - const { cipherTextBlob: encryptedBindDN } = encryptor({ - plainText: Buffer.from(bindDN) - }); - const isConnected = await testLDAPConfig({ - bindDN, - bindPass, - caCert: ldapCaCertificate || "", - url + bindDN: ldapConfig.bindDN, + bindPass: ldapConfig.bindPass, + caCert: ldapConfig.ldapCaCertificate || "", + url: ldapConfig.url }); if (!isConnected) { @@ -282,15 +328,16 @@ export const identityLdapAuthServiceFactory = ({ identityId: identityMembershipOrg.identityId, encryptedBindDN, encryptedBindPass, - searchBase, + searchBase: ldapConfig.searchBase, searchFilter, - url, + url: ldapConfig.url, encryptedLdapCaCertificate, accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), - allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined + allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined, + templateId }, tx ); @@ -301,6 +348,7 @@ export const identityLdapAuthServiceFactory = ({ const updateLdapAuth = async ({ identityId, + templateId, url, searchBase, searchFilter, @@ -344,6 +392,13 @@ export const identityLdapAuthServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (templateId) { + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + } + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); if (!plan.ldap) { @@ -371,33 +426,56 @@ export const identityLdapAuthServiceFactory = ({ if (allowedFields) AllowedFieldsSchema.array().parse(allowedFields); - const { encryptor } = await kmsService.createCipherPairWithDataKey({ + const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityMembershipOrg.orgId }); + const template = templateId + ? await identityAuthTemplateDAL.findByIdAndOrgId(templateId, identityMembershipOrg.orgId) + : undefined; + let config: { + bindDN?: string; + bindPass?: string; + searchBase?: string; + url?: string; + ldapCaCertificate?: string; + }; + + if (template) { + config = JSON.parse(decryptor({ cipherTextBlob: template.templateFields }).toString()); + } else { + config = { + bindDN, + bindPass, + searchBase, + url, + ldapCaCertificate + }; + } + let encryptedBindPass: Buffer | undefined; - if (bindPass) { + if (config.bindPass) { const { cipherTextBlob: bindPassCiphertext } = encryptor({ - plainText: Buffer.from(bindPass) + plainText: Buffer.from(config.bindPass) }); encryptedBindPass = bindPassCiphertext; } let encryptedLdapCaCertificate: Buffer | undefined; - if (ldapCaCertificate) { + if (config.ldapCaCertificate) { const { cipherTextBlob: ldapCaCertificateCiphertext } = encryptor({ - plainText: Buffer.from(ldapCaCertificate) + plainText: Buffer.from(config.ldapCaCertificate) }); encryptedLdapCaCertificate = ldapCaCertificateCiphertext; } let encryptedBindDN: Buffer | undefined; - if (bindDN) { + if (config.bindDN) { const { cipherTextBlob: bindDNCiphertext } = encryptor({ - plainText: Buffer.from(bindDN) + plainText: Buffer.from(config.bindDN) }); encryptedBindDN = bindDNCiphertext; @@ -406,10 +484,10 @@ export const identityLdapAuthServiceFactory = ({ const { ldapConfig } = await getLdapConfig(identityId); const isConnected = await testLDAPConfig({ - bindDN: bindDN || ldapConfig.bindDN, - bindPass: bindPass || ldapConfig.bindPass, - caCert: ldapCaCertificate || ldapConfig.caCert, - url: url || ldapConfig.url + bindDN: config.bindDN || ldapConfig.bindDN, + bindPass: config.bindPass || ldapConfig.bindPass, + caCert: config.ldapCaCertificate || ldapConfig.caCert, + url: config.url || ldapConfig.url }); if (!isConnected) { @@ -420,14 +498,15 @@ export const identityLdapAuthServiceFactory = ({ } const updatedLdapAuth = await identityLdapAuthDAL.updateById(identityLdapAuth.id, { - url, - searchBase, + url: config.url, + searchBase: config.searchBase, searchFilter, encryptedBindDN, encryptedBindPass, encryptedLdapCaCertificate, allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined, accessTokenMaxTTL, + templateId: template?.id || null, accessTokenTTL, accessTokenNumUsesLimit, accessTokenTrustedIps: reformattedAccessTokenTrustedIps diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts index 0e6feb5fb..8629763bb 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts @@ -14,11 +14,12 @@ export type TAllowedFields = z.infer; export type TAttachLdapAuthDTO = { identityId: string; - url: string; - searchBase: string; + templateId?: string; + url?: string; + searchBase?: string; searchFilter: string; - bindDN: string; - bindPass: string; + bindDN?: string; + bindPass?: string; ldapCaCertificate?: string; allowedFields?: TAllowedFields[]; accessTokenTTL: number; @@ -30,6 +31,7 @@ export type TAttachLdapAuthDTO = { export type TUpdateLdapAuthDTO = { identityId: string; + templateId?: string; url?: string; searchBase?: string; searchFilter?: string; diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts index 3b6450e6e..a4294250c 100644 --- a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts @@ -36,7 +36,7 @@ import { type TIdentityOciAuthServiceFactoryDep = { identityAccessTokenDAL: Pick; identityOciAuthDAL: Pick; - identityOrgMembershipDAL: Pick; + identityOrgMembershipDAL: Pick; licenseService: Pick; permissionService: Pick; }; @@ -57,6 +57,7 @@ export const identityOciAuthServiceFactory = ({ } const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityOciAuth.identityId }); + if (!identityMembershipOrg) throw new UnauthorizedError({ message: "Identity not attached to a organization" }); // Validate OCI host format. Ensures that the host is in "identity..oraclecloud.com" format. if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) { @@ -91,6 +92,14 @@ export const identityOciAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => { + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, + lastLoginTime: new Date() + }, + tx + ); const newToken = await identityAccessTokenDAL.create( { identityId: identityOciAuth.identityId, 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 08d53a344..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"; @@ -43,7 +43,7 @@ import { type TIdentityOidcAuthServiceFactoryDep = { identityOidcAuthDAL: TIdentityOidcAuthDALFactory; - identityOrgMembershipDAL: Pick; + identityOrgMembershipDAL: Pick; identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; @@ -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,17 +167,25 @@ 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(); }); } const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => { + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, + lastLoginTime: new Date() + }, + tx + ); const newToken = await identityAccessTokenDAL.create( { identityId: identityOidcAuth.identityId, diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts index 742633b50..ef2463eec 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts @@ -30,7 +30,7 @@ type TIdentityTlsCertAuthServiceFactoryDep = { TIdentityTlsCertAuthDALFactory, "findOne" | "transaction" | "create" | "updateById" | "delete" >; - identityOrgMembershipDAL: Pick; + identityOrgMembershipDAL: Pick; licenseService: Pick; permissionService: Pick; kmsService: Pick; @@ -118,6 +118,14 @@ export const identityTlsCertAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => { + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, + lastLoginTime: new Date() + }, + tx + ); const newToken = await identityAccessTokenDAL.create( { identityId: identityTlsCertAuth.identityId, diff --git a/backend/src/services/identity-token-auth/identity-token-auth-service.ts b/backend/src/services/identity-token-auth/identity-token-auth-service.ts index 82949090e..d3743bd96 100644 --- a/backend/src/services/identity-token-auth/identity-token-auth-service.ts +++ b/backend/src/services/identity-token-auth/identity-token-auth-service.ts @@ -35,7 +35,7 @@ type TIdentityTokenAuthServiceFactoryDep = { TIdentityTokenAuthDALFactory, "transaction" | "create" | "findOne" | "updateById" | "delete" >; - identityOrgMembershipDAL: Pick; + identityOrgMembershipDAL: Pick; identityAccessTokenDAL: Pick< TIdentityAccessTokenDALFactory, "create" | "find" | "update" | "findById" | "findOne" | "updateById" | "delete" @@ -345,6 +345,14 @@ export const identityTokenAuthServiceFactory = ({ const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); const identityAccessToken = await identityTokenAuthDAL.transaction(async (tx) => { + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.TOKEN_AUTH, + lastLoginTime: new Date() + }, + tx + ); const newToken = await identityAccessTokenDAL.create( { identityId: identityTokenAuth.identityId, diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index cf5ccdeb7..8aec16371 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -8,10 +8,18 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + NotFoundError, + PermissionBoundaryError, + RateLimitError, + UnauthorizedError +} from "@app/lib/errors"; import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -22,6 +30,7 @@ import { TIdentityUaClientSecretDALFactory } from "./identity-ua-client-secret-d import { TIdentityUaDALFactory } from "./identity-ua-dal"; import { TAttachUaDTO, + TClearUaLockoutsDTO, TCreateUaClientSecretDTO, TGetUaClientSecretsDTO, TGetUaDTO, @@ -38,138 +47,220 @@ type TIdentityUaServiceFactoryDep = { identityOrgMembershipDAL: TIdentityOrgDALFactory; permissionService: Pick; licenseService: Pick; + keyStore: Pick< + TKeyStoreFactory, + "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock" + >; }; export type TIdentityUaServiceFactory = ReturnType; +type LockoutObject = { + lockedOut: boolean; + failedAttempts: number; +}; + export const identityUaServiceFactory = ({ identityUaDAL, identityUaClientSecretDAL, identityAccessTokenDAL, identityOrgMembershipDAL, permissionService, - licenseService + licenseService, + keyStore }: TIdentityUaServiceFactoryDep) => { const login = async (clientId: string, clientSecret: string, ip: string) => { const identityUa = await identityUaDAL.findOne({ clientId }); if (!identityUa) { - throw new NotFoundError({ - message: "No identity with specified client ID was found" + throw new UnauthorizedError({ + message: "Invalid credentials" }); } - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); - checkIPAgainstBlocklist({ ipAddress: ip, trustedIps: identityUa.clientSecretTrustedIps as TIp[] }); - const clientSecretPrefix = clientSecret.slice(0, 4); - const clientSecrtInfo = await identityUaClientSecretDAL.find({ - identityUAId: identityUa.id, - isClientSecretRevoked: false, - clientSecretPrefix - }); - let validClientSecretInfo: (typeof clientSecrtInfo)[0] | null = null; - for await (const info of clientSecrtInfo) { - const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); + const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; - if (isMatch) { - validClientSecretInfo = info; - break; + let lock: Awaited> | undefined; + if (identityUa.lockoutEnabled) { + try { + lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, { + retryCount: 3, + retryDelay: 300, + retryJitter: 100 + }); + } catch (e) { + logger.info( + `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` + ); + throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" }); } } - if (!validClientSecretInfo) throw new UnauthorizedError({ message: "Invalid credentials" }); + try { + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); - const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; - if (Number(clientSecretTTL) > 0) { - const clientSecretCreated = new Date(validClientSecretInfo.createdAt); - const ttlInMilliseconds = Number(clientSecretTTL) * 1000; - const currentDate = new Date(); - const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + let lockout: LockoutObject | undefined; + if (lockoutRaw) { + lockout = JSON.parse(lockoutRaw) as LockoutObject; + } - if (currentDate > expirationTime) { + if (lockout && lockout.lockedOut) { + throw new UnauthorizedError({ + message: "This identity auth method is temporarily locked, please try again later" + }); + } + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); + if (!identityMembershipOrg) { + throw new UnauthorizedError({ + message: "Invalid credentials" + }); + } + + const clientSecretPrefix = clientSecret.slice(0, 4); + const clientSecretInfo = await identityUaClientSecretDAL.find({ + identityUAId: identityUa.id, + isClientSecretRevoked: false, + clientSecretPrefix + }); + + let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null; + for await (const info of clientSecretInfo) { + const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); + + if (isMatch) { + validClientSecretInfo = info; + break; + } + } + + if (!validClientSecretInfo) { + if (identityUa.lockoutEnabled) { + if (!lockout) { + lockout = { + lockedOut: false, + failedAttempts: 0 + }; + } + + lockout.failedAttempts += 1; + if (lockout.failedAttempts >= identityUa.lockoutThreshold) { + lockout.lockedOut = true; + } + + await keyStore.setItemWithExpiry( + LOCKOUT_KEY, + lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds, + JSON.stringify(lockout) + ); + } + + throw new UnauthorizedError({ message: "Invalid credentials" }); + } else if (lockout) { + await keyStore.deleteItem(LOCKOUT_KEY); + } + + const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; + if (Number(clientSecretTTL) > 0) { + const clientSecretCreated = new Date(validClientSecretInfo.createdAt); + const ttlInMilliseconds = Number(clientSecretTTL) * 1000; + const currentDate = new Date(); + const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationTime) { + await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { + isClientSecretRevoked: true + }); + + throw new UnauthorizedError({ + message: "Access denied due to expired client secret" + }); + } + } + + if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) { + // number of times client secret can be used for + // a login operation reached await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { isClientSecretRevoked: true }); - throw new UnauthorizedError({ - message: "Access denied due to expired client secret" + message: "Access denied due to client secret usage limit reached" }); } - } - if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) { - // number of times client secret can be used for - // a login operation reached - await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { - isClientSecretRevoked: true + const accessTokenTTLParams = + Number(identityUa.accessTokenPeriod) === 0 + ? { + accessTokenTTL: identityUa.accessTokenTTL, + accessTokenMaxTTL: identityUa.accessTokenMaxTTL + } + : { + accessTokenTTL: identityUa.accessTokenPeriod, + // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token + // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" + accessTokenMaxTTL: 1000000000 + }; + + const identityAccessToken = await identityUaDAL.transaction(async (tx) => { + const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityUa.identityId, + isAccessTokenRevoked: false, + identityUAClientSecretId: uaClientSecretDoc.id, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, + accessTokenPeriod: identityUa.accessTokenPeriod, + authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + ...accessTokenTTLParams + }, + tx + ); + + return newToken; }); - throw new UnauthorizedError({ - message: "Access denied due to client secret usage limit reached" - }); - } - const accessTokenTTLParams = - Number(identityUa.accessTokenPeriod) === 0 - ? { - accessTokenTTL: identityUa.accessTokenTTL, - accessTokenMaxTTL: identityUa.accessTokenMaxTTL - } - : { - accessTokenTTL: identityUa.accessTokenPeriod, - // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token - // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" - accessTokenMaxTTL: 1000000000 - }; - - const identityAccessToken = await identityUaDAL.transaction(async (tx) => { - const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); - - const newToken = await identityAccessTokenDAL.create( + const appCfg = getConfig(); + const accessToken = crypto.jwt().sign( { identityId: identityUa.identityId, - isAccessTokenRevoked: false, - identityUAClientSecretId: uaClientSecretDoc.id, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, - accessTokenPeriod: identityUa.accessTokenPeriod, - authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, - ...accessTokenTTLParams - }, - tx + clientSecretId: validClientSecretInfo.id, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityUa.identityId, - clientSecretId: validClientSecretInfo.id, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); - - return { - accessToken, - identityUa, - validClientSecretInfo, - identityAccessToken, - identityMembershipOrg, - ...accessTokenTTLParams - }; + return { + accessToken, + identityUa, + validClientSecretInfo, + identityAccessToken, + identityMembershipOrg, + ...accessTokenTTLParams + }; + } finally { + if (lock) await lock.release(); + } }; const attachUniversalAuth = async ({ @@ -184,7 +275,11 @@ export const identityUaServiceFactory = ({ actor, actorOrgId, isActorSuperAdmin, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }: TAttachUaDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); @@ -254,7 +349,11 @@ export const identityUaServiceFactory = ({ accessTokenTTL, accessTokenNumUsesLimit, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }, tx ); @@ -274,7 +373,11 @@ export const identityUaServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }: TUpdateUaDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -350,7 +453,11 @@ export const identityUaServiceFactory = ({ accessTokenPeriod, accessTokenTrustedIps: reformattedAccessTokenTrustedIps ? JSON.stringify(reformattedAccessTokenTrustedIps) - : undefined + : undefined, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return { ...updatedUaAuth, orgId: identityMembershipOrg.orgId }; }; @@ -701,6 +808,38 @@ export const identityUaServiceFactory = ({ return { ...updatedClientSecret, identityId, orgId: identityMembershipOrg.orgId }; }; + const clearUniversalAuthLockouts = async ({ + identityId, + actorId, + actor, + actorOrgId, + actorAuthMethod + }: TClearUaLockoutsDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have universal auth" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const deleted = await keyStore.deleteItems({ + pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:*` + }); + + return { deleted, identityId, orgId: identityMembershipOrg.orgId }; + }; + return { login, attachUniversalAuth, @@ -710,6 +849,7 @@ export const identityUaServiceFactory = ({ createUniversalAuthClientSecret, getUniversalAuthClientSecrets, revokeUniversalAuthClientSecret, - getUniversalAuthClientSecretById + getUniversalAuthClientSecretById, + clearUniversalAuthLockouts }; }; diff --git a/backend/src/services/identity-ua/identity-ua-types.ts b/backend/src/services/identity-ua/identity-ua-types.ts index f7938e0f7..8e7644b58 100644 --- a/backend/src/services/identity-ua/identity-ua-types.ts +++ b/backend/src/services/identity-ua/identity-ua-types.ts @@ -9,6 +9,10 @@ export type TAttachUaDTO = { clientSecretTrustedIps: { ipAddress: string }[]; accessTokenTrustedIps: { ipAddress: string }[]; isActorSuperAdmin?: boolean; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; } & Omit; export type TUpdateUaDTO = { @@ -19,6 +23,10 @@ export type TUpdateUaDTO = { accessTokenPeriod?: number; clientSecretTrustedIps?: { ipAddress: string }[]; accessTokenTrustedIps?: { ipAddress: string }[]; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; } & Omit; export type TGetUaDTO = { @@ -45,6 +53,10 @@ export type TRevokeUaClientSecretDTO = { clientSecretId: string; } & Omit; +export type TClearUaLockoutsDTO = { + identityId: string; +} & Omit; + export type TGetUniversalAuthClientSecretByIdDTO = { identityId: string; clientSecretId: string; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index 3c5ca8ffe..c083df5aa 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -254,6 +254,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("role").withSchema("paginatedIdentity"), db.ref("roleId").withSchema("paginatedIdentity"), db.ref("orgId").withSchema("paginatedIdentity"), + db.ref("lastLoginAuthMethod").withSchema("paginatedIdentity"), + db.ref("lastLoginTime").withSchema("paginatedIdentity"), db.ref("createdAt").withSchema("paginatedIdentity"), db.ref("updatedAt").withSchema("paginatedIdentity"), db.ref("identityId").withSchema("paginatedIdentity").as("identityId"), @@ -319,7 +321,9 @@ export const identityOrgDALFactory = (db: TDbClient) => { ldapId, tlsCertId, createdAt, - updatedAt + updatedAt, + lastLoginAuthMethod, + lastLoginTime }) => ({ role, roleId, @@ -328,6 +332,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { orgId, createdAt, updatedAt, + lastLoginAuthMethod, + lastLoginTime, customRole: roleId ? { id: crId, @@ -497,6 +503,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("orgId").withSchema(TableName.IdentityOrgMembership), db.ref("createdAt").withSchema(TableName.IdentityOrgMembership), db.ref("updatedAt").withSchema(TableName.IdentityOrgMembership), + db.ref("lastLoginAuthMethod").withSchema(TableName.IdentityOrgMembership), + db.ref("lastLoginTime").withSchema(TableName.IdentityOrgMembership), db.ref("identityId").withSchema(TableName.IdentityOrgMembership).as("identityId"), db.ref("name").withSchema(TableName.Identity).as("identityName"), db.ref("hasDeleteProtection").withSchema(TableName.Identity), @@ -531,10 +539,10 @@ export const identityOrgDALFactory = (db: TDbClient) => { } else if (orderBy === OrgIdentityOrderBy.Role) { void query.orderByRaw( ` - CASE - WHEN ??.role = ? - THEN ??.slug - ELSE ??.role + CASE + WHEN ??.role = ? + THEN ??.slug + ELSE ??.role END ? `, [ @@ -576,7 +584,9 @@ export const identityOrgDALFactory = (db: TDbClient) => { tokenId, ldapId, createdAt, - updatedAt + updatedAt, + lastLoginTime, + lastLoginAuthMethod }) => ({ role, roleId, @@ -586,6 +596,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { orgId, createdAt, updatedAt, + lastLoginTime, + lastLoginAuthMethod, customRole: roleId ? { id: crId, diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 7c76520b3..969d00331 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -8,6 +8,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; @@ -32,6 +33,7 @@ type TIdentityServiceFactoryDep = { identityProjectDAL: Pick; permissionService: Pick; licenseService: Pick; + keyStore: Pick; }; export type TIdentityServiceFactory = ReturnType; @@ -42,7 +44,8 @@ export const identityServiceFactory = ({ identityOrgMembershipDAL, identityProjectDAL, permissionService, - licenseService + licenseService, + keyStore }: TIdentityServiceFactoryDep) => { const createIdentity = async ({ name, @@ -255,7 +258,20 @@ export const identityServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - return identity; + const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`); + + const activeLockoutAuthMethods = new Set(); + activeLockouts.forEach((key) => { + const parts = key.split(":"); + if (parts.length > 3) { + activeLockoutAuthMethods.add(parts[3]); + } + }); + + return { + ...identity, + identity: { ...identity.identity, activeLockoutAuthMethods: Array.from(activeLockoutAuthMethods) } + }; }; const deleteIdentity = async ({ diff --git a/backend/src/services/integration-auth/integration-delete-secret.ts b/backend/src/services/integration-auth/integration-delete-secret.ts index 2efec7a47..eaad08df8 100644 --- a/backend/src/services/integration-auth/integration-delete-secret.ts +++ b/backend/src/services/integration-auth/integration-delete-secret.ts @@ -39,7 +39,7 @@ const getIntegrationSecretsV2 = async ( }, secretV2BridgeDAL: Pick, folderDAL: Pick, - secretImportDAL: Pick + secretImportDAL: Pick ) => { const content: Record = {}; if (dto.depth > MAX_SYNC_SECRET_DEPTH) { @@ -300,7 +300,7 @@ export const deleteIntegrationSecrets = async ({ projectBotService: Pick; secretV2BridgeDAL: Pick; folderDAL: Pick; - secretImportDAL: Pick; + secretImportDAL: Pick; secretDAL: Pick; kmsService: Pick; }) => { diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 2ef8615eb..463ca12e3 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -40,7 +40,7 @@ type TIntegrationServiceFactoryDep = { projectBotService: TProjectBotServiceFactory; secretQueueService: Pick; secretV2BridgeDAL: Pick; - secretImportDAL: Pick; + secretImportDAL: Pick; kmsService: Pick; secretDAL: Pick; }; 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/notification/notification-queue.ts b/backend/src/services/notification/notification-queue.ts new file mode 100644 index 000000000..e5d89c83b --- /dev/null +++ b/backend/src/services/notification/notification-queue.ts @@ -0,0 +1,39 @@ +import { QueueJobs, TQueueServiceFactory } from "@app/queue"; + +import { TCreateUserNotificationDTO } from "./notification-types"; +import { TUserNotificationDALFactory } from "./user-notification-dal"; + +type TNotificationQueueServiceFactoryDep = { + userNotificationDAL: Pick; + queueService: TQueueServiceFactory; +}; + +export type TNotificationQueueServiceFactory = { + pushUserNotifications: (data: TCreateUserNotificationDTO[]) => Promise; +}; + +export const notificationQueueServiceFactory = async ({ + userNotificationDAL, + queueService +}: TNotificationQueueServiceFactoryDep): Promise => { + const pushUserNotifications = async (data: TCreateUserNotificationDTO[]) => { + await queueService.queuePg(QueueJobs.UserNotification, { notifications: data }); + }; + + await queueService.startPg( + QueueJobs.UserNotification, + async ([job]) => { + const { notifications } = job.data as { notifications: TCreateUserNotificationDTO[] }; + await userNotificationDAL.batchInsert(notifications); + }, + { + batchSize: 1, + workerCount: 2, + pollingIntervalSeconds: 1 + } + ); + + return { + pushUserNotifications + }; +}; diff --git a/backend/src/services/notification/notification-service.ts b/backend/src/services/notification/notification-service.ts new file mode 100644 index 000000000..ed50c3f1c --- /dev/null +++ b/backend/src/services/notification/notification-service.ts @@ -0,0 +1,81 @@ +import { NotFoundError, UnauthorizedError } from "@app/lib/errors"; + +import { TNotificationQueueServiceFactory } from "./notification-queue"; +import { TCreateUserNotificationDTO } from "./notification-types"; +import { TUserNotificationDALFactory } from "./user-notification-dal"; + +type TNotificationServiceFactoryDep = { + notificationQueue: TNotificationQueueServiceFactory; + userNotificationDAL: TUserNotificationDALFactory; +}; + +export type TNotificationServiceFactory = ReturnType; + +export const notificationServiceFactory = ({ + notificationQueue, + userNotificationDAL +}: TNotificationServiceFactoryDep) => { + const listUserNotifications = async ({ userId, orgId }: { userId: string; orgId: string }) => { + const now = new Date(); + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + + const notifications = await userNotificationDAL.find({ + userId, + orgId, + startDate: threeMonthsAgo.toISOString(), + endDate: now.toISOString() + }); + + return notifications; + }; + + const createUserNotifications = async (data: TCreateUserNotificationDTO[]) => { + return notificationQueue.pushUserNotifications(data); + }; + + const deleteUserNotification = async ({ userId, notificationId }: { userId: string; notificationId: string }) => { + if (!userId) throw new UnauthorizedError({ message: "Invalid userId" }); + + const deletedNotifications = await userNotificationDAL.delete({ id: notificationId, userId }); + + if (deletedNotifications.length <= 0) throw new NotFoundError({ message: "Notification not found" }); + + return deletedNotifications[0]; + }; + + const markUserNotificationsAsRead = async ({ userId, orgId }: { userId: string; orgId: string }) => { + await userNotificationDAL.markAllNotificationsAsRead(userId, orgId); + }; + + const updateUserNotification = async ({ + userId, + notificationId, + isRead + }: { + userId: string; + notificationId: string; + isRead: boolean; + }) => { + const [updatedNotification] = await userNotificationDAL.update( + { + id: notificationId, + userId + }, + { + isRead + } + ); + + if (!updatedNotification) throw new NotFoundError({ message: "Notification not found" }); + return updatedNotification; + }; + + return { + listUserNotifications, + createUserNotifications, + deleteUserNotification, + markUserNotificationsAsRead, + updateUserNotification + }; +}; diff --git a/backend/src/services/notification/notification-types.ts b/backend/src/services/notification/notification-types.ts new file mode 100644 index 000000000..30bc87244 --- /dev/null +++ b/backend/src/services/notification/notification-types.ts @@ -0,0 +1,15 @@ +export enum NotificationType { + ACCESS_APPROVAL_REQUEST = "access-approval-request", + ACCESS_APPROVAL_REQUEST_UPDATED = "access-approval-request-updated" +} + +export interface TCreateUserNotificationDTO { + userId: string; + // Adding an orgId will make the notification only show up when a user is in a certain org. Otherwise, it shows up in all orgs. + // Keep in mind that org-scoped links for a notification will break if orgId is missing and the user is in the wrong org + orgId?: string; + type: NotificationType; + title: string; + body?: string; + link?: string; +} diff --git a/backend/src/services/notification/user-notification-dal.ts b/backend/src/services/notification/user-notification-dal.ts new file mode 100644 index 000000000..6dcb2e3d0 --- /dev/null +++ b/backend/src/services/notification/user-notification-dal.ts @@ -0,0 +1,130 @@ +import knex from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError, GatewayTimeoutError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; +import { QueueName } from "@app/queue"; + +export type TUserNotificationDALFactory = ReturnType; + +const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const PRUNE_BATCH_SIZE = 10000; +const MAX_RETRY_ON_FAILURE = 3; + +export const userNotificationDALFactory = (db: TDbClient) => { + const notificationOrm = ormify(db, TableName.UserNotifications); + + const find = async ( + { + userId, + orgId, + startDate, + endDate, + limit = 1000, + offset = 0 + }: { + userId: string; + orgId: string; + startDate: string; + endDate: string; + limit?: number; + offset?: number; + }, + tx?: knex.Knex + ) => { + try { + const docs = await (tx || db.replicaNode())(TableName.UserNotifications) + .where(`${TableName.UserNotifications}.userId`, userId) + .andWhere((qb) => { + void qb + .where(`${TableName.UserNotifications}.orgId`, orgId) + .orWhereNull(`${TableName.UserNotifications}.orgId`); + }) + .whereRaw(`"${TableName.UserNotifications}"."createdAt" >= ?::timestamptz`, [startDate]) + .andWhereRaw(`"${TableName.UserNotifications}"."createdAt" < ?::timestamptz`, [endDate]) + .select(selectAllTableCols(TableName.UserNotifications)) + .limit(limit) + .offset(offset) + .orderBy(`${TableName.UserNotifications}.createdAt`, "desc") + .timeout(1000 * 120); // 2 minutes timeout + + return docs; + } catch (error) { + if (error instanceof knex.KnexTimeoutError) { + throw new GatewayTimeoutError({ + error, + message: "Failed to fetch notifications due to timeout." + }); + } + + throw new DatabaseError({ error }); + } + }; + + // delete all notifications older than 3 months + const pruneNotifications = async () => { + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + let deletedNotificationIds: { id: string }[] = []; + let numberOfRetryOnFailure = 0; + + logger.info(`${QueueName.DailyResourceCleanUp}: prune notifications started`); + do { + try { + // eslint-disable-next-line no-await-in-loop + deletedNotificationIds = await db.transaction(async (trx) => { + await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); + + const findExpiredNotificationSubQuery = trx(TableName.UserNotifications) + .where("createdAt", "<", threeMonthsAgo) + .orderBy(`${TableName.UserNotifications}.createdAt`, "desc") + .select("id") + .limit(PRUNE_BATCH_SIZE); + + // eslint-disable-next-line no-await-in-loop + const results = await trx(TableName.UserNotifications) + .whereIn("id", findExpiredNotificationSubQuery) + .del() + .returning("id"); + + return results; + }); + + numberOfRetryOnFailure = 0; + } catch (error) { + numberOfRetryOnFailure += 1; + deletedNotificationIds = []; + logger.error(error, "Failed to delete notification on pruning. Retrying..."); + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } + } while ( + deletedNotificationIds.length > 0 || + (numberOfRetryOnFailure > 0 && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE) + ); + + if (numberOfRetryOnFailure >= MAX_RETRY_ON_FAILURE) { + logger.error( + `${QueueName.DailyResourceCleanUp}: prune notifications completed with persistent errors after ${MAX_RETRY_ON_FAILURE} retries. Some notifications might not have been pruned.` + ); + } else { + logger.info(`${QueueName.DailyResourceCleanUp}: prune notifications completed`); + } + }; + + const markAllNotificationsAsRead = async (userId: string, orgId: string) => { + await db(TableName.UserNotifications) + .where({ userId }) + .andWhere((qb) => { + void qb.where({ orgId }).orWhereNull("orgId"); + }) + .update({ isRead: true }); + }; + + return { ...notificationOrm, pruneNotifications, find, markAllNotificationsAsRead }; +}; diff --git a/backend/src/services/offline-usage-report/offline-usage-report-dal.ts b/backend/src/services/offline-usage-report/offline-usage-report-dal.ts new file mode 100644 index 000000000..109d90da8 --- /dev/null +++ b/backend/src/services/offline-usage-report/offline-usage-report-dal.ts @@ -0,0 +1,208 @@ +import { TDbClient } from "@app/db"; +import { ProjectType, TableName } from "@app/db/schemas"; + +export type TOfflineUsageReportDALFactory = ReturnType; + +export const offlineUsageReportDALFactory = (db: TDbClient) => { + const getUserMetrics = async () => { + // Get total users and admin users + const userMetrics = (await db + .from(TableName.Users) + .select( + db.raw( + ` + COUNT(*) as total_users, + COUNT(CASE WHEN "superAdmin" = true THEN 1 END) as admin_users + ` + ) + ) + .where({ isGhost: false }) + .first()) as { total_users: string; admin_users: string } | undefined; + + // Get users by auth method + const authMethodStats = (await db + .from(TableName.Users) + .select( + db.raw(` + unnest("authMethods") as auth_method, + COUNT(*) as count + `) + ) + .where({ isGhost: false }) + .whereNotNull("authMethods") + .groupBy(db.raw('unnest("authMethods")'))) as Array<{ auth_method: string; count: string }>; + + const usersByAuthMethod = authMethodStats.reduce( + (acc: Record, row: { auth_method: string; count: string }) => { + acc[row.auth_method] = parseInt(row.count, 10); + return acc; + }, + {} as Record + ); + + return { + totalUsers: parseInt(userMetrics?.total_users || "0", 10), + adminUsers: parseInt(userMetrics?.admin_users || "0", 10), + usersByAuthMethod + }; + }; + + const getMachineIdentityMetrics = async () => { + // Get total machine identities + const identityMetrics = (await db + .from(TableName.Identity) + .select( + db.raw( + ` + COUNT(*) as total_identities + ` + ) + ) + .first()) as { total_identities: string } | undefined; + + // Get identities by auth method + const authMethodStats = (await db + .from(TableName.Identity) + .select("authMethod") + .count("* as count") + .whereNotNull("authMethod") + .groupBy("authMethod")) as Array<{ authMethod: string; count: string }>; + + const machineIdentitiesByAuthMethod = authMethodStats.reduce( + (acc: Record, row: { authMethod: string; count: string }) => { + acc[row.authMethod] = parseInt(row.count, 10); + return acc; + }, + {} as Record + ); + + return { + totalMachineIdentities: parseInt(identityMetrics?.total_identities || "0", 10), + machineIdentitiesByAuthMethod + }; + }; + + const getProjectMetrics = async () => { + // Get total projects and projects by type + const projectMetrics = (await db + .from(TableName.Project) + .select("type") + .count("* as count") + .groupBy("type")) as Array<{ type: string; count: string }>; + + const totalProjects = projectMetrics.reduce( + (sum, row: { type: string; count: string }) => sum + parseInt(row.count, 10), + 0 + ); + const projectsByType = projectMetrics.reduce( + (acc: Record, row: { type: string; count: string }) => { + acc[row.type] = parseInt(row.count, 10); + return acc; + }, + {} as Record + ); + + // Calculate average secrets per project + const secretsPerProject = (await db + .from(`${TableName.SecretV2} as s`) + .select("p.id as projectId") + .count("s.id as count") + .leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id") + .leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id") + .leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id") + .where("p.type", ProjectType.SecretManager) + .groupBy("p.id") + .whereNotNull("p.id")) as Array<{ projectId: string; count: string }>; + + const averageSecretsPerProject = + secretsPerProject.length > 0 + ? secretsPerProject.reduce( + (sum, row: { projectId: string; count: string }) => sum + parseInt(row.count, 10), + 0 + ) / secretsPerProject.length + : 0; + + return { + totalProjects, + projectsByType, + averageSecretsPerProject: Math.round(averageSecretsPerProject * 100) / 100 + }; + }; + + const getSecretMetrics = async () => { + // Get total secrets + const totalSecretsResult = (await db.from(TableName.SecretV2).count("* as count").first()) as + | { count: string } + | undefined; + + const totalSecrets = parseInt(totalSecretsResult?.count || "0", 10); + + // Get secrets by project + const secretsByProject = (await db + .from(`${TableName.SecretV2} as s`) + .select("p.id as projectId", "p.name as projectName") + .count("s.id as secretCount") + .leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id") + .leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id") + .leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id") + .where("p.type", ProjectType.SecretManager) + .groupBy("p.id", "p.name") + .whereNotNull("p.id")) as Array<{ projectId: string; projectName: string; secretCount: string }>; + + return { + totalSecrets, + secretsByProject: secretsByProject.map( + (row: { projectId: string; projectName: string; secretCount: string }) => ({ + projectId: row.projectId, + projectName: row.projectName, + secretCount: parseInt(row.secretCount, 10) + }) + ) + }; + }; + + const getSecretSyncMetrics = async () => { + const totalSecretSyncsResult = (await db.from(TableName.SecretSync).count("* as count").first()) as + | { count: string } + | undefined; + + return { + totalSecretSyncs: parseInt(totalSecretSyncsResult?.count || "0", 10) + }; + }; + + const getDynamicSecretMetrics = async () => { + const totalDynamicSecretsResult = (await db.from(TableName.DynamicSecret).count("* as count").first()) as + | { count: string } + | undefined; + + return { + totalDynamicSecrets: parseInt(totalDynamicSecretsResult?.count || "0", 10) + }; + }; + + const getSecretRotationMetrics = async () => { + // Check both v1 and v2 secret rotation tables + const [v1RotationsResult, v2RotationsResult] = await Promise.all([ + db.from(TableName.SecretRotation).count("* as count").first() as Promise<{ count: string } | undefined>, + db.from(TableName.SecretRotationV2).count("* as count").first() as Promise<{ count: string } | undefined> + ]); + + const totalV1Rotations = parseInt(v1RotationsResult?.count || "0", 10); + const totalV2Rotations = parseInt(v2RotationsResult?.count || "0", 10); + + return { + totalSecretRotations: totalV1Rotations + totalV2Rotations + }; + }; + + return { + getUserMetrics, + getMachineIdentityMetrics, + getProjectMetrics, + getSecretMetrics, + getSecretSyncMetrics, + getDynamicSecretMetrics, + getSecretRotationMetrics + }; +}; diff --git a/backend/src/services/offline-usage-report/offline-usage-report-service.ts b/backend/src/services/offline-usage-report/offline-usage-report-service.ts new file mode 100644 index 000000000..179232aa4 --- /dev/null +++ b/backend/src/services/offline-usage-report/offline-usage-report-service.ts @@ -0,0 +1,133 @@ +import crypto from "crypto"; + +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; + +import { TOfflineUsageReportDALFactory } from "./offline-usage-report-dal"; + +type TOfflineUsageReportServiceFactoryDep = { + offlineUsageReportDAL: TOfflineUsageReportDALFactory; + licenseService: Pick; +}; + +export type TOfflineUsageReportServiceFactory = ReturnType; + +export const offlineUsageReportServiceFactory = ({ + offlineUsageReportDAL, + licenseService +}: TOfflineUsageReportServiceFactoryDep) => { + const signReportContent = (content: string, licenseId: string): string => { + const contentHash = crypto.createHash("sha256").update(content).digest("hex"); + const hmac = crypto.createHmac("sha256", licenseId); + hmac.update(contentHash); + return hmac.digest("hex"); + }; + + const verifyReportContent = (content: string, signature: string, licenseId: string): boolean => { + const expectedSignature = signReportContent(content, licenseId); + return signature === expectedSignature; + }; + + const generateUsageReportCSV = async () => { + const cfg = getConfig(); + if (!cfg.LICENSE_KEY_OFFLINE) { + throw new BadRequestError({ + message: "Offline usage reports are not enabled. LICENSE_KEY_OFFLINE must be configured." + }); + } + + const customerId = licenseService.getCustomerId() as string; + const licenseId = licenseService.getLicenseId(); + + const [ + userMetrics, + machineIdentityMetrics, + projectMetrics, + secretMetrics, + secretSyncMetrics, + dynamicSecretMetrics, + secretRotationMetrics + ] = await Promise.all([ + offlineUsageReportDAL.getUserMetrics(), + offlineUsageReportDAL.getMachineIdentityMetrics(), + offlineUsageReportDAL.getProjectMetrics(), + offlineUsageReportDAL.getSecretMetrics(), + offlineUsageReportDAL.getSecretSyncMetrics(), + offlineUsageReportDAL.getDynamicSecretMetrics(), + offlineUsageReportDAL.getSecretRotationMetrics() + ]); + + const headers = [ + "Total Users", + "Admin Users", + "Total Identities", + "Total Projects", + "Total Secrets", + "Total Secret Syncs", + "Total Dynamic Secrets", + "Total Secret Rotations", + "Avg Secrets Per Project" + ]; + + const allUserAuthMethods = Object.keys(userMetrics.usersByAuthMethod); + allUserAuthMethods.forEach((method) => { + headers.push(`Users Auth ${method}`); + }); + + const allIdentityAuthMethods = Object.keys(machineIdentityMetrics.machineIdentitiesByAuthMethod); + allIdentityAuthMethods.forEach((method) => { + headers.push(`Identities Auth ${method}`); + }); + + const allProjectTypes = Object.keys(projectMetrics.projectsByType); + allProjectTypes.forEach((type) => { + headers.push(`Projects ${type}`); + }); + + headers.push("Signature"); + + const dataRow: (string | number)[] = [ + userMetrics.totalUsers, + userMetrics.adminUsers, + machineIdentityMetrics.totalMachineIdentities, + projectMetrics.totalProjects, + secretMetrics.totalSecrets, + secretSyncMetrics.totalSecretSyncs, + dynamicSecretMetrics.totalDynamicSecrets, + secretRotationMetrics.totalSecretRotations, + projectMetrics.averageSecretsPerProject + ]; + + allUserAuthMethods.forEach((method) => { + dataRow.push(userMetrics.usersByAuthMethod[method] || 0); + }); + allIdentityAuthMethods.forEach((method) => { + dataRow.push(machineIdentityMetrics.machineIdentitiesByAuthMethod[method] || 0); + }); + + allProjectTypes.forEach((type) => { + dataRow.push(projectMetrics.projectsByType[type] || 0); + }); + + const headersWithoutSignature = headers.slice(0, -1); + const contentWithoutSignature = [headersWithoutSignature.join(","), dataRow.join(",")].join("\n"); + + const signature = signReportContent(contentWithoutSignature, licenseId); + dataRow.push(signature); + + const csvContent = [headers.join(","), dataRow.join(",")].join("\n"); + + return { + csvContent, + signature, + filename: `infisical-usage-report-${customerId}-${new Date().toISOString().split("T")[0]}.csv` + }; + }; + + return { + generateUsageReportCSV, + verifyReportSignature: (csvContent: string, signature: string, licenseId: string) => + verifyReportContent(csvContent, signature, licenseId) + }; +}; diff --git a/backend/src/services/offline-usage-report/offline-usage-report-types.ts b/backend/src/services/offline-usage-report/offline-usage-report-types.ts new file mode 100644 index 000000000..614d5ccce --- /dev/null +++ b/backend/src/services/offline-usage-report/offline-usage-report-types.ts @@ -0,0 +1,42 @@ +export interface TUsageMetrics { + // User metrics + totalUsers: number; + usersByAuthMethod: Record; + adminUsers: number; + + // Machine identity metrics + totalMachineIdentities: number; + machineIdentitiesByAuthMethod: Record; + + // Project metrics + totalProjects: number; + projectsByType: Record; + averageSecretsPerProject: number; + + // Secret metrics + totalSecrets: number; + totalSecretSyncs: number; + totalDynamicSecrets: number; + totalSecretRotations: number; +} + +export interface TUsageReportMetadata { + generatedAt: string; + instanceId: string; + reportVersion: string; +} + +export interface TUsageReport { + metadata: TUsageReportMetadata; + metrics: TUsageMetrics; + signature?: string; +} + +export interface TGenerateUsageReportDTO { + includeSignature?: boolean; +} + +export interface TVerifyUsageReportDTO { + reportData: string; + signature: string; +} diff --git a/backend/src/services/org-admin/org-admin-service.ts b/backend/src/services/org-admin/org-admin-service.ts index 6640412c3..23005e52b 100644 --- a/backend/src/services/org-admin/org-admin-service.ts +++ b/backend/src/services/org-admin/org-admin-service.ts @@ -1,19 +1,14 @@ import { ForbiddenError } from "@casl/ability"; -import { ProjectMembershipRole, ProjectVersion, SecretKeyEncoding } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectVersion } from "@app/db/schemas"; import { OrgPermissionAdminConsoleAction, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TProjectDALFactory } from "../project/project-dal"; -import { assignWorkspaceKeysToMembers } from "../project/project-fns"; -import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; -import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; -import { TUserDALFactory } from "../user/user-dal"; import { TAccessProjectDTO, TListOrgProjectsDTO } from "./org-admin-types"; type TOrgAdminServiceFactoryDep = { @@ -23,9 +18,6 @@ type TOrgAdminServiceFactoryDep = { TProjectMembershipDALFactory, "findOne" | "create" | "transaction" | "delete" | "findAllProjectMembers" >; - projectKeyDAL: Pick; - projectBotDAL: Pick; - userDAL: Pick; projectUserMembershipRoleDAL: Pick; smtpService: Pick; }; @@ -36,9 +28,6 @@ export const orgAdminServiceFactory = ({ permissionService, projectDAL, projectMembershipDAL, - projectKeyDAL, - projectBotDAL, - userDAL, projectUserMembershipRoleDAL, smtpService }: TOrgAdminServiceFactoryDep) => { @@ -83,7 +72,7 @@ export const orgAdminServiceFactory = ({ actorAuthMethod, projectId }: TAccessProjectDTO) => { - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission( actor, actorId, actorOrgId, @@ -98,8 +87,10 @@ export const orgAdminServiceFactory = ({ const project = await projectDAL.findOne({ id: projectId, orgId: actorOrgId }); if (!project) throw new NotFoundError({ message: `Project with ID '${projectId}' not found` }); - if (project.version === ProjectVersion.V1) { - throw new BadRequestError({ message: "Please upgrade your project on your dashboard" }); + if (project.version === ProjectVersion.V1 || project.version === ProjectVersion.V2) { + throw new BadRequestError({ + message: `Project '${project.name}' is a legacy project and must be upgraded before accessing it through the admin console.` + }); } // check already there exist a membership if there return it @@ -122,52 +113,6 @@ export const orgAdminServiceFactory = ({ return { isExistingMember: true, membership: projectMembership }; } - // missing membership thus add admin back as admin to project - const ghostUser = await projectDAL.findProjectGhostUser(projectId); - if (!ghostUser) { - throw new NotFoundError({ - message: `Project owner of project with ID '${projectId}' not found` - }); - } - - const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId); - if (!ghostUserLatestKey) { - throw new NotFoundError({ - message: `Project owner's latest key of project with ID '${projectId}' not found` - }); - } - - const bot = await projectBotDAL.findOne({ projectId }); - if (!bot) { - throw new NotFoundError({ - message: `Project bot for project with ID '${projectId}' not found` - }); - } - - const botPrivateKey = crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - keyEncoding: bot.keyEncoding as SecretKeyEncoding, - iv: bot.iv, - tag: bot.tag, - ciphertext: bot.encryptedPrivateKey - }); - - const userEncryptionKey = await userDAL.findUserEncKeyByUserId(actorId); - if (!userEncryptionKey) - throw new NotFoundError({ message: `User encryption key for user with ID '${actorId}' not found` }); - const [newWsMember] = assignWorkspaceKeysToMembers({ - decryptKey: ghostUserLatestKey, - userPrivateKey: botPrivateKey, - members: [ - { - orgMembershipId: membership.id, - userPublicKey: userEncryptionKey.publicKey - } - ] - }); - const updatedMembership = await projectMembershipDAL.transaction(async (tx) => { const newProjectMembership = await projectMembershipDAL.create( { @@ -181,16 +126,6 @@ export const orgAdminServiceFactory = ({ tx ); - await projectKeyDAL.create( - { - encryptedKey: newWsMember.workspaceEncryptedKey, - nonce: newWsMember.workspaceEncryptedNonce, - senderId: ghostUser.id, - receiverId: actorId, - projectId - }, - tx - ); return newProjectMembership; }); diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index ed4867025..2d7992d34 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -32,6 +32,8 @@ export const orgMembershipDALFactory = (db: TDbClient) => { db.ref("roleId").withSchema(TableName.OrgMembership), db.ref("status").withSchema(TableName.OrgMembership), db.ref("isActive").withSchema(TableName.OrgMembership), + db.ref("lastLoginAuthMethod").withSchema(TableName.OrgMembership), + db.ref("lastLoginTime").withSchema(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), @@ -64,7 +66,9 @@ export const orgMembershipDALFactory = (db: TDbClient) => { role, status, isActive, - inviteEmail + inviteEmail, + lastLoginAuthMethod, + lastLoginTime }) => ({ roleId, orgId, @@ -73,6 +77,8 @@ export const orgMembershipDALFactory = (db: TDbClient) => { status, isActive, inviteEmail, + lastLoginAuthMethod, + lastLoginTime, user: { id: userId, email, @@ -118,12 +124,12 @@ export const orgMembershipDALFactory = (db: TDbClient) => { void qb .whereNull(`${TableName.OrgMembership}.lastInvitedAt`) .whereBetween(`${TableName.OrgMembership}.createdAt`, [twelveMonthsAgo, oneWeekAgo]); - }) - .orWhere((qb) => { // lastInvitedAt is older than 1 week ago AND createdAt is younger than 1 month ago - void qb - .where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo) - .where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo); + void qb.orWhere((qbInner) => { + void qbInner + .where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo) + .where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo); + }); }); return memberships; @@ -147,10 +153,64 @@ export const orgMembershipDALFactory = (db: TDbClient) => { } }; + const findOrgMembershipsWithUsersByOrgId = async (orgId: string) => { + try { + const members = await db + .replicaNode()(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.orgId`, orgId) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.OrgMembership}.userId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.OrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("inviteEmail").withSchema(TableName.OrgMembership), + db.ref("orgId").withSchema(TableName.OrgMembership), + db.ref("role").withSchema(TableName.OrgMembership), + db.ref("roleId").withSchema(TableName.OrgMembership), + db.ref("status").withSchema(TableName.OrgMembership), + db.ref("isActive").withSchema(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("isEmailVerified").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId") + ) + .where({ isGhost: false }); + + return members.map((member) => ({ + id: member.id, + orgId: member.orgId, + role: member.role, + status: member.status, + isActive: member.isActive, + inviteEmail: member.inviteEmail, + user: { + id: member.userId, + email: member.email, + username: member.username, + firstName: member.firstName, + lastName: member.lastName + } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find org memberships with users by org id" }); + } + }; + return { ...orgMembershipOrm, findOrgMembershipById, findRecentInvitedMemberships, - updateLastInvitedAtByIds + updateLastInvitedAtByIds, + findOrgMembershipsWithUsersByOrgId }; }; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 5730c0d92..925784bff 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -83,6 +83,7 @@ export const orgDALFactory = (db: TDbClient) => { .select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId")) .select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole")) .select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId")) + .select(db.ref("status").withSchema(TableName.OrgMembership).as("orgMembershipStatus")) .select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName")); const formattedDocs = sqlNestRelationships({ @@ -112,7 +113,8 @@ export const orgDALFactory = (db: TDbClient) => { orgMembershipId, orgMembershipRole, orgMembershipRoleName, - orgMembershipRoleId + orgMembershipRoleId, + orgMembershipStatus }) => ({ user: { id: userId, @@ -121,6 +123,7 @@ export const orgDALFactory = (db: TDbClient) => { firstName, lastName }, + status: orgMembershipStatus, membershipId: orgMembershipId, role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name roleId: orgMembershipRoleId @@ -285,6 +288,8 @@ export const orgDALFactory = (db: TDbClient) => { db.ref("roleId").withSchema(TableName.OrgMembership), db.ref("status").withSchema(TableName.OrgMembership), db.ref("isActive").withSchema(TableName.OrgMembership), + db.ref("lastLoginAuthMethod").withSchema(TableName.OrgMembership), + db.ref("lastLoginTime").withSchema(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users), db.ref("isEmailVerified").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), @@ -486,6 +491,15 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const bulkCreateMemberships = async (data: TOrgMembershipsInsert[], tx?: Knex) => { + try { + const memberships = await (tx || db)(TableName.OrgMembership).insert(data).returning("*"); + return memberships; + } catch (error) { + throw new DatabaseError({ error, name: "Create org memberships" }); + } + }; + const updateMembershipById = async (id: string, data: TOrgMembershipsUpdate, tx?: Knex) => { try { const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*"); @@ -513,6 +527,21 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const deleteMembershipsById = async (ids: string[], orgId: string, tx?: Knex) => { + try { + const memberships = await (tx || db)(TableName.OrgMembership) + .where({ + orgId + }) + .whereIn("id", ids) + .delete() + .returning("*"); + return memberships; + } catch (error) { + throw new DatabaseError({ error, name: "Delete org memberships" }); + } + }; + const findMembership = async ( filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {} @@ -613,6 +642,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, @@ -632,8 +680,11 @@ export const orgDALFactory = (db: TDbClient) => { findMembership, findMembershipWithScimFilter, createMembership, + bulkCreateMemberships, updateMembershipById, deleteMembershipById, - updateMembership + deleteMembershipsById, + updateMembership, + findIdentityOrganization }); }; diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts index 559ccc7db..30e67cca4 100644 --- a/backend/src/services/org/org-fns.ts +++ b/backend/src/services/org/org-fns.ts @@ -1,5 +1,6 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; +import { BadRequestError } from "@app/lib/errors"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; @@ -14,6 +15,19 @@ type TDeleteOrgMembership = { userAliasDAL: Pick; licenseService: Pick; projectUserAdditionalPrivilegeDAL: Pick; + userId?: string; +}; + +type TDeleteOrgMemberships = { + orgMembershipIds: string[]; + orgId: string; + orgDAL: Pick; + projectMembershipDAL: Pick; + projectKeyDAL: Pick; + userAliasDAL: Pick; + licenseService: Pick; + projectUserAdditionalPrivilegeDAL: Pick; + userId?: string; }; export const deleteOrgMembershipFn = async ({ @@ -24,11 +38,17 @@ export const deleteOrgMembershipFn = async ({ projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, - licenseService + licenseService, + userId }: TDeleteOrgMembership) => { const deletedMembership = await orgDAL.transaction(async (tx) => { const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx); + if (userId && orgMembership.userId === userId) { + // scott: this is temporary, we will add a leave org endpoint with proper handling to ensure org isn't abandoned/broken + throw new BadRequestError({ message: "You cannot remove yourself from an organization" }); + } + if (!orgMembership.userId) { await licenseService.updateSubscriptionOrgMemberCount(orgId); return orgMembership; @@ -86,3 +106,88 @@ export const deleteOrgMembershipFn = async ({ return deletedMembership; }; + +export const deleteOrgMembershipsFn = async ({ + orgMembershipIds, + orgId, + orgDAL, + projectMembershipDAL, + projectUserAdditionalPrivilegeDAL, + projectKeyDAL, + userAliasDAL, + licenseService, + userId +}: TDeleteOrgMemberships) => { + const deletedMemberships = await orgDAL.transaction(async (tx) => { + const orgMemberships = await orgDAL.deleteMembershipsById(orgMembershipIds, orgId, tx); + + const membershipUserIds = orgMemberships + .filter((member) => Boolean(member.userId)) + .map((member) => member.userId) as string[]; + + if (userId && membershipUserIds.includes(userId)) { + // scott: this is temporary, we will add a leave org endpoint with proper handling to ensure org isn't abandoned/broken + throw new BadRequestError({ message: "You cannot remove yourself from an organization" }); + } + + if (!membershipUserIds.length) { + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMemberships; + } + + await userAliasDAL.delete( + { + $in: { + userId: membershipUserIds + }, + orgId + }, + tx + ); + + await projectUserAdditionalPrivilegeDAL.delete( + { + $in: { + userId: membershipUserIds + } + }, + tx + ); + + // Get all the project memberships of the users in the organization + const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, membershipUserIds); + + // Delete all the project memberships of the users in the organization + await projectMembershipDAL.delete( + { + $in: { + id: projectMemberships.map((membership) => membership.id) + } + }, + tx + ); + + // Get all the project keys of the user in the organization + const projectKeys = await projectKeyDAL.find({ + $in: { + projectId: projectMemberships.map((membership) => membership.projectId), + receiverId: membershipUserIds + } + }); + + // Delete all the project keys of the user in the organization + await projectKeyDAL.delete( + { + $in: { + id: projectKeys.map((key) => key.id) + } + }, + tx + ); + + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMemberships; + }); + + return deletedMemberships; +}; 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 5f60bfe1e..ffcf4459e 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -8,13 +8,15 @@ import { OrgMembershipStatus, ProjectMembershipRole, ProjectVersion, - SecretKeyEncoding, TableName, + TOidcConfigs, TProjectMemberships, TProjectUserMembershipRolesInsert, + TSamlConfigs, TUsers } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; +import { TLdapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { @@ -58,8 +60,6 @@ import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; import { TIdentityMetadataDALFactory } from "../identity/identity-metadata-dal"; import { TProjectDALFactory } from "../project/project-dal"; -import { assignWorkspaceKeysToMembers, createProjectKey } from "../project/project-fns"; -import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; @@ -75,10 +75,11 @@ import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; import { TOrgBotDALFactory } from "./org-bot-dal"; import { TOrgDALFactory } from "./org-dal"; -import { deleteOrgMembershipFn } from "./org-fns"; +import { deleteOrgMembershipFn, deleteOrgMembershipsFn } from "./org-fns"; import { TOrgRoleDALFactory } from "./org-role-dal"; import { TDeleteOrgMembershipDTO, + TDeleteOrgMembershipsDTO, TFindAllWorkspacesDTO, TFindOrgMembersByEmailDTO, TGetOrgGroupsDTO, @@ -106,7 +107,13 @@ type TOrgServiceFactoryDep = { identityMetadataDAL: Pick; projectMembershipDAL: Pick< TProjectMembershipDALFactory, - "findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction" + | "findProjectMembershipsByUserId" + | "delete" + | "create" + | "find" + | "insertMany" + | "transaction" + | "findProjectMembershipsByUserIds" >; projectKeyDAL: Pick; orgMembershipDAL: Pick< @@ -121,6 +128,7 @@ type TOrgServiceFactoryDep = { incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; oidcConfigDAL: Pick; + ldapConfigDAL: Pick; smtpService: TSmtpService; tokenService: TAuthTokenServiceFactory; permissionService: TPermissionServiceFactory; @@ -130,7 +138,6 @@ type TOrgServiceFactoryDep = { >; projectUserAdditionalPrivilegeDAL: Pick; projectRoleDAL: Pick; - projectBotDAL: Pick; projectUserMembershipRoleDAL: Pick; projectBotService: Pick; loginService: Pick; @@ -162,7 +169,7 @@ export const orgServiceFactory = ({ projectRoleDAL, samlConfigDAL, oidcConfigDAL, - projectBotDAL, + ldapConfigDAL, projectUserMembershipRoleDAL, identityMetadataDAL, projectBotService, @@ -196,6 +203,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 * */ @@ -280,15 +296,7 @@ export const orgServiceFactory = ({ user.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 + publicKey: encKeys.publicKey }, tx ); @@ -361,6 +369,7 @@ export const orgServiceFactory = ({ name, slug, authEnforced, + googleSsoAuthEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa, @@ -427,16 +436,35 @@ export const orgServiceFactory = ({ } } - if (authEnforced) { - const samlCfg = await samlConfigDAL.findOne({ - orgId, - isActive: true - }); - const oidcCfg = await oidcConfigDAL.findOne({ - orgId, - isActive: true - }); + 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." + }); + } + + let samlCfg: TSamlConfigs | undefined; + let oidcCfg: TOidcConfigs | undefined; + if (authEnforced || googleSsoAuthEnforced) { + samlCfg = await samlConfigDAL.findOne({ + orgId, + isActive: true + }); + oidcCfg = await oidcConfigDAL.findOne({ + orgId, + isActive: true + }); + } + + if (authEnforced) { if (!samlCfg && !oidcCfg) throw new NotFoundError({ message: `SAML or OIDC configuration for organization with ID '${orgId}' not found` @@ -457,6 +485,47 @@ 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 (samlCfg) { + throw new BadRequestError({ + message: + "Cannot enable Google OAuth enforcement while SAML SSO is configured. Disable SAML SSO to enforce Google OAuth." + }); + } + + if (oidcCfg) { + throw new BadRequestError({ + message: + "Cannot enable Google OAuth enforcement while OIDC SSO is configured. Disable OIDC SSO to enforce Google OAuth." + }); + } + + const ldapCfg = await ldapConfigDAL.findOne({ + orgId, + isActive: true + }); + + if (ldapCfg) { + throw new BadRequestError({ + message: + "Cannot enable Google OAuth enforcement while LDAP SSO is configured. Disable LDAP SSO to enforce Google OAuth." + }); + } + + 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({ @@ -471,6 +540,7 @@ export const orgServiceFactory = ({ name, slug: slug ? slugify(slug) : undefined, authEnforced, + googleSsoAuthEnforced, scimEnabled, defaultMembershipRole, enforceMfa, @@ -493,15 +563,18 @@ export const orgServiceFactory = ({ /* * Create organization * */ - const createOrganization = async ({ - userId, - userEmail, - orgName - }: { - userId: string; - orgName: string; - userEmail?: string | null; - }) => { + const createOrganization = async ( + { + userId, + userEmail, + orgName + }: { + userId?: string; + orgName: string; + userEmail?: string | null; + }, + trx?: Knex + ) => { const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair(); const key = crypto.randomBytes(32).toString("base64"); const { @@ -520,22 +593,25 @@ export const orgServiceFactory = ({ } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key); const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail); - const organization = await orgDAL.transaction(async (tx) => { + + const createOrg = async (tx: Knex) => { // akhilmhdh: for now this is auto created. in future we can input from user and for previous users just modifiy const org = await orgDAL.create( { name: orgName, customerId, slug: slugify(`${orgName}-${alphaNumericNanoId(4)}`) }, tx ); - await orgDAL.createMembership( - { - userId, - orgId: org.id, - role: OrgMembershipRole.Admin, - status: OrgMembershipStatus.Accepted, - isActive: true - }, - tx - ); + if (userId) { + await orgDAL.createMembership( + { + userId, + orgId: org.id, + role: OrgMembershipRole.Admin, + status: OrgMembershipStatus.Accepted, + isActive: true + }, + tx + ); + } await orgBotDAL.create( { name: org.name, @@ -555,7 +631,9 @@ export const orgServiceFactory = ({ tx ); return org; - }); + }; + + const organization = await (trx ? createOrg(trx) : orgDAL.transaction(createOrg)); await licenseService.updateSubscriptionOrgMemberCount(organization.id); return organization; @@ -878,29 +956,10 @@ export const orgServiceFactory = ({ // So what we do is we generate a random secure password and then encrypt it with a random pub-private key // Then when user sign in (as login is not possible as isAccepted is false) we rencrypt the private key with the user password if (!inviteeUser || (inviteeUser && !inviteeUser?.isAccepted && !existingEncrytionKey)) { - const serverGeneratedPassword = crypto.randomBytes(32).toString("hex"); - const { tag, encoding, ciphertext, iv } = crypto - .encryption() - .symmetric() - .encryptWithRootEncryptionKey(serverGeneratedPassword); - const encKeys = await generateUserSrpKeys(inviteeEmail, serverGeneratedPassword); await userDAL.createUserEncryption( { userId: inviteeUserId, - 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 + encryptionVersion: 2 }, tx ); @@ -1062,106 +1121,6 @@ export const orgServiceFactory = ({ const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); - // this will auto generate bot - const { botKey, bot: autoGeneratedBot } = await projectBotService.getBotKey(projectId, true); - - const ghostUser = await projectDAL.findProjectGhostUser(projectId, tx); - let ghostUserId = ghostUser?.id; - - // backfill missing ghost user - if (!ghostUserId) { - const newGhostUser = await addGhostUser(project.orgId, tx); - const projectMembership = await projectMembershipDAL.create( - { - userId: newGhostUser.user.id, - projectId: project.id - }, - tx - ); - await projectUserMembershipRoleDAL.create( - { projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, - tx - ); - - const { key: encryptedProjectKey, iv: encryptedProjectKeyIv } = createProjectKey({ - publicKey: newGhostUser.keys.publicKey, - privateKey: newGhostUser.keys.plainPrivateKey, - plainProjectKey: botKey - }); - - // 4. Save the project key for the ghost user. - await projectKeyDAL.create( - { - projectId: project.id, - receiverId: newGhostUser.user.id, - encryptedKey: encryptedProjectKey, - nonce: encryptedProjectKeyIv, - senderId: newGhostUser.user.id - }, - tx - ); - - const { iv, tag, ciphertext, encoding, algorithm } = crypto - .encryption() - .symmetric() - .encryptWithRootEncryptionKey(newGhostUser.keys.plainPrivateKey); - if (autoGeneratedBot) { - await projectBotDAL.updateById( - autoGeneratedBot.id, - { - tag, - iv, - encryptedProjectKey, - encryptedProjectKeyNonce: encryptedProjectKeyIv, - encryptedPrivateKey: ciphertext, - isActive: true, - publicKey: newGhostUser.keys.publicKey, - senderId: newGhostUser.user.id, - algorithm, - keyEncoding: encoding - }, - tx - ); - } - ghostUserId = newGhostUser.user.id; - } - - const bot = await projectBotDAL.findOne({ projectId }, tx); - if (!bot) { - throw new NotFoundError({ - name: "InviteUser", - message: `Failed to find project bot for project with ID '${projectId}'` - }); - } - - const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUserId, projectId, tx); - if (!ghostUserLatestKey) { - throw new NotFoundError({ - name: "InviteUser", - message: `Failed to find project owner's latest key for project with ID '${projectId}'` - }); - } - - const botPrivateKey = crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - keyEncoding: bot.keyEncoding as SecretKeyEncoding, - iv: bot.iv, - tag: bot.tag, - ciphertext: bot.encryptedPrivateKey - }); - - const newWsMembers = assignWorkspaceKeysToMembers({ - decryptKey: ghostUserLatestKey, - userPrivateKey: botPrivateKey, - members: userWithEncryptionKeyInvitedToProject.map((userEnc) => ({ - orgMembershipId: userEnc.userId, - projectMembershipRole: ProjectMembershipRole.Admin, - userPublicKey: userEnc.publicKey - })) - }); - const projectMemberships = await projectMembershipDAL.insertMany( userWithEncryptionKeyInvitedToProject.map((userEnc) => ({ projectId, @@ -1184,16 +1143,6 @@ export const orgServiceFactory = ({ }); await projectUserMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); - await projectKeyDAL.insertMany( - newWsMembers.map((el) => ({ - encryptedKey: el.workspaceEncryptedKey, - nonce: el.workspaceEncryptedNonce, - senderId: ghostUserId, - receiverId: el.orgMembershipId, - projectId - })), - tx - ); mailsForProjectInvitation.push({ email: userWithEncryptionKeyInvitedToProject .filter((el) => !userIdsWithOrgInvitation.has(el.userId)) @@ -1369,12 +1318,42 @@ export const orgServiceFactory = ({ projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, - licenseService + licenseService, + userId }); return deletedMembership; }; + const bulkDeleteOrgMemberships = async ({ + orgId, + userId, + membershipIds, + actorAuthMethod, + actorOrgId + }: TDeleteOrgMembershipsDTO) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); + + if (membershipIds.includes(userId)) { + throw new BadRequestError({ message: "You cannot delete your own organization membership" }); + } + + const deletedMemberships = await deleteOrgMembershipsFn({ + orgMembershipIds: membershipIds, + orgId, + orgDAL, + projectMembershipDAL, + projectUserAdditionalPrivilegeDAL, + projectKeyDAL, + userAliasDAL, + licenseService, + userId + }); + + return deletedMemberships; + }; + const listProjectMembershipsByOrgMembershipId = async ({ orgMembershipId, orgId, @@ -1508,6 +1487,7 @@ export const orgServiceFactory = ({ findOrganizationById, findAllOrgMembers, findAllOrganizationOfUser, + findIdentityOrganization, inviteUserToOrganization, verifyUserToOrg, updateOrg, @@ -1528,6 +1508,7 @@ export const orgServiceFactory = ({ findOrgBySlug, resendOrgMemberInvitation, upgradePrivilegeSystem, - notifyInvitedUsers + notifyInvitedUsers, + bulkDeleteOrgMemberships }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 0736f174d..1a27d131f 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -25,6 +25,14 @@ export type TDeleteOrgMembershipDTO = { actorAuthMethod: ActorAuthMethod; }; +export type TDeleteOrgMembershipsDTO = { + userId: string; + orgId: string; + membershipIds: string[]; + actorOrgId: string | undefined; + actorAuthMethod: ActorAuthMethod; +}; + export type TInviteUserToOrgDTO = { inviteeEmails: string[]; organizationRoleSlug: string; @@ -66,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/pki-subscriber/pki-subscriber-schema.ts b/backend/src/services/pki-subscriber/pki-subscriber-schema.ts index 337f81d8c..7b22ab645 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-schema.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-schema.ts @@ -18,7 +18,8 @@ export const sanitizedPkiSubscriber = PkiSubscribersSchema.pick({ lastOperationAt: true, enableAutoRenewal: true, autoRenewalPeriodInDays: true, - lastAutoRenewAt: true + lastAutoRenewAt: true, + properties: true }).extend({ supportsImmediateCertIssuance: z.boolean().optional() }); diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index a3e6ec78c..391b5b2a8 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -109,6 +109,7 @@ export const pkiSubscriberServiceFactory = ({ extendedKeyUsages, enableAutoRenewal, autoRenewalPeriodInDays, + properties, projectId, actorId, actorAuthMethod, @@ -157,7 +158,8 @@ export const pkiSubscriberServiceFactory = ({ keyUsages, extendedKeyUsages, enableAutoRenewal, - autoRenewalPeriodInDays + autoRenewalPeriodInDays, + properties }); return newSubscriber; @@ -221,6 +223,7 @@ export const pkiSubscriberServiceFactory = ({ extendedKeyUsages, enableAutoRenewal, autoRenewalPeriodInDays, + properties, actorId, actorAuthMethod, actor, @@ -275,7 +278,8 @@ export const pkiSubscriberServiceFactory = ({ keyUsages, extendedKeyUsages, enableAutoRenewal, - autoRenewalPeriodInDays + autoRenewalPeriodInDays, + properties }); return updatedSubscriber; @@ -360,7 +364,7 @@ export const pkiSubscriberServiceFactory = ({ throw new BadRequestError({ message: "CA is disabled" }); } - if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) { + if (ca.externalCa?.id && (ca.externalCa.type === CaType.ACME || ca.externalCa.type === CaType.AZURE_AD_CS)) { await certificateAuthorityQueue.orderCertificateForSubscriber({ subscriberId: subscriber.id, caType: ca.externalCa.type diff --git a/backend/src/services/pki-subscriber/pki-subscriber-types.ts b/backend/src/services/pki-subscriber/pki-subscriber-types.ts index 6881eea74..60b6f51b8 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-types.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-types.ts @@ -18,6 +18,7 @@ export type TCreatePkiSubscriberDTO = { extendedKeyUsages: CertExtendedKeyUsage[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; + properties?: TPkiSubscriberProperties; } & TProjectPermission; export type TGetPkiSubscriberDTO = { @@ -36,6 +37,7 @@ export type TUpdatePkiSubscriberDTO = { extendedKeyUsages?: CertExtendedKeyUsage[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; + properties?: TPkiSubscriberProperties; } & TProjectPermission; export type TDeletePkiSubscriberDTO = { @@ -69,3 +71,13 @@ export enum SubscriberOperationStatus { SUCCESS = "success", FAILED = "failed" } + +export type TPkiSubscriberProperties = { + azureTemplateType?: string; + organization?: string; + organizationalUnit?: string; + country?: string; + state?: string; + locality?: string; + emailAddress?: string; +}; diff --git a/backend/src/services/project-bot/project-bot-fns.ts b/backend/src/services/project-bot/project-bot-fns.ts index d26669b86..029f320df 100644 --- a/backend/src/services/project-bot/project-bot-fns.ts +++ b/backend/src/services/project-bot/project-bot-fns.ts @@ -42,6 +42,13 @@ export const getBotKeyFnFactory = ( message: `Project bot not found for project with ID '${projectId}'. Please ask an administrator to log-in to the Infisical Console.` }); } + + if (!projectV1Keys.senderPublicKey) { + throw new NotFoundError({ + message: `Project bot not found for project with ID '${projectId}'. Please ask an administrator to log-in to the Infisical Console and upgrade the project.` + }); + } + let userPrivateKey = ""; if ( projectV1Keys?.serverEncryptedPrivateKey && 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-key/project-key-dal.ts b/backend/src/services/project-key/project-key-dal.ts index ea4ed813c..bb91b9c85 100644 --- a/backend/src/services/project-key/project-key-dal.ts +++ b/backend/src/services/project-key/project-key-dal.ts @@ -14,7 +14,7 @@ export const projectKeyDALFactory = (db: TDbClient) => { userId: string, projectId: string, tx?: Knex - ): Promise<(TProjectKeys & { sender: { publicKey: string } }) | undefined> => { + ): Promise<(TProjectKeys & { sender: { publicKey?: string } }) | undefined> => { try { const projectKey = await (tx || db.replicaNode())(TableName.ProjectKeys) .join(TableName.Users, `${TableName.ProjectKeys}.senderId`, `${TableName.Users}.id`) @@ -25,7 +25,7 @@ export const projectKeyDALFactory = (db: TDbClient) => { .select(db.ref("publicKey").withSchema(TableName.UserEncryptionKey)) .first(); if (projectKey) { - return { ...projectKey, sender: { publicKey: projectKey.publicKey } }; + return { ...projectKey, sender: { publicKey: projectKey.publicKey || undefined } }; } } catch (error) { throw new DatabaseError({ error, name: "Find latest project key" }); diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 8315ed429..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 @@ -314,11 +333,122 @@ export const projectMembershipDALFactory = (db: TDbClient) => { } }; + const findProjectMembershipsByUserIds = async (orgId: string, userIds: string[]) => { + try { + const docs = await db + .replicaNode()(TableName.ProjectMembership) + .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .whereIn(`${TableName.Users}.id`, userIds) + .where(`${TableName.Project}.orgId`, orgId) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .join( + TableName.ProjectUserMembershipRole, + `${TableName.ProjectUserMembershipRole}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectUserMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .select( + db.ref("id").withSchema(TableName.ProjectMembership), + db.ref("isGhost").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("email").withSchema(TableName.Users), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("role").withSchema(TableName.ProjectUserMembershipRole), + db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole), + db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), + 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("id").as("projectId").withSchema(TableName.Project), + db.ref("type").as("projectType").withSchema(TableName.Project) + ) + .where({ isGhost: false }); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ + email, + firstName, + username, + lastName, + publicKey, + isGhost, + id, + projectId, + projectName, + projectType, + userId + }) => ({ + id, + userId, + projectId, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + project: { + id: projectId, + name: projectName, + type: projectType + } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; + } catch (error) { + throw new DatabaseError({ error, name: "Find project memberships by user ids" }); + } + }; + return { ...projectMemberOrm, findAllProjectMembers, findProjectGhostUser, findMembershipsByUsername, - findProjectMembershipsByUserId + findProjectMembershipsByUserId, + findProjectMembershipsByUserIds }; }; 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/project/project-fns.ts b/backend/src/services/project/project-fns.ts index f166ec04f..fca0074d7 100644 --- a/backend/src/services/project/project-fns.ts +++ b/backend/src/services/project/project-fns.ts @@ -10,6 +10,10 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { AddUserToWsDTO, TBootstrapSshProjectDTO } from "./project-types"; export const assignWorkspaceKeysToMembers = ({ members, decryptKey, userPrivateKey }: AddUserToWsDTO) => { + if (!decryptKey.sender.publicKey) { + throw new Error("Decrypt key sender public key not found"); + } + const plaintextProjectKey = crypto.encryption().asymmetric().decrypt({ ciphertext: decryptKey.encryptedKey, nonce: decryptKey.nonce, diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts index 0d7c7dd55..e557b7c96 100644 --- a/backend/src/services/project/project-queue.ts +++ b/backend/src/services/project/project-queue.ts @@ -121,6 +121,10 @@ export const projectQueueFactory = ({ tag: data.encryptedPrivateKey.encryptedKeyTag }); + if (!oldProjectKey.sender.publicKey) { + throw new Error("Old project key sender public key not found"); + } + const decryptedPlainProjectKey = crypto.encryption().asymmetric().decrypt({ ciphertext: oldProjectKey.encryptedKey, nonce: oldProjectKey.nonce, @@ -290,6 +294,10 @@ export const projectQueueFactory = ({ continue; } + if (!user.publicKey) { + throw new Error(`User with ID ${key.receiverId} has no public key during upgrade.`); + } + const [newMember] = assignWorkspaceKeysToMembers({ decryptKey: ghostUserLatestKey, userPrivateKey: ghostUser.keys.plainPrivateKey, diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 153f627fc..d59f20bc6 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1,4 +1,5 @@ -import { ForbiddenError, subject } from "@casl/ability"; +import { createMongoAbility, ForbiddenError, MongoAbility, RawRuleOf, subject } from "@casl/ability"; +import { PackRule, unpackRules } from "@casl/ability/extra"; import slugify from "@sindresorhus/slugify"; import { @@ -16,9 +17,11 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionCertificateActions, + ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, + ProjectPermissionSet, ProjectPermissionSshHostActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -55,13 +58,10 @@ import { validateMicrosoftTeamsChannelsSchema } from "../microsoft-teams/microso import { TMicrosoftTeamsIntegrationDALFactory } from "../microsoft-teams/microsoft-teams-integration-dal"; import { TProjectMicrosoftTeamsConfigDALFactory } from "../microsoft-teams/project-microsoft-teams-config-dal"; import { TOrgDALFactory } from "../org/org-dal"; -import { TOrgServiceFactory } from "../org/org-service"; import { TPkiAlertDALFactory } from "../pki-alert/pki-alert-dal"; import { TPkiCollectionDALFactory } from "../pki-collection/pki-collection-dal"; -import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; -import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; @@ -78,7 +78,7 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { WorkflowIntegration, WorkflowIntegrationStatus } from "../workflow-integration/workflow-integration-types"; import { TProjectDALFactory } from "./project-dal"; -import { assignWorkspaceKeysToMembers, bootstrapSshProject, createProjectKey } from "./project-fns"; +import { bootstrapSshProject } from "./project-fns"; import { TProjectQueueFactory } from "./project-queue"; import { TProjectSshConfigDALFactory } from "./project-ssh-config-dal"; import { @@ -123,6 +123,7 @@ export const DEFAULT_PROJECT_ENVS = [ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; + identityProjectDAL: Pick; projectSshConfigDAL: Pick; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; @@ -132,9 +133,7 @@ type TProjectServiceFactoryDep = { secretV2BridgeDAL: Pick; projectEnvDAL: Pick; identityOrgMembershipDAL: TIdentityOrgDALFactory; - identityProjectDAL: TIdentityProjectDALFactory; identityProjectMembershipRoleDAL: Pick; - projectKeyDAL: Pick; projectMembershipDAL: Pick< TProjectMembershipDALFactory, "create" | "findProjectGhostUser" | "findOne" | "delete" | "findAllProjectMembers" @@ -167,12 +166,10 @@ type TProjectServiceFactoryDep = { sshHostDAL: Pick; sshHostGroupDAL: Pick; permissionService: TPermissionServiceFactory; - orgService: Pick; licenseService: Pick; smtpService: Pick; orgDAL: Pick; keyStore: Pick; - projectBotDAL: Pick; projectRoleDAL: Pick; kmsService: Pick< TKmsServiceFactory, @@ -196,27 +193,25 @@ export const projectServiceFactory = ({ secretDAL, secretV2BridgeDAL, projectQueue, - projectKeyDAL, permissionService, projectBotService, orgDAL, userDAL, folderDAL, - orgService, - identityProjectDAL, identityOrgMembershipDAL, projectMembershipDAL, projectEnvDAL, licenseService, projectUserMembershipRoleDAL, projectRoleDAL, - identityProjectMembershipRoleDAL, certificateAuthorityDAL, certificateDAL, certificateTemplateDAL, pkiCollectionDAL, pkiAlertDAL, pkiSubscriberDAL, + identityProjectDAL, + identityProjectMembershipRoleDAL, sshCertificateAuthorityDAL, sshCertificateAuthoritySecretDAL, sshCertificateDAL, @@ -225,7 +220,6 @@ export const projectServiceFactory = ({ sshHostGroupDAL, keyStore, kmsService, - projectBotDAL, projectSlackConfigDAL, projectMicrosoftTeamsConfigDAL, slackIntegrationDAL, @@ -253,7 +247,7 @@ export const projectServiceFactory = ({ type = ProjectType.SecretManager }: TCreateProjectDTO) => { const organization = await orgDAL.findOne({ id: actorOrgId }); - const { permission, membership: orgMembership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission( actor, actorId, organization.id, @@ -277,7 +271,6 @@ export const projectServiceFactory = ({ message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces." }); } - const ghostUser = await orgService.addGhostUser(organization.id, tx); if (kmsKeyId) { const kms = await kmsService.getKmsById(kmsKeyId, tx); @@ -329,19 +322,6 @@ export const projectServiceFactory = ({ }); } - // set ghost user as admin of project - const projectMembership = await projectMembershipDAL.create( - { - userId: ghostUser.user.id, - projectId: project.id - }, - tx - ); - await projectUserMembershipRoleDAL.create( - { projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, - tx - ); - // set default environments and root folder for provided environments let envs: TProjectEnvironments[] = []; if (projectTemplate) { @@ -374,55 +354,6 @@ export const projectServiceFactory = ({ ); } - // 3. Create a random key that we'll use as the project key. - const { key: encryptedProjectKey, iv: encryptedProjectKeyIv } = createProjectKey({ - publicKey: ghostUser.keys.publicKey, - privateKey: ghostUser.keys.plainPrivateKey - }); - - // 4. Save the project key for the ghost user. - await projectKeyDAL.create( - { - projectId: project.id, - receiverId: ghostUser.user.id, - encryptedKey: encryptedProjectKey, - nonce: encryptedProjectKeyIv, - senderId: ghostUser.user.id - }, - tx - ); - - const { iv, tag, ciphertext, encoding, algorithm } = crypto - .encryption() - .symmetric() - .encryptWithRootEncryptionKey(ghostUser.keys.plainPrivateKey); - - // 5. Create & a bot for the project - await projectBotDAL.create( - { - name: "Infisical Bot (Ghost)", - projectId: project.id, - tag, - iv, - encryptedProjectKey, - encryptedProjectKeyNonce: encryptedProjectKeyIv, - encryptedPrivateKey: ciphertext, - isActive: true, - publicKey: ghostUser.keys.publicKey, - senderId: ghostUser.user.id, - algorithm, - keyEncoding: encoding - }, - tx - ); - - // Find the ghost users latest key - const latestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.user.id, project.id, tx); - - if (!latestKey) { - throw new Error("Latest key not found for user"); - } - // If the project is being created by a user, add the user to the project as an admin if (actor === ActorType.USER) { // Find public key of user @@ -432,17 +363,6 @@ export const projectServiceFactory = ({ throw new Error("User not found"); } - const [projectAdmin] = assignWorkspaceKeysToMembers({ - decryptKey: latestKey, - userPrivateKey: ghostUser.keys.plainPrivateKey, - members: [ - { - userPublicKey: user.publicKey, - orgMembershipId: orgMembership.id - } - ] - }); - // Create a membership for the user const userProjectMembership = await projectMembershipDAL.create( { @@ -455,18 +375,6 @@ export const projectServiceFactory = ({ { projectMembershipId: userProjectMembership.id, role: ProjectMembershipRole.Admin }, tx ); - - // Create a project key for the user - await projectKeyDAL.create( - { - encryptedKey: projectAdmin.workspaceEncryptedKey, - nonce: projectAdmin.workspaceEncryptedNonce, - senderId: ghostUser.user.id, - receiverId: user.id, - projectId: project.id - }, - tx - ); } // If the project is being created by an identity, add the identity to the project as an admin @@ -1947,9 +1855,52 @@ export const projectServiceFactory = ({ if (projectMember) throw new BadRequestError({ message: "User already has access to the project" }); const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); - const filteredProjectMembers = projectMembers + + let filteredProjectMembers = projectMembers .filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin)) .map((el) => el.user.email!); + if (filteredProjectMembers.length === 0) { + const customRolesWithMemberCreate = await projectRoleDAL.find({ projectId }); + const customRoleSlugsCanCreate = customRolesWithMemberCreate + .filter((role) => { + try { + const permissions = ( + typeof role.permissions === "string" + ? (JSON.parse(role.permissions) as PackRule>>[]) + : role.permissions + ) as PackRule>>[]; + + const ability = createMongoAbility>( + unpackRules>>(permissions) + ); + return ability.can(ProjectPermissionMemberActions.Create, ProjectPermissionSub.Member); + } catch { + return false; + } + }) + .map((role) => role.slug); + + if (customRoleSlugsCanCreate.length > 0) { + const usersWithCustomCreateMemberRole = projectMembers + .filter((member) => + member.roles.some((role) => role.customRoleSlug && customRoleSlugsCanCreate.includes(role.customRoleSlug)) + ) + .map((el) => el.user.email!) + .filter(Boolean); + + if (usersWithCustomCreateMemberRole.length > 0) { + filteredProjectMembers = usersWithCustomCreateMemberRole; + } + } + } + + if (filteredProjectMembers.length === 0) { + throw new BadRequestError({ + message: + "No users in this project have permission to grant you access. Please contact an organization administrator to assign the necessary permissions." + }); + } + const org = await orgDAL.findOne({ id: permission.orgId }); const project = await projectDAL.findById(projectId); const userDetails = await userDAL.findById(permission.id); diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 02f89bc38..ceef78f6a 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -117,7 +117,7 @@ export type TUpgradeProjectDTO = { } & TProjectPermission; export type AddUserToWsDTO = { - decryptKey: TProjectKeys & { sender: { publicKey: string } }; + decryptKey: TProjectKeys & { sender: { publicKey?: string } }; userPrivateKey: string; members: { orgMembershipId: string; diff --git a/backend/src/services/reminder/reminder-dal.ts b/backend/src/services/reminder/reminder-dal.ts index 0e5d7b895..897a75234 100644 --- a/backend/src/services/reminder/reminder-dal.ts +++ b/backend/src/services/reminder/reminder-dal.ts @@ -124,10 +124,35 @@ export const reminderDALFactory = (db: TDbClient) => { return reminders[0] || null; }; + const findSecretReminders = async (secretIds: string[], tx?: Knex) => { + const rawReminders = await (tx || db)(TableName.Reminder) + .whereIn(`${TableName.Reminder}.secretId`, secretIds) + .leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`) + .select(selectAllTableCols(TableName.Reminder)) + .select(db.ref("userId").withSchema(TableName.ReminderRecipient)); + const reminders = sqlNestRelationships({ + data: rawReminders, + key: "id", + parentMapper: (el) => ({ + _id: el.id, + ...RemindersSchema.parse(el) + }), + childrenMapper: [ + { + key: "userId", + label: "recipients" as const, + mapper: ({ userId }) => userId + } + ] + }); + return reminders; + }; + return { ...reminderOrm, findSecretDailyReminders, findUpcomingReminders, - findSecretReminder + findSecretReminder, + findSecretReminders }; }; diff --git a/backend/src/services/reminder/reminder-service.ts b/backend/src/services/reminder/reminder-service.ts index a03e9cddd..86788c249 100644 --- a/backend/src/services/reminder/reminder-service.ts +++ b/backend/src/services/reminder/reminder-service.ts @@ -42,7 +42,7 @@ export const reminderServiceFactory = ({ const $manageReminderRecipients = async (reminderId: string, newRecipients?: string[] | null): Promise => { if (!newRecipients || newRecipients.length === 0) { // If no recipients provided, remove all existing recipients - await reminderRecipientDAL.deleteById(reminderId); + await reminderRecipientDAL.delete({ reminderId }); return; } @@ -79,25 +79,33 @@ export const reminderServiceFactory = ({ repeatDays, nextReminderDate: nextReminderDateInput, recipients, - projectId + projectId, + fromDate: fromDateInput }: { secretId?: string; message?: string | null; repeatDays?: number | null; nextReminderDate?: string | null; recipients?: string[] | null; + fromDate?: string | null; projectId: string; }) => { if (!secretId) { throw new BadRequestError({ message: "secretId is required" }); } let nextReminderDate; + let fromDate; if (nextReminderDateInput) { nextReminderDate = new Date(nextReminderDateInput); } - if (repeatDays && repeatDays > 0) { - nextReminderDate = $addDays(repeatDays); + if (repeatDays) { + if (fromDateInput) { + fromDate = new Date(fromDateInput); + nextReminderDate = fromDate; + } else { + nextReminderDate = $addDays(repeatDays); + } } if (!nextReminderDate) { @@ -112,7 +120,8 @@ export const reminderServiceFactory = ({ await reminderDAL.updateById(existingReminder.id, { message, repeatDays, - nextReminderDate + nextReminderDate, + fromDate }); reminderId = existingReminder.id; } else { @@ -121,7 +130,8 @@ export const reminderServiceFactory = ({ secretId, message, repeatDays, - nextReminderDate + nextReminderDate, + fromDate }); reminderId = newReminder.id; } @@ -280,14 +290,28 @@ export const reminderServiceFactory = ({ } const processedReminders = remindersData.map( - ({ secretId, message, repeatDays, nextReminderDate: nextReminderDateInput, recipients, projectId }) => { + ({ + secretId, + message, + repeatDays, + nextReminderDate: nextReminderDateInput, + recipients, + projectId, + fromDate: fromDateInput + }) => { let nextReminderDate; + let fromDate; if (nextReminderDateInput) { nextReminderDate = new Date(nextReminderDateInput); } - if (repeatDays && repeatDays > 0 && !nextReminderDate) { - nextReminderDate = $addDays(repeatDays); + if (repeatDays && !nextReminderDate) { + if (fromDateInput) { + fromDate = new Date(fromDateInput); + nextReminderDate = fromDate; + } else { + nextReminderDate = $addDays(repeatDays); + } } if (!nextReminderDate) { @@ -302,17 +326,19 @@ export const reminderServiceFactory = ({ repeatDays, nextReminderDate, recipients: recipients ? [...new Set(recipients)] : [], - projectId + projectId, + fromDate }; } ); const newReminders = await reminderDAL.insertMany( - processedReminders.map(({ secretId, message, repeatDays, nextReminderDate }) => ({ + processedReminders.map(({ secretId, message, repeatDays, nextReminderDate, fromDate }) => ({ secretId, message, repeatDays, - nextReminderDate + nextReminderDate, + fromDate })), tx ); @@ -346,6 +372,21 @@ export const reminderServiceFactory = ({ }; }; + const getRemindersForDashboard: TReminderServiceFactory["getRemindersForDashboard"] = async (secretIds) => { + // scott we don't need to check permissions/secret existence because these are the + // secrets from the dashboard that have already gone through these checks + + const reminders = await reminderDAL.findSecretReminders(secretIds); + + const reminderMap: Record = {}; + + reminders.forEach((reminder) => { + if (reminder.secretId) reminderMap[reminder.secretId] = reminder; + }); + + return reminderMap; + }; + return { createReminder, getReminder, @@ -353,6 +394,7 @@ export const reminderServiceFactory = ({ deleteReminder, deleteReminderBySecretId, batchCreateReminders, - createReminderInternal + createReminderInternal, + getRemindersForDashboard }; }; diff --git a/backend/src/services/reminder/reminder-types.ts b/backend/src/services/reminder/reminder-types.ts index 1f6a53ac7..078e049b5 100644 --- a/backend/src/services/reminder/reminder-types.ts +++ b/backend/src/services/reminder/reminder-types.ts @@ -8,6 +8,7 @@ export type TReminder = { message?: string | null; repeatDays?: number | null; nextReminderDate: Date; + fromDate?: Date | null; createdAt: Date; updatedAt: Date; }; @@ -21,6 +22,7 @@ export type TCreateReminderDTO = { secretId?: string; message?: string | null; repeatDays?: number | null; + fromDate?: string | null; nextReminderDate?: string | null; recipients?: string[] | null; }; @@ -31,6 +33,7 @@ export type TBatchCreateReminderDTO = { message?: string | null; repeatDays?: number | null; nextReminderDate?: string | Date | null; + fromDate?: Date | null; recipients?: string[] | null; projectId?: string; }[]; @@ -95,8 +98,11 @@ export interface TReminderServiceFactory { nextReminderDate?: string | null; recipients?: string[] | null; projectId: string; + fromDate?: string | null; }) => Promise<{ id: string; created: boolean; }>; + + getRemindersForDashboard: (secretIds: string[]) => Promise>; } diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index bfaef5708..fbd9d3fd6 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -1,7 +1,9 @@ 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"; +import { TUserNotificationDALFactory } from "@app/services/notification/user-notification-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal"; @@ -24,6 +26,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { serviceTokenService: Pick; queueService: TQueueServiceFactory; orgService: TOrgServiceFactory; + userNotificationDAL: Pick; }; export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; @@ -39,34 +42,22 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ secretVersionV2DAL, identityUniversalAuthClientSecretDAL, serviceTokenService, - orgService + orgService, + userNotificationDAL }: 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 +65,44 @@ 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(); + await userNotificationDAL.pruneNotifications(); + 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..c5eb0adde 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -30,6 +30,7 @@ import { TDeleteFolderDTO, TDeleteManyFoldersDTO, TGetFolderByIdDTO, + TGetFolderByPathDTO, TGetFolderDTO, TGetFoldersDeepByEnvsDTO, TUpdateFolderDTO, @@ -238,8 +239,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 +505,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 ({ @@ -1371,6 +1399,31 @@ export const secretFolderServiceFactory = ({ }; }; + const getFolderByPath = async ( + { projectId, environment, secretPath }: TGetFolderByPathDTO, + actor: OrgServiceActor + ) => { + // folder check is allowed to be read by anyone + // permission is to check if user has access + await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager + }); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + + if (!folder) + throw new NotFoundError({ + message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + return folder; + }; + return { createFolder, updateFolder, @@ -1378,6 +1431,7 @@ export const secretFolderServiceFactory = ({ deleteFolder, getFolders, getFolderById, + getFolderByPath, getProjectFolderCount, getFoldersMultiEnv, getFoldersDeepByEnvs, diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index ae8e2c5dc..eed815da5 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -91,3 +91,9 @@ export type TDeleteManyFoldersDTO = { idOrName: string; }>; }; + +export type TGetFolderByPathDTO = { + projectId: string; + environment: string; + secretPath: string; +}; diff --git a/backend/src/services/secret-import/secret-import-dal.ts b/backend/src/services/secret-import/secret-import-dal.ts index dbe2f6a84..db611dc6c 100644 --- a/backend/src/services/secret-import/secret-import-dal.ts +++ b/backend/src/services/secret-import/secret-import-dal.ts @@ -127,6 +127,27 @@ export const secretImportDALFactory = (db: TDbClient) => { } }; + const findByIds = async (ids: string[], tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.SecretImport) + .whereIn(`${TableName.SecretImport}.id`, ids) + .join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`) + .select( + db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports, + db.ref("slug").withSchema(TableName.Environment), + db.ref("name").withSchema(TableName.Environment), + db.ref("id").withSchema(TableName.Environment).as("envId") + ); + + return docs.map(({ envId, slug, name, ...el }) => ({ + ...el, + importEnv: { id: envId, slug, name } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find secret imports by ids" }); + } + }; + const getProjectImportCount = async ( { search, ...filter }: Partial, tx?: Knex @@ -325,6 +346,7 @@ export const secretImportDALFactory = (db: TDbClient) => { ...secretImportOrm, find, findById, + findByIds, findByFolderIds, findLastImportPosition, updateAllPosition, diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index 6aa73465d..c739c5ad2 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -1,3 +1,5 @@ +import RE2 from "re2"; + import { SecretType, TSecretImports, TSecrets, TSecretsV2 } from "@app/db/schemas"; import { groupBy, unique } from "@app/lib/fn"; @@ -54,6 +56,74 @@ type TSecretImportSecretsV2 = { const LEVEL_BREAK = 10; const getImportUniqKey = (envSlug: string, path: string) => `${envSlug}=${path}`; +const RESERVED_IMPORT_REGEX = new RE2("/__reserve_replication_([a-f0-9-]{36})"); + +/** + * Processes reserved imports by resolving them to their replication source. + */ +const processReservedImports = async < + T extends { + isReserved?: boolean | null; + importPath: string; + importEnv: { id: string; slug: string; name: string }; + folderId: string; + } +>( + imports: T[], + secretImportDAL: Pick +): Promise => { + const reservedImportIds: string[] = []; + + imports.forEach((secretImport) => { + if (secretImport.isReserved) { + const reservedMatch = RESERVED_IMPORT_REGEX.exec(secretImport.importPath); + if (reservedMatch) { + const referencedImportId = reservedMatch[1]; + reservedImportIds.push(referencedImportId); + } + } + }); + + if (reservedImportIds.length === 0) { + return imports; + } + + try { + const importDetailsMap = new Map< + string, + { importPath: string; importEnv: { id: string; slug: string; name: string } } + >(); + + const referencedImports = await secretImportDAL.findByIds(reservedImportIds); + referencedImports.forEach((referencedImport) => { + importDetailsMap.set(referencedImport.id, { + importPath: referencedImport.importPath, + importEnv: referencedImport.importEnv + }); + }); + + return imports.map((secretImport) => { + if (secretImport.isReserved) { + const reservedMatch = RESERVED_IMPORT_REGEX.exec(secretImport.importPath); + if (reservedMatch) { + const referencedImportId = reservedMatch[1]; + const referencedDetails = importDetailsMap.get(referencedImportId); + + if (referencedDetails) { + return { + ...secretImport, + importPath: referencedDetails.importPath, + importEnv: referencedDetails.importEnv + }; + } + } + } + return secretImport; + }); + } catch (error) { + return imports; + } +}; export const fnSecretsFromImports = async ({ allowedImports: possibleCyclicImports, folderDAL, @@ -167,7 +237,7 @@ export const fnSecretsV2FromImports = async ({ folderDAL: Pick; viewSecretValue: boolean; secretDAL: Pick; - secretImportDAL: Pick; + secretImportDAL: Pick; decryptor: (value?: Buffer | null) => string; expandSecretReferences?: (inputSecret: { value?: string; @@ -188,6 +258,10 @@ export const fnSecretsV2FromImports = async ({ })[]; }[] = [{ secretImports: rootSecretImports, depth: 0, parentImportedSecrets: [] }]; + const processedSecretImports = await processReservedImports(rootSecretImports, secretImportDAL); + + stack[0] = { secretImports: processedSecretImports, depth: 0, parentImportedSecrets: [] }; + const processedImports: TSecretImportSecretsV2[] = []; while (stack.length) { 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..9bcf659aa 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"; @@ -11,7 +12,7 @@ type TAWSParameterStoreRecord = Record; type TAWSParameterStoreMetadataRecord = Record; type TAWSParameterStoreTagsRecord = Record>; -const MAX_RETRIES = 5; +const MAX_RETRIES = 10; const BATCH_SIZE = 10; const getSSM = async (secretSync: TAwsParameterStoreSyncWithCredentials) => { @@ -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/aws-secrets-manager/aws-secrets-manager-sync-fns.ts b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts index 8e37cf277..8573d6ffc 100644 --- a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts +++ b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts @@ -38,7 +38,7 @@ type TAwsSecretsRecord = Record; type TAwsSecretValuesRecord = Record; type TAwsSecretDescriptionsRecord = Record; -const MAX_RETRIES = 5; +const MAX_RETRIES = 10; const BATCH_SIZE = 20; const getSecretsManagerClient = async (secretSync: TAwsSecretsManagerSyncWithCredentials) => { diff --git a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts index 7aa1c16ce..51669067a 100644 --- a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts @@ -13,7 +13,7 @@ import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { TAzureAppConfigurationSyncWithCredentials } from "./azure-app-configuration-sync-types"; type TAzureAppConfigurationSyncFactoryDeps = { - appConnectionDAL: Pick; + appConnectionDAL: Pick; kmsService: Pick; }; diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts index edc8af709..a4cf59909 100644 --- a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts @@ -12,7 +12,7 @@ import { SecretSyncError } from "../secret-sync-errors"; import { GetAzureKeyVaultSecret, TAzureKeyVaultSyncWithCredentials } from "./azure-key-vault-sync-types"; type TAzureKeyVaultSyncFactoryDeps = { - appConnectionDAL: Pick; + appConnectionDAL: Pick; kmsService: Pick; }; diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts b/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts index eded130bb..822773948 100644 --- a/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts +++ b/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts @@ -23,56 +23,120 @@ export const ChecklySyncFns = { const config = secretSync.destinationConfig; - const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + if (config.groupId) { + // Handle group environment variables + const groupVars = await ChecklyPublicAPI.getCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId + ); - const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + const checklyGroupSecrets = Object.fromEntries(groupVars.map((variable) => [variable.key, variable])); - for await (const key of Object.keys(secretMap)) { - try { + // Prepare all variables to update at once + const updatedVariables = { ...checklyGroupSecrets }; + + for (const key of Object.keys(secretMap)) { const entry = secretMap[key]; - // If value is empty, we skip the upsert - checkly does not allow empty values + // If value is empty, we skip adding it - checkly does not allow empty values if (entry.value.trim() === "") { - // Delete the secret from Checkly if its empty + // Delete the secret from the group if it's empty if (!disableSecretDeletion) { - await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { - key - }); + delete updatedVariables[key]; } continue; // Skip empty values } - await ChecklyPublicAPI.upsertVariable(secretSync.connection, config.accountId, { + // Add or update the variable + updatedVariables[key] = { key, value: entry.value, - secret: true, locked: true - }); + }; + } + + // Remove secrets that are not in the secretMap if deletion is enabled + if (!disableSecretDeletion) { + for (const key of Object.keys(checklyGroupSecrets)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + delete updatedVariables[key]; + } + } + } + + // Update all group environment variables at once + try { + await ChecklyPublicAPI.updateCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId, + Object.values(updatedVariables) + ); } catch (error) { + if (error instanceof SecretSyncError) throw error; + throw new SecretSyncError({ error, - secretKey: key + secretKey: "group_update" }); } - } + } else { + // Handle global variables (existing logic) + const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); - if (disableSecretDeletion) return; + const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); - for await (const key of Object.keys(checklySecrets)) { - try { - // eslint-disable-next-line no-continue - if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + for await (const key of Object.keys(secretMap)) { + try { + const entry = secretMap[key]; - if (!secretMap[key]) { - await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { - key + // If value is empty, we skip the upsert - checkly does not allow empty values + if (entry.value.trim() === "") { + // Delete the secret from Checkly if its empty + if (!disableSecretDeletion) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key + }); + } + continue; // Skip empty values + } + + await ChecklyPublicAPI.upsertVariable(secretSync.connection, config.accountId, { + key, + value: entry.value, + secret: true, + locked: true + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (disableSecretDeletion) return; + + for await (const key of Object.keys(checklySecrets)) { + try { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key }); } - } catch (error) { - throw new SecretSyncError({ - error, - secretKey: key - }); } } }, @@ -80,23 +144,54 @@ export const ChecklySyncFns = { async removeSecrets(secretSync: TChecklySyncWithCredentials, secretMap: TSecretMap) { const config = secretSync.destinationConfig; - const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + if (config.groupId) { + // Handle group environment variables + const groupVars = await ChecklyPublicAPI.getCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId + ); - const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + const checklyGroupSecrets = Object.fromEntries(groupVars.map((variable) => [variable.key, variable])); + + // Filter out the secrets to remove + const remainingVariables = Object.keys(checklyGroupSecrets) + .filter((key) => !(key in secretMap)) + .map((key) => checklyGroupSecrets[key]); - for await (const secret of Object.keys(checklySecrets)) { try { - if (secret in secretMap) { - await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { - key: secret - }); - } + await ChecklyPublicAPI.updateCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId, + remainingVariables + ); } catch (error) { throw new SecretSyncError({ error, - secretKey: secret + secretKey: "group_remove" }); } + } else { + // Handle global variables (existing logic) + const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + + const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + + for await (const secret of Object.keys(checklySecrets)) { + try { + if (secret in secretMap) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key: secret + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: secret + }); + } + } } } }; diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts b/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts index 04f444357..fc511b2d7 100644 --- a/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts +++ b/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts @@ -11,7 +11,17 @@ import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types" const ChecklySyncDestinationConfigSchema = z.object({ accountId: z.string().min(1, "Account ID is required").max(255, "Account ID must be less than 255 characters"), - accountName: z.string().min(1, "Account Name is required").max(255, "Account ID must be less than 255 characters") + accountName: z + .string() + .min(1, "Account Name is required") + .max(255, "Account ID must be less than 255 characters") + .optional(), + groupId: z.string().min(1, "Group ID is required").max(255, "Group ID must be less than 255 characters").optional(), + groupName: z + .string() + .min(1, "Group Name is required") + .max(255, "Group Name must be less than 255 characters") + .optional() }); const ChecklySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; 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 f06f0cfc2..2cae048aa 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -1,7 +1,13 @@ -import { Octokit } from "@octokit/rest"; import sodium from "libsodium-wrappers"; -import { getGitHubClient } from "@app/services/app-connection/github"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { + getGitHubAppAuthToken, + getGitHubInstanceApiUrl, + GitHubConnectionMethod, + makePaginatedGitHubRequest, + requestWithGitHubGateway +} from "@app/services/app-connection/github"; import { GitHubSyncScope, GitHubSyncVisibility } from "@app/services/secret-sync/github/github-sync-enums"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; @@ -12,155 +18,165 @@ import { TGitHubPublicKey, TGitHubSecret, TGitHubSecretPayload, TGitHubSyncWithC // TODO: rate limit handling -const getEncryptedSecrets = async (client: Octokit, secretSync: TGitHubSyncWithCredentials) => { - let encryptedSecrets: TGitHubSecret[]; - - const { destinationConfig } = secretSync; +const getEncryptedSecrets = async ( + secretSync: TGitHubSyncWithCredentials, + gatewayService: Pick +) => { + const { destinationConfig, connection } = secretSync; + let path: string; switch (destinationConfig.scope) { case GitHubSyncScope.Organization: { - encryptedSecrets = await client.paginate("GET /orgs/{org}/actions/secrets", { - org: destinationConfig.org - }); + path = `/orgs/${encodeURIComponent(destinationConfig.org)}/actions/secrets`; break; } case GitHubSyncScope.Repository: { - encryptedSecrets = await client.paginate("GET /repos/{owner}/{repo}/actions/secrets", { - owner: destinationConfig.owner, - repo: destinationConfig.repo - }); - + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/actions/secrets`; break; } case GitHubSyncScope.RepositoryEnvironment: default: { - encryptedSecrets = await client.paginate("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets", { - owner: destinationConfig.owner, - repo: destinationConfig.repo, - environment_name: destinationConfig.env - }); + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/environments/${encodeURIComponent(destinationConfig.env)}/secrets`; break; } } - return encryptedSecrets; + return makePaginatedGitHubRequest( + connection, + gatewayService, + path, + (data) => data.secrets + ); }; -const getPublicKey = async (client: Octokit, secretSync: TGitHubSyncWithCredentials) => { - let publicKey: TGitHubPublicKey; - - const { destinationConfig } = secretSync; +const getPublicKey = async ( + secretSync: TGitHubSyncWithCredentials, + gatewayService: Pick, + token: string +) => { + const { destinationConfig, connection } = secretSync; + let path: string; switch (destinationConfig.scope) { case GitHubSyncScope.Organization: { - publicKey = ( - await client.request("GET /orgs/{org}/actions/secrets/public-key", { - org: destinationConfig.org - }) - ).data; + path = `/orgs/${encodeURIComponent(destinationConfig.org)}/actions/secrets/public-key`; break; } case GitHubSyncScope.Repository: { - publicKey = ( - await client.request("GET /repos/{owner}/{repo}/actions/secrets/public-key", { - owner: destinationConfig.owner, - repo: destinationConfig.repo - }) - ).data; + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/actions/secrets/public-key`; break; } case GitHubSyncScope.RepositoryEnvironment: default: { - publicKey = ( - await client.request("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets/public-key", { - owner: destinationConfig.owner, - repo: destinationConfig.repo, - environment_name: destinationConfig.env - }) - ).data; + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/environments/${encodeURIComponent(destinationConfig.env)}/secrets/public-key`; break; } } - return publicKey; + const response = await requestWithGitHubGateway(connection, gatewayService, { + url: `https://${await getGitHubInstanceApiUrl(connection)}${path}`, + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); + + return response.data; }; const deleteSecret = async ( - client: Octokit, secretSync: TGitHubSyncWithCredentials, + gatewayService: Pick, + token: string, encryptedSecret: TGitHubSecret ) => { - const { destinationConfig } = secretSync; + const { destinationConfig, connection } = secretSync; + let path: string; switch (destinationConfig.scope) { case GitHubSyncScope.Organization: { - await client.request(`DELETE /orgs/{org}/actions/secrets/{secret_name}`, { - org: destinationConfig.org, - secret_name: encryptedSecret.name - }); + path = `/orgs/${encodeURIComponent(destinationConfig.org)}/actions/secrets/${encodeURIComponent(encryptedSecret.name)}`; break; } case GitHubSyncScope.Repository: { - await client.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { - owner: destinationConfig.owner, - repo: destinationConfig.repo, - secret_name: encryptedSecret.name - }); + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/actions/secrets/${encodeURIComponent(encryptedSecret.name)}`; break; } case GitHubSyncScope.RepositoryEnvironment: default: { - await client.request("DELETE /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", { - owner: destinationConfig.owner, - repo: destinationConfig.repo, - environment_name: destinationConfig.env, - secret_name: encryptedSecret.name - }); + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/environments/${encodeURIComponent(destinationConfig.env)}/secrets/${encodeURIComponent(encryptedSecret.name)}`; break; } } + + await requestWithGitHubGateway(connection, gatewayService, { + url: `https://${await getGitHubInstanceApiUrl(connection)}${path}`, + method: "DELETE", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); }; -const putSecret = async (client: Octokit, secretSync: TGitHubSyncWithCredentials, payload: TGitHubSecretPayload) => { - const { destinationConfig } = secretSync; +const putSecret = async ( + secretSync: TGitHubSyncWithCredentials, + gatewayService: Pick, + token: string, + payload: TGitHubSecretPayload +) => { + const { destinationConfig, connection } = secretSync; + + let path: string; + let body: Record = payload; switch (destinationConfig.scope) { case GitHubSyncScope.Organization: { const { visibility, selectedRepositoryIds } = destinationConfig; - - await client.request(`PUT /orgs/{org}/actions/secrets/{secret_name}`, { - org: destinationConfig.org, + path = `/orgs/${encodeURIComponent(destinationConfig.org)}/actions/secrets/${encodeURIComponent(payload.secret_name)}`; + body = { ...payload, visibility, ...(visibility === GitHubSyncVisibility.Selected && { selected_repository_ids: selectedRepositoryIds }) - }); + }; break; } case GitHubSyncScope.Repository: { - await client.request("PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}", { - owner: destinationConfig.owner, - repo: destinationConfig.repo, - ...payload - }); + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/actions/secrets/${encodeURIComponent(payload.secret_name)}`; break; } case GitHubSyncScope.RepositoryEnvironment: default: { - await client.request("PUT /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", { - owner: destinationConfig.owner, - repo: destinationConfig.repo, - environment_name: destinationConfig.env, - ...payload - }); + path = `/repos/${encodeURIComponent(destinationConfig.owner)}/${encodeURIComponent(destinationConfig.repo)}/environments/${encodeURIComponent(destinationConfig.env)}/secrets/${encodeURIComponent(payload.secret_name)}`; break; } } + + await requestWithGitHubGateway(connection, gatewayService, { + url: `https://${await getGitHubInstanceApiUrl(connection)}${path}`, + method: "PUT", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + }, + data: body + }); }; export const GithubSyncFns = { - syncSecrets: async (secretSync: TGitHubSyncWithCredentials, secretMap: TSecretMap) => { + syncSecrets: async ( + secretSync: TGitHubSyncWithCredentials, + ogSecretMap: TSecretMap, + gatewayService: Pick + ) => { + const secretMap = Object.fromEntries(Object.entries(ogSecretMap).map(([i, v]) => [i.toUpperCase(), v])); + switch (secretSync.destinationConfig.scope) { case GitHubSyncScope.Organization: if (Object.values(secretMap).length > 1000) { @@ -187,38 +203,40 @@ export const GithubSyncFns = { ); } - const client = getGitHubClient(secretSync.connection); + const { connection } = secretSync; + const token = + connection.method === GitHubConnectionMethod.OAuth + ? connection.credentials.accessToken + : await getGitHubAppAuthToken(connection, gatewayService); - const encryptedSecrets = await getEncryptedSecrets(client, secretSync); + const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); + const publicKey = await getPublicKey(secretSync, gatewayService, token); - const publicKey = await getPublicKey(client, secretSync); + await sodium.ready; + for await (const key of Object.keys(secretMap)) { + // convert secret & base64 key to Uint8Array. + const binaryKey = sodium.from_base64(publicKey.key, sodium.base64_variants.ORIGINAL); + const binarySecretValue = sodium.from_string(secretMap[key].value); - await sodium.ready.then(async () => { - for await (const key of Object.keys(secretMap)) { - // convert secret & base64 key to Uint8Array. - const binaryKey = sodium.from_base64(publicKey.key, sodium.base64_variants.ORIGINAL); - const binarySecretValue = sodium.from_string(secretMap[key].value); + // encrypt secret using libsodium + const encryptedBytes = sodium.crypto_box_seal(binarySecretValue, binaryKey); - // encrypt secret using libsodium - const encryptedBytes = sodium.crypto_box_seal(binarySecretValue, binaryKey); + // convert encrypted Uint8Array to base64 + const encryptedSecretValue = sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL); - // convert encrypted Uint8Array to base64 - const encryptedSecretValue = sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL); - - try { - await putSecret(client, secretSync, { - secret_name: key, - encrypted_value: encryptedSecretValue, - key_id: publicKey.key_id - }); - } catch (error) { - throw new SecretSyncError({ - error, - secretKey: key - }); - } + try { + await putSecret(secretSync, gatewayService, token, { + secret_name: key, + encrypted_value: encryptedSecretValue, + key_id: publicKey.key_id + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); } - }); + } if (secretSync.syncOptions.disableSecretDeletion) return; @@ -228,21 +246,31 @@ export const GithubSyncFns = { continue; if (!(encryptedSecret.name in secretMap)) { - await deleteSecret(client, secretSync, encryptedSecret); + await deleteSecret(secretSync, gatewayService, token, encryptedSecret); } } }, getSecrets: async (secretSync: TGitHubSyncWithCredentials) => { throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); }, - removeSecrets: async (secretSync: TGitHubSyncWithCredentials, secretMap: TSecretMap) => { - const client = getGitHubClient(secretSync.connection); + removeSecrets: async ( + secretSync: TGitHubSyncWithCredentials, + ogSecretMap: TSecretMap, + gatewayService: Pick + ) => { + const secretMap = Object.fromEntries(Object.entries(ogSecretMap).map(([i, v]) => [i.toUpperCase(), v])); - const encryptedSecrets = await getEncryptedSecrets(client, secretSync); + const { connection } = secretSync; + const token = + connection.method === GitHubConnectionMethod.OAuth + ? connection.credentials.accessToken + : await getGitHubAppAuthToken(connection, gatewayService); + + const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); for await (const encryptedSecret of encryptedSecrets) { if (encryptedSecret.name in secretMap) { - await deleteSecret(client, secretSync, encryptedSecret); + await deleteSecret(secretSync, gatewayService, token, encryptedSecret); } } } diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts index 724eec7be..9168c96a6 100644 --- a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts @@ -1,9 +1,13 @@ import { isAxiosError } from "axios"; -import { request } from "@app/lib/config/request"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { removeTrailingSlash } from "@app/lib/fn"; -import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; -import { getHCVaultAccessToken, getHCVaultInstanceUrl } from "@app/services/app-connection/hc-vault"; +import { + getHCVaultAccessToken, + getHCVaultInstanceUrl, + requestWithHCVaultGateway, + THCVaultConnection +} from "@app/services/app-connection/hc-vault"; import { THCVaultListVariables, THCVaultListVariablesResponse, @@ -14,19 +18,20 @@ import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; -const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables) => { - await blockLocalAndPrivateIpAddresses(instanceUrl); - +const listHCVaultVariables = async ( + { instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables, + connection: THCVaultConnection, + gatewayService: Pick +) => { try { - const { data } = await request.get( - `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, - { - headers: { - "X-Vault-Token": accessToken, - ...(namespace ? { "X-Vault-Namespace": namespace } : {}) - } + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + ...(namespace ? { "X-Vault-Namespace": namespace } : {}) } - ); + }); return data.data.data; } catch (error: unknown) { @@ -39,33 +44,29 @@ const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken }; // Hashicorp Vault updates all variables in one batch. This is to respect their versioning -const updateHCVaultVariables = async ({ - path, - instanceUrl, - namespace, - accessToken, - mount, - data -}: TPostHCVaultVariable) => { - await blockLocalAndPrivateIpAddresses(instanceUrl); - - return request.post( - `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, - { - data +const updateHCVaultVariables = async ( + { path, instanceUrl, namespace, accessToken, mount, data }: TPostHCVaultVariable, + connection: THCVaultConnection, + gatewayService: Pick +) => { + return requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, + method: "POST", + headers: { + "X-Vault-Token": accessToken, + ...(namespace ? { "X-Vault-Namespace": namespace } : {}), + "Content-Type": "application/json" }, - { - headers: { - "X-Vault-Token": accessToken, - ...(namespace ? { "X-Vault-Namespace": namespace } : {}), - "Content-Type": "application/json" - } - } - ); + data: { data } + }); }; export const HCVaultSyncFns = { - syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { + syncSecrets: async ( + secretSync: THCVaultSyncWithCredentials, + secretMap: TSecretMap, + gatewayService: Pick + ) => { const { connection, environment, @@ -74,16 +75,20 @@ export const HCVaultSyncFns = { } = secretSync; const { namespace } = connection.credentials; - const accessToken = await getHCVaultAccessToken(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); const instanceUrl = await getHCVaultInstanceUrl(connection); - const variables = await listHCVaultVariables({ - instanceUrl, - accessToken, - namespace, - mount, - path - }); + const variables = await listHCVaultVariables( + { + instanceUrl, + accessToken, + namespace, + mount, + path + }, + connection, + gatewayService + ); let tainted = false; for (const entry of Object.entries(secretMap)) { @@ -110,24 +115,36 @@ export const HCVaultSyncFns = { if (!tainted) return; try { - await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables }); + await updateHCVaultVariables( + { accessToken, instanceUrl, namespace, mount, path, data: variables }, + connection, + gatewayService + ); } catch (error) { throw new SecretSyncError({ error }); } }, - removeSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { + removeSecrets: async ( + secretSync: THCVaultSyncWithCredentials, + secretMap: TSecretMap, + gatewayService: Pick + ) => { const { connection, destinationConfig: { mount, path } } = secretSync; const { namespace } = connection.credentials; - const accessToken = await getHCVaultAccessToken(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); const instanceUrl = await getHCVaultInstanceUrl(connection); - const variables = await listHCVaultVariables({ instanceUrl, namespace, accessToken, mount, path }); + const variables = await listHCVaultVariables( + { instanceUrl, namespace, accessToken, mount, path }, + connection, + gatewayService + ); for await (const [key] of Object.entries(variables)) { if (key in secretMap) { @@ -136,30 +153,41 @@ export const HCVaultSyncFns = { } try { - await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables }); + await updateHCVaultVariables( + { accessToken, instanceUrl, namespace, mount, path, data: variables }, + connection, + gatewayService + ); } catch (error) { throw new SecretSyncError({ error }); } }, - getSecrets: async (secretSync: THCVaultSyncWithCredentials) => { + getSecrets: async ( + secretSync: THCVaultSyncWithCredentials, + gatewayService: Pick + ) => { const { connection, destinationConfig: { mount, path } } = secretSync; const { namespace } = connection.credentials; - const accessToken = await getHCVaultAccessToken(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); const instanceUrl = await getHCVaultInstanceUrl(connection); - const variables = await listHCVaultVariables({ - instanceUrl, - namespace, - accessToken, - mount, - path - }); + const variables = await listHCVaultVariables( + { + instanceUrl, + namespace, + accessToken, + mount, + path + }, + connection, + gatewayService + ); return Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, { value }])); } diff --git a/backend/src/services/secret-sync/netlify/index.ts b/backend/src/services/secret-sync/netlify/index.ts new file mode 100644 index 000000000..2730a7757 --- /dev/null +++ b/backend/src/services/secret-sync/netlify/index.ts @@ -0,0 +1,4 @@ +export * from "./netlify-sync-constants"; +export * from "./netlify-sync-fns"; +export * from "./netlify-sync-schemas"; +export * from "./netlify-sync-types"; diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-constants.ts b/backend/src/services/secret-sync/netlify/netlify-sync-constants.ts new file mode 100644 index 000000000..c07f4c77a --- /dev/null +++ b/backend/src/services/secret-sync/netlify/netlify-sync-constants.ts @@ -0,0 +1,19 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const NETLIFY_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Netlify", + destination: SecretSync.Netlify, + connection: AppConnection.Netlify, + canImportSecrets: true +}; + +export enum NetlifySyncContext { + All = "all", + DeployPreview = "deploy-preview", + Production = "production", + BranchDeploy = "branch-deploy", + Dev = "dev", + Branch = "branch" +} diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-fns.ts b/backend/src/services/secret-sync/netlify/netlify-sync-fns.ts new file mode 100644 index 000000000..23445623d --- /dev/null +++ b/backend/src/services/secret-sync/netlify/netlify-sync-fns.ts @@ -0,0 +1,125 @@ +/* eslint-disable no-continue */ +import { NetlifyPublicAPI } from "@app/services/app-connection/netlify/netlify-connection-public-client"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { SecretSyncError } from "../secret-sync-errors"; +import type { TNetlifySyncWithCredentials } from "./netlify-sync-types"; + +export const NetlifySyncFns = { + async getSecrets(secretSync: TNetlifySyncWithCredentials): Promise { + const config = secretSync.destinationConfig; + + const params = { + account_id: config.accountId, + context_name: config.context, // Only used in the case of getVariables + site_id: config.siteId + }; + + const variables = await NetlifyPublicAPI.getVariables(secretSync.connection, params); + + const map = {} as TSecretMap; + + for (const variable of variables) { + if (variable.is_secret) continue; + + const latest = variable.values.sort((a, b) => Number(b.created_at) - Number(a.created_at))[0]; + + map[variable.key] = { + value: latest?.value ?? "" + }; + } + + return map; + }, + + async syncSecrets(secretSync: TNetlifySyncWithCredentials, secretMap: TSecretMap) { + const { + environment, + syncOptions: { disableSecretDeletion, keySchema } + } = secretSync; + + const config = secretSync.destinationConfig; + + const params = { + account_id: config.accountId, + context_name: config.context, + site_id: config.siteId + }; + + const variables = await NetlifyPublicAPI.getVariables(secretSync.connection, params); + + const existing = Object.fromEntries(variables.map((variable) => [variable.key, variable])); + + for await (const key of Object.keys(secretMap)) { + try { + const entry = secretMap[key]; + + await NetlifyPublicAPI.upsertVariable(secretSync.connection, params, { + key, + is_secret: false, + values: [ + { + value: entry.value, + context: config.context + } + ] + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (disableSecretDeletion) return; + + for await (const key of Object.keys(existing)) { + try { + // eslint-disable-next-line no-continue, @typescript-eslint/no-unsafe-argument + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + await NetlifyPublicAPI.deleteVariable(secretSync.connection, params, { + key + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + }, + + async removeSecrets(secretSync: TNetlifySyncWithCredentials, secretMap: TSecretMap) { + const config = secretSync.destinationConfig; + + const params = { + account_id: config.accountId, + context_name: config.context, + site_id: config.siteId + }; + + const variables = await NetlifyPublicAPI.getVariables(secretSync.connection, params); + + const existingSecrets = Object.fromEntries(variables.map((variable) => [variable.key, variable])); + + for await (const secret of Object.keys(existingSecrets)) { + try { + if (secret in secretMap) { + await NetlifyPublicAPI.deleteVariable(secretSync.connection, params, { + key: secret + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: secret + }); + } + } + } +}; diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts b/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts new file mode 100644 index 000000000..cd9a985c9 --- /dev/null +++ b/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +import { NetlifySyncContext } from "./netlify-sync-constants"; + +const NetlifySyncDestinationConfigSchema = z.object({ + accountId: z + .string() + .min(1, "Account ID is required") + .max(255, "Account ID must be less than 255 characters") + .describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.accountId), + accountName: z + .string() + .min(1, "Account Name is required") + .max(255, "Account Name must be less than 255 characters") + .optional() + .describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.accountName), + siteId: z + .string() + .min(1, "Site ID is required") + .max(255, "Site ID must be less than 255 characters") + .optional() + .describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.siteId), + siteName: z + .string() + .min(1, "Site Name is required") + .max(255, "Site Name must be less than 255 characters") + .optional() + .describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.siteName), + context: z.nativeEnum(NetlifySyncContext).optional().describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.context) +}); + +const NetlifySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const NetlifySyncSchema = BaseSecretSyncSchema(SecretSync.Netlify, NetlifySyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Netlify), + destinationConfig: NetlifySyncDestinationConfigSchema +}); + +export const CreateNetlifySyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Netlify, + NetlifySyncOptionsConfig +).extend({ + destinationConfig: NetlifySyncDestinationConfigSchema +}); + +export const UpdateNetlifySyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Netlify, + NetlifySyncOptionsConfig +).extend({ + destinationConfig: NetlifySyncDestinationConfigSchema.optional() +}); + +export const NetlifySyncListItemSchema = z.object({ + name: z.literal("Netlify"), + connection: z.literal(AppConnection.Netlify), + destination: z.literal(SecretSync.Netlify), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-types.ts b/backend/src/services/secret-sync/netlify/netlify-sync-types.ts new file mode 100644 index 000000000..2cef04686 --- /dev/null +++ b/backend/src/services/secret-sync/netlify/netlify-sync-types.ts @@ -0,0 +1,15 @@ +import z from "zod"; + +import { TNetlifyConnection } from "@app/services/app-connection/netlify"; + +import { CreateNetlifySyncSchema, NetlifySyncListItemSchema, NetlifySyncSchema } from "./netlify-sync-schemas"; + +export type TNetlifySyncListItem = z.infer; + +export type TNetlifySync = z.infer; + +export type TNetlifySyncInput = z.infer; + +export type TNetlifySyncWithCredentials = TNetlifySync & { + connection: TNetlifyConnection; +}; 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-sync/secret-sync-dal.ts b/backend/src/services/secret-sync/secret-sync-dal.ts index 617393668..e50593f10 100644 --- a/backend/src/services/secret-sync/secret-sync-dal.ts +++ b/backend/src/services/secret-sync/secret-sync-dal.ts @@ -30,6 +30,7 @@ const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: Secre db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"), db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("connectionGatewayId"), db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), db @@ -65,6 +66,7 @@ const expandSecretSync = ( connectionUpdatedAt, connectionVersion, connectionIsPlatformManagedCredentials, + connectionGatewayId, ...el } = secretSync; @@ -83,7 +85,8 @@ const expandSecretSync = ( createdAt: connectionCreatedAt, updatedAt: connectionUpdatedAt, version: connectionVersion, - isPlatformManagedCredentials: connectionIsPlatformManagedCredentials + isPlatformManagedCredentials: connectionIsPlatformManagedCredentials, + gatewayId: connectionGatewayId }, folder: folder ? { diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 55859200a..fe0dc9f56 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -27,6 +27,7 @@ export enum SecretSync { Railway = "railway", Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", + Netlify = "netlify", Bitbucket = "bitbucket" } diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index caa6ddcee..5b083baf6 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -1,6 +1,7 @@ import { AxiosError } from "axios"; import handlebars from "handlebars"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "@app/ee/services/secret-sync/oci-vault"; import { BadRequestError } from "@app/lib/errors"; @@ -47,6 +48,7 @@ import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify"; import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants"; import { RailwaySyncFns } from "./railway/railway-sync-fns"; import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render"; @@ -87,6 +89,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Railway]: RAILWAY_SYNC_LIST_OPTION, [SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION, [SecretSync.DigitalOceanAppPlatform]: DIGITAL_OCEAN_APP_PLATFORM_SYNC_LIST_OPTION, + [SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION, [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION }; @@ -97,6 +100,7 @@ export const listSecretSyncOptions = () => { type TSyncSecretDeps = { appConnectionDAL: Pick; kmsService: Pick; + gatewayService: Pick; }; // Add schema to secret keys @@ -191,7 +195,7 @@ export const SecretSyncFns = { syncSecrets: ( secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap, - { kmsService, appConnectionDAL }: TSyncSecretDeps + { kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps ): Promise => { const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); @@ -201,7 +205,7 @@ export const SecretSyncFns = { case SecretSync.AWSSecretsManager: return AwsSecretsManagerSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.GitHub: - return GithubSyncFns.syncSecrets(secretSync, schemaSecretMap); + return GithubSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService); case SecretSync.GCPSecretManager: return GcpSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.AzureKeyVault: @@ -240,7 +244,7 @@ export const SecretSyncFns = { case SecretSync.Windmill: return WindmillSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.HCVault: - return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap); + return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService); case SecretSync.TeamCity: return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.OCIVault: @@ -267,6 +271,8 @@ export const SecretSyncFns = { return SupabaseSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.DigitalOceanAppPlatform: return DigitalOceanAppPlatformSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Netlify: + return NetlifySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap); default: @@ -277,7 +283,7 @@ export const SecretSyncFns = { }, getSecrets: async ( secretSync: TSecretSyncWithCredentials, - { kmsService, appConnectionDAL }: TSyncSecretDeps + { kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps ): Promise => { let secretMap: TSecretMap; switch (secretSync.destination) { @@ -335,7 +341,7 @@ export const SecretSyncFns = { secretMap = await WindmillSyncFns.getSecrets(secretSync); break; case SecretSync.HCVault: - secretMap = await HCVaultSyncFns.getSecrets(secretSync); + secretMap = await HCVaultSyncFns.getSecrets(secretSync, gatewayService); break; case SecretSync.TeamCity: secretMap = await TeamCitySyncFns.getSecrets(secretSync); @@ -379,6 +385,9 @@ export const SecretSyncFns = { case SecretSync.DigitalOceanAppPlatform: secretMap = await DigitalOceanAppPlatformSyncFns.getSecrets(secretSync); break; + case SecretSync.Netlify: + secretMap = await NetlifySyncFns.getSecrets(secretSync); + break; case SecretSync.Bitbucket: secretMap = await BitbucketSyncFns.getSecrets(secretSync); break; @@ -395,7 +404,7 @@ export const SecretSyncFns = { removeSecrets: ( secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap, - { kmsService, appConnectionDAL }: TSyncSecretDeps + { kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps ): Promise => { const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); @@ -405,7 +414,7 @@ export const SecretSyncFns = { case SecretSync.AWSSecretsManager: return AwsSecretsManagerSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.GitHub: - return GithubSyncFns.removeSecrets(secretSync, schemaSecretMap); + return GithubSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService); case SecretSync.GCPSecretManager: return GcpSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.AzureKeyVault: @@ -442,7 +451,7 @@ export const SecretSyncFns = { case SecretSync.Windmill: return WindmillSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.HCVault: - return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap); + return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService); case SecretSync.TeamCity: return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.OCIVault: @@ -471,6 +480,8 @@ export const SecretSyncFns = { return SupabaseSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.DigitalOceanAppPlatform: return DigitalOceanAppPlatformSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Netlify: + return NetlifySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap); default: diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 2f2b3c427..04e91051d 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -30,6 +30,7 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Railway]: "Railway", [SecretSync.Checkly]: "Checkly", [SecretSync.DigitalOceanAppPlatform]: "Digital Ocean App Platform", + [SecretSync.Netlify]: "Netlify", [SecretSync.Bitbucket]: "Bitbucket" }; @@ -62,6 +63,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Railway]: AppConnection.Railway, [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, + [SecretSync.Netlify]: AppConnection.Netlify, [SecretSync.Bitbucket]: AppConnection.Bitbucket }; @@ -94,5 +96,6 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Railway]: SecretSyncPlanType.Regular, [SecretSync.Checkly]: SecretSyncPlanType.Regular, [SecretSync.DigitalOceanAppPlatform]: SecretSyncPlanType.Regular, + [SecretSync.Netlify]: SecretSyncPlanType.Regular, [SecretSync.Bitbucket]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 5d788ea88..915ff89bf 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -4,6 +4,7 @@ import { Job } from "bullmq"; import { ProjectMembershipRole, SecretType } from "@app/db/schemas"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; @@ -79,8 +80,8 @@ type TSecretSyncQueueFactoryDep = { | "deleteMany" | "invalidateSecretCacheByProjectId" >; - secretImportDAL: Pick; - secretSyncDAL: Pick; + secretImportDAL: Pick; + secretSyncDAL: Pick; auditLogService: Pick; projectMembershipDAL: Pick; projectDAL: TProjectDALFactory; @@ -96,23 +97,22 @@ type TSecretSyncQueueFactoryDep = { resourceMetadataDAL: Pick; folderCommitService: Pick; licenseService: Pick; + gatewayService: Pick; }; type SecretSyncActionJob = Job< TQueueSecretSyncSyncSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO >; +const JITTER_MS = 10 * 1000; +const REQUEUE_MS = 30 * 1000; +const REQUEUE_LIMIT = 30; +const CONNECTION_CONCURRENCY_LIMIT = 3; + const getRequeueDelay = (failureCount?: number) => { - if (!failureCount) return 0; - - const baseDelay = 1000; - const maxDelay = 30000; - - const delay = Math.min(baseDelay * 2 ** failureCount, maxDelay); - - const jitter = delay * (0.5 + Math.random() * 0.5); - - return jitter; + const jitter = Math.random() * JITTER_MS; + if (!failureCount) return jitter; + return REQUEUE_MS + jitter; }; export const secretSyncQueueFactory = ({ @@ -138,7 +138,8 @@ export const secretSyncQueueFactory = ({ secretVersionTagV2BridgeDAL, resourceMetadataDAL, folderCommitService, - licenseService + licenseService, + gatewayService }: TSecretSyncQueueFactoryDep) => { const appCfg = getConfig(); @@ -190,6 +191,46 @@ export const secretSyncQueueFactory = ({ folderCommitService }); + const $isConnectionConcurrencyLimitReached = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + if (!concurrencyCount) return false; + + const count = Number.parseInt(concurrencyCount, 10); + + if (Number.isNaN(count)) return false; + + return count >= CONNECTION_CONCURRENCY_LIMIT; + }; + + const $incrementConnectionConcurrencyCount = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + const currentCount = Number.parseInt(concurrencyCount || "0", 10); + + const incrementedCount = Number.isNaN(currentCount) ? 1 : currentCount + 1; + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId), + (REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds + incrementedCount + ); + }; + + const $decrementConnectionConcurrencyCount = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + const currentCount = Number.parseInt(concurrencyCount || "0", 10); + + const decrementedCount = Math.max(0, Number.isNaN(currentCount) ? 0 : currentCount - 1); + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId), + (REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds + decrementedCount + ); + }; + const $getInfisicalSecrets = async ( secretSync: TSecretSyncRaw | TSecretSyncWithCredentials, includeImports = true @@ -353,7 +394,8 @@ export const secretSyncQueueFactory = ({ const importedSecrets = await SecretSyncFns.getSecrets(secretSync, { appConnectionDAL, - kmsService + kmsService, + gatewayService }); if (!Object.keys(importedSecrets).length) return {}; @@ -412,15 +454,11 @@ export const secretSyncQueueFactory = ({ return importedSecretMap; }; - const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO) => { + const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO, secretSync: TSecretSyncRaw) => { const { data: { syncId, auditLogInfo } } = job; - const secretSync = await secretSyncDAL.findById(syncId); - - if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); - await enterpriseSyncCheck( licenseService, secretSync.destination as SecretSync, @@ -481,7 +519,8 @@ export const secretSyncQueueFactory = ({ await SecretSyncFns.syncSecrets(secretSyncWithCredentials, secretMap, { appConnectionDAL, - kmsService + kmsService, + gatewayService }); isSynced = true; @@ -561,15 +600,11 @@ export const secretSyncQueueFactory = ({ logger.info("SecretSync Sync Job with ID %s Completed", job.id); }; - const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO) => { + const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO, secretSync: TSecretSyncRaw) => { const { data: { syncId, auditLogInfo, importBehavior } } = job; - const secretSync = await secretSyncDAL.findById(syncId); - - if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); - await secretSyncDAL.updateById(syncId, { importStatus: SecretSyncStatus.Running }); @@ -678,15 +713,11 @@ export const secretSyncQueueFactory = ({ logger.info("SecretSync Import Job with ID %s Completed", job.id); }; - const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO) => { + const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO, secretSync: TSecretSyncRaw) => { const { data: { syncId, auditLogInfo, deleteSyncOnComplete } } = job; - const secretSync = await secretSyncDAL.findById(syncId); - - if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); - await enterpriseSyncCheck( licenseService, secretSync.destination as SecretSync, @@ -730,7 +761,8 @@ export const secretSyncQueueFactory = ({ secretMap, { appConnectionDAL, - kmsService + kmsService, + gatewayService } ); @@ -888,6 +920,17 @@ export const secretSyncQueueFactory = ({ const secretSyncs = await secretSyncDAL.find({ folderId: folder.id, isAutoSyncEnabled: true }); + await secretSyncDAL.update( + { + $in: { + id: secretSyncs.map((sync) => sync.id) + } + }, + { + syncStatus: SecretSyncStatus.Pending + } + ); + await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncSyncSecretsById({ syncId: secretSync.id }))); }; @@ -898,7 +941,7 @@ export const secretSyncQueueFactory = ({ case QueueJobs.SecretSyncSyncSecrets: { const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueueSecretSyncSyncSecretsByIdDTO; - if (failedToAcquireLockCount < 10) { + if (failedToAcquireLockCount < REQUEUE_LIMIT) { await queueSecretSyncSyncSecretsById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 }); return; } @@ -968,6 +1011,26 @@ export const secretSyncQueueFactory = ({ | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO; + const secretSync = await secretSyncDAL.findById(syncId); + + if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`); + + const { connectionId } = secretSync; + + if (job.name === QueueJobs.SecretSyncSyncSecrets) { + const isConcurrentLimitReached = await $isConnectionConcurrencyLimitReached(connectionId); + + if (isConcurrentLimitReached) { + logger.info( + `SecretSync Concurrency limit reached [syncId=${syncId}] [job=${job.name}] [connectionId=${connectionId}]` + ); + + await $handleAcquireLockFailure(job as SecretSyncActionJob); + + return; + } + } + let lock: Awaited>; try { @@ -987,20 +1050,26 @@ export const secretSyncQueueFactory = ({ try { switch (job.name) { - case QueueJobs.SecretSyncSyncSecrets: - await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO); + case QueueJobs.SecretSyncSyncSecrets: { + await $incrementConnectionConcurrencyCount(connectionId); + await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO, secretSync); break; + } case QueueJobs.SecretSyncImportSecrets: - await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO); + await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO, secretSync); break; case QueueJobs.SecretSyncRemoveSecrets: - await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO); + await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO, secretSync); break; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled Secret Sync Job ${job.name}`); } } finally { + if (job.name === QueueJobs.SecretSyncSyncSecrets) { + await $decrementConnectionConcurrencyCount(connectionId); + } + await lock.release(); } }); diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index a844fbe48..6435e19d3 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -117,6 +117,7 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { TNetlifySync, TNetlifySyncInput, TNetlifySyncListItem, TNetlifySyncWithCredentials } from "./netlify"; import { TRailwaySync, TRailwaySyncInput, @@ -178,6 +179,7 @@ export type TSecretSync = | TRailwaySync | TChecklySync | TSupabaseSync + | TNetlifySync | TBitbucketSync; export type TSecretSyncWithCredentials = @@ -209,6 +211,7 @@ export type TSecretSyncWithCredentials = | TChecklySyncWithCredentials | TSupabaseSyncWithCredentials | TDigitalOceanAppPlatformSyncWithCredentials + | TNetlifySyncWithCredentials | TBitbucketSyncWithCredentials; export type TSecretSyncInput = @@ -240,6 +243,7 @@ export type TSecretSyncInput = | TChecklySyncInput | TSupabaseSyncInput | TDigitalOceanAppPlatformSyncInput + | TNetlifySyncInput | TBitbucketSyncInput; export type TSecretSyncListItem = @@ -271,6 +275,7 @@ export type TSecretSyncListItem = | TChecklySyncListItem | TSupabaseSyncListItem | TDigitalOceanAppPlatformSyncListItem + | TNetlifySyncListItem | TBitbucketSyncListItem; export type TSyncOptionsConfig = { 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..48ab07816 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 @@ -108,7 +108,7 @@ type TSecretV2BridgeServiceFactoryDep = { | "findBySecretPathMultiEnv" | "findSecretPathByFolderIds" >; - secretImportDAL: Pick; + secretImportDAL: Pick; secretQueueService: Pick; secretApprovalPolicyService: Pick; secretApprovalRequestDAL: Pick; @@ -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 + } + } }); } @@ -1050,12 +1074,22 @@ export const secretV2BridgeServiceFactory = ({ currentPath: path }); - if (!deepPaths) return { secrets: [], imports: [] }; + if (!deepPaths?.length) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); } else { const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) return { secrets: [], imports: [] }; + if (!folder) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = [{ folderId: folder.id, path }]; } @@ -1708,7 +1742,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 +2117,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 +2264,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 +2809,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 +2827,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..ed794a664 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"; @@ -82,7 +86,7 @@ type TSecretQueueFactoryDep = { integrationAuthService: Pick; folderDAL: TSecretFolderDALFactory; secretDAL: TSecretDALFactory; - secretImportDAL: Pick; + secretImportDAL: Pick; webhookDAL: Pick; projectEnvDAL: Pick; projectDAL: TProjectDALFactory; @@ -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/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index f776b4527..6bafa3ba6 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -637,7 +637,12 @@ export const secretServiceFactory = ({ } }); - if (!deepPaths) return { secrets: [], imports: [] }; + if (!deepPaths?.length) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); } else { @@ -647,7 +652,12 @@ export const secretServiceFactory = ({ }); const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) return { secrets: [], imports: [] }; + if (!folder) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = [{ folderId: folder.id, path }]; } 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/AccessApprovalRequestTemplate.tsx b/backend/src/services/smtp/emails/AccessApprovalRequestTemplate.tsx index fef072546..bdefc8b86 100644 --- a/backend/src/services/smtp/emails/AccessApprovalRequestTemplate.tsx +++ b/backend/src/services/smtp/emails/AccessApprovalRequestTemplate.tsx @@ -1,7 +1,9 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +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 AccessApprovalRequestTemplateProps extends Omit { projectName: string; @@ -38,18 +40,15 @@ export const AccessApprovalRequestTemplate = ({ You have a new access approval request pending review for the project {projectName} -

+
- {requesterFullName} ( - - {requesterEmail} - - ) has requested {isTemporary ? "temporary" : "permanent"} access to {secretPath} in the{" "} + {requesterFullName} ({requesterEmail}) + has requested {isTemporary ? "temporary" : "permanent"} access to {secretPath} in the{" "} {environment} environment. {isTemporary && ( - + This access will expire {expiresIn} after approval. )} @@ -67,13 +66,8 @@ export const AccessApprovalRequestTemplate = ({ )}
-
- +
+ Review Request
); 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/BaseButton.tsx b/backend/src/services/smtp/emails/BaseButton.tsx new file mode 100644 index 000000000..7d79ea581 --- /dev/null +++ b/backend/src/services/smtp/emails/BaseButton.tsx @@ -0,0 +1,18 @@ +import { Button } from "@react-email/components"; +import React from "react"; + +type Props = { + href: string; + children: string; +}; + +export const BaseButton = ({ href, children }: Props) => { + return ( + + ); +}; diff --git a/backend/src/services/smtp/emails/BaseEmailWrapper.tsx b/backend/src/services/smtp/emails/BaseEmailWrapper.tsx index 01bf779c5..3e908db18 100644 --- a/backend/src/services/smtp/emails/BaseEmailWrapper.tsx +++ b/backend/src/services/smtp/emails/BaseEmailWrapper.tsx @@ -16,23 +16,21 @@ export const BaseEmailWrapper = ({ title, preview, children, siteUrl }: BaseEmai {preview} -
-
-
- Infisical Logo -
+
+ Infisical Logo
+
{children}

Email sent via{" "} - + Infisical diff --git a/backend/src/services/smtp/emails/BaseLink.tsx b/backend/src/services/smtp/emails/BaseLink.tsx new file mode 100644 index 000000000..f2a8763e6 --- /dev/null +++ b/backend/src/services/smtp/emails/BaseLink.tsx @@ -0,0 +1,15 @@ +import { Link } from "@react-email/components"; +import React from "react"; + +type Props = { + href: string; + children: string; +}; + +export const BaseLink = ({ href, children }: Props) => { + return ( + + {children} + + ); +}; diff --git a/backend/src/services/smtp/emails/EmailMfaTemplate.tsx b/backend/src/services/smtp/emails/EmailMfaTemplate.tsx index b01e2f8af..a5d1fdf03 100644 --- a/backend/src/services/smtp/emails/EmailMfaTemplate.tsx +++ b/backend/src/services/smtp/emails/EmailMfaTemplate.tsx @@ -1,7 +1,8 @@ -import { Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface EmailMfaTemplateProps extends Omit { code: string; @@ -25,11 +26,7 @@ export const EmailMfaTemplate = ({ code, siteUrl, isCloud }: EmailMfaTemplatePro Not you?{" "} {isCloud ? ( <> - Contact us at{" "} - - support@infisical.com - {" "} - immediately + Contact us at support@infisical.com immediately ) : ( "Contact your administrator immediately" diff --git a/backend/src/services/smtp/emails/EmailVerificationTemplate.tsx b/backend/src/services/smtp/emails/EmailVerificationTemplate.tsx index fc32b01b0..313601e6a 100644 --- a/backend/src/services/smtp/emails/EmailVerificationTemplate.tsx +++ b/backend/src/services/smtp/emails/EmailVerificationTemplate.tsx @@ -1,7 +1,8 @@ -import { Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface EmailVerificationTemplateProps extends Omit { code: string; @@ -29,10 +30,7 @@ export const EmailVerificationTemplate = ({ code, siteUrl, isCloud }: EmailVerif Questions about Infisical?{" "} {isCloud ? ( <> - Email us at{" "} - - support@infisical.com - + Email us at support@infisical.com ) : ( "Contact your administrator" diff --git a/backend/src/services/smtp/emails/ExternalImportFailedTemplate.tsx b/backend/src/services/smtp/emails/ExternalImportFailedTemplate.tsx index 3cca41721..28bccc9d5 100644 --- a/backend/src/services/smtp/emails/ExternalImportFailedTemplate.tsx +++ b/backend/src/services/smtp/emails/ExternalImportFailedTemplate.tsx @@ -1,7 +1,8 @@ -import { Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface ExternalImportFailedTemplateProps extends Omit { error: string; @@ -21,12 +22,9 @@ export const ExternalImportFailedTemplate = ({ error, siteUrl, provider }: Exter If your issue persists, you can contact the Infisical team at{" "} - - support@infisical.com - - . + support@infisical.com. - + Error: "{error}"
diff --git a/backend/src/services/smtp/emails/IntegrationSyncFailedTemplate.tsx b/backend/src/services/smtp/emails/IntegrationSyncFailedTemplate.tsx index c2fb78ae0..3263d4928 100644 --- a/backend/src/services/smtp/emails/IntegrationSyncFailedTemplate.tsx +++ b/backend/src/services/smtp/emails/IntegrationSyncFailedTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface IntegrationSyncFailedTemplateProps extends Omit { @@ -30,7 +31,7 @@ export const IntegrationSyncFailedTemplate = ({ {count} integration(s) failed to sync -
+
Project {projectName} Environment @@ -38,15 +39,10 @@ export const IntegrationSyncFailedTemplate = ({ Secret Path {secretPath} Failure Reason: - "{syncMessage}" + "{syncMessage}"
-
- +
+ View Integrations
); diff --git a/backend/src/services/smtp/emails/NewDeviceLoginTemplate.tsx b/backend/src/services/smtp/emails/NewDeviceLoginTemplate.tsx index 9692bc77d..da1049096 100644 --- a/backend/src/services/smtp/emails/NewDeviceLoginTemplate.tsx +++ b/backend/src/services/smtp/emails/NewDeviceLoginTemplate.tsx @@ -1,7 +1,8 @@ -import { Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface NewDeviceLoginTemplateProps extends Omit { email: string; @@ -42,9 +43,7 @@ export const NewDeviceLoginTemplate = ({ If you believe that this login is suspicious, please contact{" "} {isCloud ? ( - - support@infisical.com - + support@infisical.com ) : ( "your administrator" )}{" "} diff --git a/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx b/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx index 1d2ecc1a3..ee09574b6 100644 --- a/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx +++ b/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx @@ -1,7 +1,8 @@ -import { Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface OrgAdminBreakglassAccessTemplateProps extends Omit { email: string; @@ -35,10 +36,7 @@ export const OrgAdminBreakglassAccessTemplate = ({ {userAgent} If you'd like to disable Admin SSO Bypass, please visit{" "} - - Organization Security Settings - - . + Organization Security Settings.
diff --git a/backend/src/services/smtp/emails/OrgAdminProjectGrantAccessTemplate.tsx b/backend/src/services/smtp/emails/OrgAdminProjectGrantAccessTemplate.tsx index bec22575c..1cf875d1c 100644 --- a/backend/src/services/smtp/emails/OrgAdminProjectGrantAccessTemplate.tsx +++ b/backend/src/services/smtp/emails/OrgAdminProjectGrantAccessTemplate.tsx @@ -2,6 +2,7 @@ import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface OrgAdminProjectGrantAccessTemplateProps extends Omit { email: string; @@ -24,8 +25,8 @@ export const OrgAdminProjectGrantAccessTemplate = ({
- The organization admin {email} has self-issued direct access to the project{" "} - {projectName}. + The organization admin {email} has self-issued direct access to + the project {projectName}.
diff --git a/backend/src/services/smtp/emails/OrganizationAssignmentTemplate.tsx b/backend/src/services/smtp/emails/OrganizationAssignmentTemplate.tsx new file mode 100644 index 000000000..14fb30d80 --- /dev/null +++ b/backend/src/services/smtp/emails/OrganizationAssignmentTemplate.tsx @@ -0,0 +1,69 @@ +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 OrganizationAssignmentTemplateProps extends Omit { + inviterFirstName?: string; + inviterUsername?: string; + organizationName: string; + callback_url: string; +} + +export const OrganizationAssignmentTemplate = ({ + organizationName, + inviterFirstName, + inviterUsername, + callback_url, + siteUrl +}: OrganizationAssignmentTemplateProps) => { + return ( + + + You've been added to the organization +
+ {organizationName} on Infisical +
+
+ + {inviterFirstName && inviterUsername ? ( + <> + {inviterFirstName} ( + {inviterUsername}) has added you as an + organization admin to {organizationName}. + + ) : ( + <> + An instance admin has added you as an organization admin to {organizationName}. + + )} + +
+
+ View Dashboard +
+
+ + About Infisical: Infisical is an all-in-one platform to securely manage application secrets, + certificates, SSH keys, and configurations across your team and infrastructure. + +
+
+ ); +}; + +export default OrganizationAssignmentTemplate; + +OrganizationAssignmentTemplate.PreviewProps = { + organizationName: "Example Organization", + inviterFirstName: "Jane", + inviterUsername: "jane@infisical.com", + siteUrl: "https://infisical.com", + callback_url: "https://app.infisical.com" +} as OrganizationAssignmentTemplateProps; diff --git a/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx b/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx index 27092a843..9c57548ce 100644 --- a/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx +++ b/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx @@ -1,7 +1,9 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +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 OrganizationInvitationTemplateProps extends Omit { metadata?: string; @@ -36,15 +38,13 @@ export const OrganizationInvitationTemplate = ({
{organizationName} on Infisical -
+
{inviterFirstName && inviterUsername ? ( <> {inviterFirstName} ( - - {inviterUsername} - - ) has invited you to collaborate on {organizationName}. + {inviterUsername}) has invited you to collaborate + on {organizationName}. ) : ( <> @@ -53,13 +53,12 @@ export const OrganizationInvitationTemplate = ({ )}
-
- +
diff --git a/backend/src/services/smtp/emails/PasswordResetTemplate.tsx b/backend/src/services/smtp/emails/PasswordResetTemplate.tsx index 7486b29d9..2cfa00508 100644 --- a/backend/src/services/smtp/emails/PasswordResetTemplate.tsx +++ b/backend/src/services/smtp/emails/PasswordResetTemplate.tsx @@ -1,7 +1,9 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +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 PasswordResetTemplateProps extends Omit { email: string; @@ -20,16 +22,13 @@ export const PasswordResetTemplate = ({ email, isCloud, siteUrl, callback_url, t Account Recovery -
+
A password reset was requested for your Infisical account. If you did not initiate this request, please contact{" "} {isCloud ? ( <> - us immediately at{" "} - - support@infisical.com - + us immediately at support@infisical.com ) : ( "your administrator immediately" @@ -37,13 +36,8 @@ export const PasswordResetTemplate = ({ email, isCloud, siteUrl, callback_url, t .
-
- +
+ Reset Password
); diff --git a/backend/src/services/smtp/emails/PasswordSetupTemplate.tsx b/backend/src/services/smtp/emails/PasswordSetupTemplate.tsx index c8a986c8c..78164bed2 100644 --- a/backend/src/services/smtp/emails/PasswordSetupTemplate.tsx +++ b/backend/src/services/smtp/emails/PasswordSetupTemplate.tsx @@ -1,6 +1,8 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import { Button, Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseLink } from "@app/services/smtp/emails/BaseLink"; + import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface PasswordSetupTemplateProps extends Omit { @@ -16,19 +18,16 @@ export const PasswordSetupTemplate = ({ email, isCloud, siteUrl, callback_url, t Password Setup -
+
Someone requested to set up a password for your Infisical account. - + Make sure you are already logged in to Infisical in the current browser before clicking the link below. If you did not initiate this request, please contact{" "} {isCloud ? ( <> - us immediately at{" "} - - support@infisical.com - + us immediately at support@infisical.com ) : ( "your administrator immediately" @@ -36,7 +35,7 @@ export const PasswordSetupTemplate = ({ email, isCloud, siteUrl, callback_url, t .
-
+
+
+ Grant Access
); diff --git a/backend/src/services/smtp/emails/ProjectInvitationTemplate.tsx b/backend/src/services/smtp/emails/ProjectInvitationTemplate.tsx index 1745dc8e5..2105e967a 100644 --- a/backend/src/services/smtp/emails/ProjectInvitationTemplate.tsx +++ b/backend/src/services/smtp/emails/ProjectInvitationTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface ProjectInvitationTemplateProps extends Omit { @@ -18,18 +19,13 @@ export const ProjectInvitationTemplate = ({ callback_url, workspaceName, siteUrl You've been invited to join a project on Infisical -
+
You've been invited to join the project {workspaceName}.
-
- +
+ Join Project
diff --git a/backend/src/services/smtp/emails/ScimUserProvisionedTemplate.tsx b/backend/src/services/smtp/emails/ScimUserProvisionedTemplate.tsx index bbd41818e..df60afba3 100644 --- a/backend/src/services/smtp/emails/ScimUserProvisionedTemplate.tsx +++ b/backend/src/services/smtp/emails/ScimUserProvisionedTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface ScimUserProvisionedTemplateProps extends Omit { @@ -24,18 +25,13 @@ export const ScimUserProvisionedTemplate = ({
{organizationName} on Infisical -
+
You've been invited to collaborate on {organizationName}.
-
- +
+ Accept Invite
diff --git a/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx b/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx index a07110aa3..ebbbeda15 100644 --- a/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx @@ -1,7 +1,9 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +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 SecretApprovalRequestBypassedTemplateProps extends Omit { @@ -35,13 +37,10 @@ export const SecretApprovalRequestBypassedTemplate = ({ A secret approval request has been bypassed in the project {projectName} -
+
- {requesterFullName} ( - - {requesterEmail} - - ) has {requestType === "change" ? "merged" : "accessed"} a secret {requestType === "change" ? "to" : "in"}{" "} + {requesterFullName} ({requesterEmail}) + has {requestType === "change" ? "merged" : "accessed"} a secret {requestType === "change" ? "to" : "in"}{" "} {secretPath} in the {environment} environment without obtaining the required approval. @@ -50,13 +49,8 @@ export const SecretApprovalRequestBypassedTemplate = ({ {bypassReason}"
-
- +
+ Review Bypass
); diff --git a/backend/src/services/smtp/emails/SecretApprovalRequestNeedsReviewTemplate.tsx b/backend/src/services/smtp/emails/SecretApprovalRequestNeedsReviewTemplate.tsx index b4a7c306a..000614e00 100644 --- a/backend/src/services/smtp/emails/SecretApprovalRequestNeedsReviewTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretApprovalRequestNeedsReviewTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface SecretApprovalRequestNeedsReviewTemplateProps @@ -27,20 +28,15 @@ export const SecretApprovalRequestNeedsReviewTemplate = ({ A secret approval request for the project {projectName} requires review -
+
Hello {firstName}, You have a new secret change request pending your review for the project {projectName} in the organization {organizationName}.
-
- +
+ Review Changes
); diff --git a/backend/src/services/smtp/emails/SecretLeakIncidentTemplate.tsx b/backend/src/services/smtp/emails/SecretLeakIncidentTemplate.tsx index 631013756..fa60deb11 100644 --- a/backend/src/services/smtp/emails/SecretLeakIncidentTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretLeakIncidentTemplate.tsx @@ -1,7 +1,9 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +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 SecretLeakIncidentTemplateProps extends Omit { numberOfSecrets: number; @@ -24,7 +26,7 @@ export const SecretLeakIncidentTemplate = ({ Infisical has uncovered {numberOfSecrets} secret(s) from a recent commit -
+
You are receiving this notification because one or more leaked secrets have been detected in a recent commit {(pusher_email || pusher_name) && ( @@ -33,11 +35,7 @@ export const SecretLeakIncidentTemplate = ({ pushed by {pusher_name ?? "Unknown Pusher"}{" "} {pusher_email && ( <> - ( - - {pusher_email} - - ) + ({pusher_email}) )} @@ -49,24 +47,16 @@ export const SecretLeakIncidentTemplate = ({ a comment in the given programming language. This will prevent future notifications from being sent out for these secrets. - + If these are production secrets, please rotate them immediately. Once you have taken action, be sure to update the status of the risk in the{" "} - - Infisical Dashboard - - . + Infisical Dashboard.
-
- +
+ View Leaked Secrets
); diff --git a/backend/src/services/smtp/emails/SecretRequestCompletedTemplate.tsx b/backend/src/services/smtp/emails/SecretRequestCompletedTemplate.tsx index 4adeec636..37e758661 100644 --- a/backend/src/services/smtp/emails/SecretRequestCompletedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretRequestCompletedTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface SecretRequestCompletedTemplateProps extends Omit { @@ -20,7 +21,7 @@ export const SecretRequestCompletedTemplate = ({ A secret has been shared with you -
+
{respondentUsername ? {respondentUsername} : "Someone"} shared a secret{" "} {name && ( @@ -31,13 +32,8 @@ export const SecretRequestCompletedTemplate = ({ with you.
-
- +
+ View Secret
); diff --git a/backend/src/services/smtp/emails/SecretRotationFailedTemplate.tsx b/backend/src/services/smtp/emails/SecretRotationFailedTemplate.tsx index 52e58986d..04cc95c56 100644 --- a/backend/src/services/smtp/emails/SecretRotationFailedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretRotationFailedTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface SecretRotationFailedTemplateProps extends Omit { @@ -28,7 +29,7 @@ export const SecretRotationFailedTemplate = ({ Your {rotationType} rotation {rotationName} failed to rotate -
+
Name {rotationName} Type @@ -40,15 +41,12 @@ export const SecretRotationFailedTemplate = ({ Secret Path {secretPath} Reason: - {content} + {content}
-
- +
); diff --git a/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx b/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx index 2e212cb82..5b56b574a 100644 --- a/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface SecretScanningScanFailedTemplateProps extends Omit { @@ -30,7 +31,7 @@ export const SecretScanningScanFailedTemplate = ({ Infisical encountered an error while attempting to scan the resource {resourceName} -
+
Resource {resourceName} Data Source @@ -40,15 +41,10 @@ export const SecretScanningScanFailedTemplate = ({ Timestamp {timestamp} Error - {errorMessage} + {errorMessage}
-
- +
+ View in Infisical
); diff --git a/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx b/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx index b7c0d8a14..6bd4e8aa7 100644 --- a/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx @@ -1,7 +1,9 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +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 SecretScanningSecretsDetectedTemplateProps extends Omit { @@ -32,7 +34,7 @@ export const SecretScanningSecretsDetectedTemplate = ({ Infisical has uncovered {numberOfSecrets} secret(s) {isDiffScan ? " from a recent commit to" : " in"} {resourceName} -
+
You are receiving this notification because one or more leaked secrets have been detected {isDiffScan && " in a recent commit"} @@ -43,11 +45,7 @@ export const SecretScanningSecretsDetectedTemplate = ({ pushed by {authorName ?? "Unknown Pusher"}{" "} {authorEmail && ( <> - ( - - {authorEmail} - - ) + ({authorEmail}) )} @@ -65,24 +63,16 @@ export const SecretScanningSecretsDetectedTemplate = ({ a comment in the given programming language. This will prevent future notifications from being sent out for these secrets. - + If these are production secrets, please rotate them immediately. Once you have taken action, be sure to update the finding status in the{" "} - - Infisical Dashboard - - . + Infisical Dashboard.
-
- +
+ View Leaked Secrets
); diff --git a/backend/src/services/smtp/emails/SecretSyncFailedTemplate.tsx b/backend/src/services/smtp/emails/SecretSyncFailedTemplate.tsx index 01d909219..4abbf8f99 100644 --- a/backend/src/services/smtp/emails/SecretSyncFailedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretSyncFailedTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface SecretSyncFailedTemplateProps extends Omit { @@ -28,7 +29,7 @@ export const SecretSyncFailedTemplate = ({ Your {syncDestination} sync {syncName} failed to complete -
+
Name {syncName} Destination @@ -50,17 +51,12 @@ export const SecretSyncFailedTemplate = ({ {failureMessage && ( <> Reason: - {failureMessage} + {failureMessage} )}
-
- +
+ View in Infisical
); diff --git a/backend/src/services/smtp/emails/ServiceTokenExpiryNoticeTemplate.tsx b/backend/src/services/smtp/emails/ServiceTokenExpiryNoticeTemplate.tsx index 8f8deb63a..a4c171f86 100644 --- a/backend/src/services/smtp/emails/ServiceTokenExpiryNoticeTemplate.tsx +++ b/backend/src/services/smtp/emails/ServiceTokenExpiryNoticeTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface ServiceTokenExpiryNoticeTemplateProps extends Omit { @@ -24,20 +25,15 @@ export const ServiceTokenExpiryNoticeTemplate = ({ Service token expiry notice -
+
Your service token {tokenName} for the project {projectName} will expire within 24 hours. If this token is still needed for your workflow, please create a new one before it expires.
-
- +
+ Create New Token
); diff --git a/backend/src/services/smtp/emails/SignupEmailVerificationTemplate.tsx b/backend/src/services/smtp/emails/SignupEmailVerificationTemplate.tsx index 0a3da75f9..4c0ab133f 100644 --- a/backend/src/services/smtp/emails/SignupEmailVerificationTemplate.tsx +++ b/backend/src/services/smtp/emails/SignupEmailVerificationTemplate.tsx @@ -1,7 +1,8 @@ -import { Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface SignupEmailVerificationTemplateProps extends Omit { code: string; @@ -29,10 +30,7 @@ export const SignupEmailVerificationTemplate = ({ code, siteUrl, isCloud }: Sign Questions about setting up Infisical?{" "} {isCloud ? ( <> - Email us at{" "} - - support@infisical.com - + Email us at support@infisical.com ) : ( "Contact your administrator" diff --git a/backend/src/services/smtp/emails/UnlockAccountTemplate.tsx b/backend/src/services/smtp/emails/UnlockAccountTemplate.tsx index b0b4f2086..7ec1be3f4 100644 --- a/backend/src/services/smtp/emails/UnlockAccountTemplate.tsx +++ b/backend/src/services/smtp/emails/UnlockAccountTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface UnlockAccountTemplateProps extends Omit { @@ -18,19 +19,14 @@ export const UnlockAccountTemplate = ({ token, siteUrl, callback_url }: UnlockAc Unlock your Infisical account -
+
Your account has been temporarily locked due to multiple failed login attempts. If these attempts were not made by you, reset your password immediately.
-
- +
+ Unlock Account
); diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 840a98cad..71c338def 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"; @@ -8,6 +9,7 @@ export * from "./IntegrationSyncFailedTemplate"; export * from "./NewDeviceLoginTemplate"; export * from "./OrgAdminBreakglassAccessTemplate"; export * from "./OrgAdminProjectGrantAccessTemplate"; +export * from "./OrganizationAssignmentTemplate"; export * from "./OrganizationInvitationTemplate"; export * from "./PasswordResetTemplate"; export * from "./PasswordSetupTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index ac56f0ee4..224f78265 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, @@ -17,6 +18,7 @@ import { NewDeviceLoginTemplate, OrgAdminBreakglassAccessTemplate, OrgAdminProjectGrantAccessTemplate, + OrganizationAssignmentTemplate, OrganizationInvitationTemplate, PasswordResetTemplate, PasswordSetupTemplate, @@ -54,11 +56,13 @@ export enum SmtpTemplates { EmailMfa = "emailMfa", UnlockAccount = "unlockAccount", AccessApprovalRequest = "accessApprovalRequest", + AccessApprovalRequestUpdated = "accessApprovalRequestUpdated", AccessSecretRequestBypassed = "accessSecretRequestBypassed", SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview", // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? NewDeviceJoin = "newDevice", OrgInvite = "organizationInvitation", + OrgAssignment = "organizationAssignment", ResetPassword = "passwordReset", SetupPassword = "passwordSetup", SecretLeakIncident = "secretLeakIncident", @@ -92,10 +96,12 @@ export enum SmtpHost { // eslint-disable-next-line @typescript-eslint/no-explicit-any const EmailTemplateMap: Record> = { [SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate, + [SmtpTemplates.OrgAssignment]: OrganizationAssignmentTemplate, [SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate, [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 ff47718f1..b43e4e0a2 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -1,6 +1,13 @@ import { CronJob } from "cron"; -import { IdentityAuthMethod, OrgMembershipRole, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; +import { + IdentityAuthMethod, + OrgMembershipRole, + OrgMembershipStatus, + TSuperAdmin, + TSuperAdminUpdate, + TUsers +} from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { @@ -11,10 +18,14 @@ 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 { OrgServiceActor } from "@app/lib/types"; +import { isDisposableEmail } from "@app/lib/validator"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TAuthLoginFactory } from "../auth/auth-login-service"; import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; @@ -44,7 +55,9 @@ import { TAdminGetUsersDTO, TAdminIntegrationConfig, TAdminSignUpDTO, - TGetOrganizationsDTO + TCreateOrganizationDTO, + TGetOrganizationsDTO, + TResendOrgInviteDTO } from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { @@ -60,11 +73,13 @@ type TSuperAdminServiceFactoryDep = { authService: Pick; kmsService: Pick; kmsRootConfigDAL: TKmsRootConfigDALFactory; - orgService: Pick; + orgService: Pick; keyStore: Pick; - licenseService: Pick; + licenseService: Pick; microsoftTeamsService: Pick; invalidateCacheQueue: TInvalidateCacheQueueFactory; + smtpService: Pick; + tokenService: TAuthTokenServiceFactory; }; export type TSuperAdminServiceFactory = ReturnType; @@ -124,7 +139,9 @@ export const superAdminServiceFactory = ({ identityTokenAuthDAL, identityOrgMembershipDAL, microsoftTeamsService, - invalidateCacheQueue + invalidateCacheQueue, + smtpService, + tokenService }: TSuperAdminServiceFactoryDep) => { const initServerCfg = async () => { // TODO(akhilmhdh): bad pattern time less change this later to me itself @@ -465,43 +482,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 +508,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 +564,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 ); @@ -704,10 +669,39 @@ export const superAdminServiceFactory = ({ }; const deleteUser = async (userId: string) => { + const superAdmins = await userDAL.find({ + superAdmin: true + }); + + if (superAdmins.length === 1 && superAdmins[0].id === userId) { + throw new BadRequestError({ + message: "Cannot delete the only server admin on this instance. Add another server admin to delete this user." + }); + } + const user = await userDAL.deleteById(userId); return user; }; + const deleteUsers = async (userIds: string[]) => { + const superAdmins = await userDAL.find({ + superAdmin: true + }); + + if (superAdmins.every((superAdmin) => userIds.includes(superAdmin.id))) { + throw new BadRequestError({ + message: "Instance must have at least one server admin. Add another server admin to delete these users." + }); + } + + const users = await userDAL.delete({ + $in: { + id: userIds + } + }); + return users; + }; + const deleteIdentitySuperAdminAccess = async (identityId: string, actorId: string) => { const identity = await identityDAL.findById(identityId); if (!identity) { @@ -730,6 +724,17 @@ export const superAdminServiceFactory = ({ throw new NotFoundError({ name: "User", message: "User not found" }); } + const superAdmins = await userDAL.find({ + superAdmin: true + }); + + if (superAdmins.length === 1 && superAdmins[0].id === userId) { + throw new BadRequestError({ + message: + "Cannot remove the only server admin on this instance. Add another server admin to remove status for this user." + }); + } + const updatedUser = userDAL.updateById(userId, { superAdmin: false }); return updatedUser; @@ -745,6 +750,159 @@ export const superAdminServiceFactory = ({ return organizations; }; + const createOrganization = async ( + { name, inviteAdminEmails: emails }: TCreateOrganizationDTO, + actor: OrgServiceActor + ) => { + const appCfg = getConfig(); + + const inviteAdminEmails = [...new Set(emails)]; + + if (!appCfg.isDevelopmentMode && appCfg.isCloud) + throw new BadRequestError({ message: "This endpoint is not supported for cloud instances" }); + + const serverAdmin = await userDAL.findById(actor.id); + const plan = licenseService.onPremFeatures; + + const isEmailInvalid = await isDisposableEmail(inviteAdminEmails); + if (isEmailInvalid) { + throw new BadRequestError({ + message: "Disposable emails are not allowed", + name: "InviteUser" + }); + } + + const { organization, users: usersToEmail } = await orgDAL.transaction(async (tx) => { + const org = await orgService.createOrganization( + { + orgName: name, + userEmail: serverAdmin?.email ?? serverAdmin?.username // identities can be server admins so we can't require this + }, + tx + ); + + const users: Pick[] = []; + + for await (const inviteeEmail of inviteAdminEmails) { + const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx); + let inviteeUser = + usersByUsername?.length > 1 + ? usersByUsername.find((el) => el.username === inviteeEmail) + : usersByUsername?.[0]; + + // if the user doesn't exist we create the user with the email + if (!inviteeUser) { + // TODO(carlos): will be removed once the function receives usernames instead of emails + const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx); + if (usersByEmail?.length === 1) { + [inviteeUser] = usersByEmail; + } else { + inviteeUser = await userDAL.create( + { + isAccepted: false, + email: inviteeEmail, + username: inviteeEmail, + authMethods: [AuthMethod.EMAIL], + isGhost: false + }, + tx + ); + } + } + + const inviteeUserId = inviteeUser?.id; + const existingEncryptionKey = await userDAL.findUserEncKeyByUserId(inviteeUserId, tx); + + // when user is missing the encrytion keys + // this could happen either if user doesn't exist or user didn't find step 3 of generating the encryption keys of srp + // So what we do is we generate a random secure password and then encrypt it with a random pub-private key + // Then when user sign in (as login is not possible as isAccepted is false) we rencrypt the private key with the user password + if (!inviteeUser || (inviteeUser && !inviteeUser?.isAccepted && !existingEncryptionKey)) { + await userDAL.createUserEncryption( + { + userId: inviteeUserId, + encryptionVersion: 2 + }, + tx + ); + } + + if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { + // limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed + throw new BadRequestError({ + name: "InviteUser", + message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members." + }); + } + + await orgDAL.createMembership( + { + userId: inviteeUser.id, + inviteEmail: inviteeEmail, + orgId: org.id, + role: OrgMembershipRole.Admin, + status: inviteeUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, + isActive: true + }, + tx + ); + + users.push(inviteeUser); + } + + return { organization: org, users }; + }); + + await licenseService.updateSubscriptionOrgMemberCount(organization.id); + + await Promise.allSettled( + usersToEmail.map(async (user) => { + if (!user.email) return; + + if (user.isAccepted) { + return smtpService.sendMail({ + template: SmtpTemplates.OrgAssignment, + subjectLine: "You've been added to an Infisical organization", + recipients: [user.email], + substitutions: { + inviterFirstName: serverAdmin?.firstName, + inviterUsername: serverAdmin?.email, + organizationName: organization.name, + email: user.email, + organizationId: organization.id, + callback_url: `${appCfg.SITE_URL}/login?org_id=${organization.id}` + } + }); + } + + // new user, send regular invite + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: user.id, + orgId: organization.id + }); + + return smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: "Infisical organization invitation", + recipients: [user.email], + substitutions: { + inviterFirstName: serverAdmin?.firstName, + inviterUsername: serverAdmin?.email, + organizationName: organization.name, + email: user.email, + organizationId: organization.id, + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } + }); + }) + ); + + return organization; + }; + const deleteOrganization = async (organizationId: string) => { const organization = await orgDAL.deleteById(organizationId); return organization; @@ -776,6 +934,86 @@ export const superAdminServiceFactory = ({ return organizationMembership; }; + const joinOrganization = async (orgId: string, actor: OrgServiceActor) => { + const serverAdmin = await userDAL.findById(actor.id); + + if (!serverAdmin) { + throw new NotFoundError({ message: "Could not find server admin user" }); + } + + const org = await orgDAL.findById(orgId); + + if (!org) { + throw new NotFoundError({ message: `Could not organization with ID "${orgId}"` }); + } + + const existingOrgMembership = await orgMembershipDAL.findOne({ userId: serverAdmin.id, orgId }); + + if (existingOrgMembership) { + throw new BadRequestError({ message: `You are already a part of the organization with ID ${orgId}` }); + } + + const orgMembership = await orgDAL.createMembership({ + userId: serverAdmin.id, + orgId: org.id, + role: OrgMembershipRole.Admin, + status: OrgMembershipStatus.Accepted, + isActive: true + }); + + return orgMembership; + }; + + const resendOrgInvite = async ({ organizationId, membershipId }: TResendOrgInviteDTO, actor: OrgServiceActor) => { + const orgMembership = await orgMembershipDAL.findOne({ id: membershipId, orgId: organizationId }); + + if (!orgMembership) { + throw new NotFoundError({ name: "Organization Membership", message: "Organization membership not found" }); + } + + if (orgMembership.status === OrgMembershipStatus.Accepted) { + throw new BadRequestError({ + message: "This user has already accepted their invitation." + }); + } + + if (!orgMembership.userId) { + throw new NotFoundError({ message: "Cannot find user associated with Org Membership." }); + } + + if (!orgMembership.inviteEmail) { + throw new BadRequestError({ message: "No invite email associated with user." }); + } + + const org = await orgDAL.findOrgById(orgMembership.orgId); + + const appCfg = getConfig(); + const serverAdmin = await userDAL.findById(actor.id); + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: orgMembership.userId, + orgId: orgMembership.orgId + }); + + await smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: "Infisical organization invitation", + recipients: [orgMembership.inviteEmail], + substitutions: { + inviterFirstName: serverAdmin?.firstName, + inviterUsername: serverAdmin?.email, + organizationName: org?.name, + email: orgMembership.inviteEmail, + organizationId: orgMembership.orgId, + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } + }); + + return orgMembership; + }; + const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { const identities = await identityDAL.getIdentitiesByFilter({ limit, @@ -913,6 +1151,10 @@ export const superAdminServiceFactory = ({ initializeAdminIntegrationConfigSync, initializeEnvConfigSync, getEnvOverrides, - getEnvOverridesOrganized + getEnvOverridesOrganized, + deleteUsers, + createOrganization, + joinOrganization, + resendOrgInvite }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index b57a015a4..6cfdd384d 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; }; @@ -43,6 +34,16 @@ export type TGetOrganizationsDTO = { searchTerm: string; }; +export type TCreateOrganizationDTO = { + name: string; + inviteAdminEmails: string[]; +}; + +export type TResendOrgInviteDTO = { + organizationId: string; + membershipId: string; +}; + export enum LoginMethod { EMAIL = "email", GOOGLE = "google", diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index b2258447e..b0d7be0fe 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,9 +1,8 @@ import { ForbiddenError } from "@casl/ability"; -import { SecretKeyEncoding } from "@app/db/schemas"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { crypto } from "@app/lib/crypto/cryptography"; +import { crypto } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; @@ -11,9 +10,10 @@ import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; -import { AuthMethod } from "../auth/auth-type"; +import { AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { TUserDALFactory } from "./user-dal"; import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types"; @@ -39,6 +39,7 @@ type TUserServiceFactoryDep = { projectMembershipDAL: Pick; smtpService: Pick; permissionService: TPermissionServiceFactory; + userAliasDAL: Pick; }; export type TUserServiceFactory = ReturnType; @@ -50,22 +51,38 @@ export const userServiceFactory = ({ groupProjectDAL, tokenService, smtpService, - permissionService + permissionService, + userAliasDAL }: TUserServiceFactoryDep) => { - const sendEmailVerificationCode = async (username: string) => { + const sendEmailVerificationCode = async (token: string) => { + const { authType, aliasId, username, authTokenType } = crypto.jwt().decode(token) as { + authType: string; + aliasId?: string; + username: string; + authTokenType: AuthTokenType; + }; + if (authTokenType !== AuthTokenType.PROVIDER_TOKEN) throw new BadRequestError({ name: "Invalid auth token type" }); + // akhilmhdh: case sensitive email resolution const users = await userDAL.findUserByUsername(username); const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); + let { isEmailVerified } = user; + if (aliasId) { + const userAlias = await userAliasDAL.findOne({ userId: user.id, aliasType: authType, id: aliasId }); + if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${aliasId}' not found` }); + isEmailVerified = userAlias.isEmailVerified; + } if (!user.email) throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); - if (user.isEmailVerified) + if (isEmailVerified) throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" }); - const token = await tokenService.createTokenForUser({ + const userToken = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId }); await smtpService.sendMail({ @@ -73,7 +90,7 @@ export const userServiceFactory = ({ subjectLine: "Infisical confirmation code", recipients: [user.email], substitutions: { - code: token + code: userToken } }); }; @@ -97,15 +114,21 @@ export const userServiceFactory = ({ if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); if (!user.email) throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); - if (user.isEmailVerified) - throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); - await tokenService.validateTokenForUser({ + const token = await tokenService.validateTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, userId: user.id, code }); + if (token?.aliasId) { + const userAlias = await userAliasDAL.findOne({ userId: user.id, id: token.aliasId }); + if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${token.aliasId}' not found` }); + if (userAlias?.isEmailVerified) + throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); + + await userAliasDAL.updateById(token.aliasId, { isEmailVerified: true }); + } const userEmails = user?.email ? await userDAL.find({ email: user.email }) : []; await userDAL.updateById(user.id, { @@ -175,7 +198,11 @@ export const userServiceFactory = ({ const getMe = async (userId: string) => { const user = await userDAL.findUserEncKeyByUserId(userId); if (!user) throw new NotFoundError({ message: `User with ID '${userId}' not found`, name: "GetMe" }); - return user; + + return { + ...user, + encryptionVersion: user.encryptionVersion! + }; }; const deleteUser = async (userId: string) => { @@ -212,25 +239,6 @@ export const userServiceFactory = ({ ); }; - const getUserPrivateKey = async (userId: string) => { - const user = await userDAL.findUserEncKeyByUserId(userId); - if (!user?.serverEncryptedPrivateKey || !user.serverEncryptedPrivateKeyIV || !user.serverEncryptedPrivateKeyTag) { - throw new NotFoundError({ message: `Private key for user with ID '${userId}' not found` }); - } - - const privateKey = crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - ciphertext: user.serverEncryptedPrivateKey, - tag: user.serverEncryptedPrivateKeyTag, - iv: user.serverEncryptedPrivateKeyIV, - keyEncoding: user.serverEncryptedPrivateKeyEncoding as SecretKeyEncoding - }); - - return privateKey; - }; - const getUserProjectFavorites = async (userId: string, orgId: string) => { const orgMembership = await orgMembershipDAL.findOne({ userId, @@ -311,7 +319,6 @@ export const userServiceFactory = ({ listUserGroups, getUserAction, unlockUser, - getUserPrivateKey, getAllMyAccounts, getUserProjectFavorites, removeMyDuplicateAccounts, 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/company/favicon.ico b/company/favicon.ico new file mode 100644 index 000000000..a76dd0339 Binary files /dev/null and b/company/favicon.ico differ diff --git a/company/favicon.png b/company/favicon.png deleted file mode 100644 index 45c9b868e..000000000 Binary files a/company/favicon.png and /dev/null differ diff --git a/company/mint.json b/company/mint.json index 247fb5a2b..8d2b9e346 100644 --- a/company/mint.json +++ b/company/mint.json @@ -5,7 +5,7 @@ "light": "/logo/light.svg", "href": "https://infisical.com" }, - "favicon": "/favicon.png", + "favicon": "/favicon.ico", "colors": { "primary": "#26272b", "light": "#97b31d", diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 00dc19a46..2de271180 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -197,4 +197,4 @@ volumes: driver: local ldap_data: ldap_config: - grafana_storage: + grafana_storage: \ No newline at end of file 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/.eslintrc.js b/docs/.eslintrc.js new file mode 100644 index 000000000..e44343a1c --- /dev/null +++ b/docs/.eslintrc.js @@ -0,0 +1,40 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + ], + parser: '@babel/eslint-parser', + parserOptions: { + ecmaVersion: 2021, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + requireConfigFile: false, + babelOptions: { + presets: ['@babel/preset-react'], + }, + }, + plugins: ['react'], + rules: { + 'react/jsx-uses-react': 'error', + 'react/jsx-uses-vars': 'error', + }, + settings: { + react: { + version: 'detect', + }, + }, + ignorePatterns: [ + 'node_modules/', + 'dist/', + 'build/', + '*.config.js', + ], +}; \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/available.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/available.mdx new file mode 100644 index 000000000..6b23a07e9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/azure-adcs/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/create.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/create.mdx new file mode 100644 index 000000000..132ee409c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/azure-adcs" +--- + + + Azure ADCS Connections must be created through the Infisical UI. + Check out the configuration docs for [Azure ADCS Connections](/integrations/app-connections/azure-adcs) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/delete.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/delete.mdx new file mode 100644 index 000000000..4d7037959 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/azure-adcs/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-id.mdx new file mode 100644 index 000000000..46a57d890 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/azure-adcs/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-name.mdx new file mode 100644 index 000000000..179ef9076 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/azure-adcs/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/list.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/list.mdx new file mode 100644 index 000000000..c0f679ee1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/azure-adcs" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/update.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/update.mdx new file mode 100644 index 000000000..4c4dfc91d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/azure-adcs/{connectionId}" +--- + + + Azure ADCS Connections must be updated through the Infisical UI. + Check out the configuration docs for [Azure ADCS Connections](/integrations/app-connections/azure-adcs) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/app-connections/netlify/available.mdx b/docs/api-reference/endpoints/app-connections/netlify/available.mdx new file mode 100644 index 000000000..19ca363cc --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/netlify/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/create.mdx b/docs/api-reference/endpoints/app-connections/netlify/create.mdx new file mode 100644 index 000000000..8c99c14e7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/netlify" +--- + + + Check out the configuration docs for [Netlify Connections](/integrations/app-connections/netlify) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/netlify/delete.mdx b/docs/api-reference/endpoints/app-connections/netlify/delete.mdx new file mode 100644 index 000000000..8ab1741a8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/netlify/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/netlify/get-by-id.mdx new file mode 100644 index 000000000..cfc49a128 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/netlify/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/netlify/get-by-name.mdx new file mode 100644 index 000000000..5869b5d23 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/netlify/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/list.mdx b/docs/api-reference/endpoints/app-connections/netlify/list.mdx new file mode 100644 index 000000000..50de77482 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/netlify" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/update.mdx b/docs/api-reference/endpoints/app-connections/netlify/update.mdx new file mode 100644 index 000000000..f3a981095 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/netlify/{connectionId}" +--- + + + Check out the configuration docs for [Netlify Connections](/integrations/app-connections/netlify) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/events/project-events.mdx b/docs/api-reference/endpoints/events/project-events.mdx new file mode 100644 index 000000000..9ceb0439e --- /dev/null +++ b/docs/api-reference/endpoints/events/project-events.mdx @@ -0,0 +1,4 @@ +--- +title: "Project Events" +openapi: "POST /api/v1/events/subscribe/project-events" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/organizations/bulk-delete-memberships.mdx b/docs/api-reference/endpoints/organizations/bulk-delete-memberships.mdx new file mode 100644 index 000000000..70793572c --- /dev/null +++ b/docs/api-reference/endpoints/organizations/bulk-delete-memberships.mdx @@ -0,0 +1,4 @@ +--- +title: "Bulk Delete User Memberships" +openapi: "DELETE /api/v2/organizations/{organizationId}/memberships" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/create.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/create.mdx new file mode 100644 index 000000000..1ed9f8cea --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-scanning/data-sources/gitlab" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/delete.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/delete.mdx new file mode 100644 index 000000000..5c26f478d --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id.mdx new file mode 100644 index 000000000..48e9b9fb4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name.mdx new file mode 100644 index 000000000..42bca6419 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab/data-source-name/{dataSourceName}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources.mdx new file mode 100644 index 000000000..299e2f0a6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources.mdx @@ -0,0 +1,4 @@ +--- +title: "List Resources" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}/resources" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans.mdx new file mode 100644 index 000000000..952fa1e89 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans.mdx @@ -0,0 +1,4 @@ +--- +title: "List Scans" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}/scans" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list.mdx new file mode 100644 index 000000000..2d6ffbed3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource.mdx new file mode 100644 index 000000000..da4623bae --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan Resource" +openapi: "POST /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}/resources/{resourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan.mdx new file mode 100644 index 000000000..5ccad9c16 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan" +openapi: "POST /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/update.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/update.mdx new file mode 100644 index 000000000..e48ee40d1 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/create.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/create.mdx new file mode 100644 index 000000000..6fd4a4391 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/netlify" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/delete.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/delete.mdx new file mode 100644 index 000000000..051d6825c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/netlify/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/get-by-id.mdx new file mode 100644 index 000000000..d207e29a6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/netlify/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/get-by-name.mdx new file mode 100644 index 000000000..caee424b2 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/netlify/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/import-secrets.mdx new file mode 100644 index 000000000..41fdadf4f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/netlify/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/list.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/list.mdx new file mode 100644 index 000000000..e366d82e9 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/netlify" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/remove-secrets.mdx new file mode 100644 index 000000000..5d402e061 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/netlify/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/sync-secrets.mdx new file mode 100644 index 000000000..437bc8524 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/netlify/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/update.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/update.mdx new file mode 100644 index 000000000..e51ba3270 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/netlify/{syncId}" +--- 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/changelog/overview.mdx b/docs/changelog/overview.mdx index 11a058019..7d8282727 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -26,7 +26,7 @@ The changelog below reflects new product developments and updates on a monthly b - Revamped UI for Access Controls, Access Tree, Policies, and Approval Workflows. - Released [TLS Certificate Authentication method](https://infisical.com/docs/documentation/platform/identities/tls-cert-auth). - Added ability to copy session tokens in the Infisical Dashboard. -- Expanded resource support for [Infisical Terraform Provider](https://infisical.com/docs/integrations/frameworks/terraform). +- Expanded resource support for [Infisical Terraform Provider](https://registry.terraform.io/providers/Infisical/infisical/latest/docs). ## May 2025 @@ -62,7 +62,7 @@ The changelog below reflects new product developments and updates on a monthly b ## March 2025 - Released [Infisical Gateway](https://infisical.com/docs/documentation/platform/gateways/overview) for secure access to private resources without needing direct inbound connections to private networks. -- Enhanced [Terraform](https://infisical.com/docs/integrations/frameworks/terraform#terraform) capabilities with token authentication, ability to import existing Infisical secrets as resources, and support for project templates. +- Enhanced [Terraform](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) capabilities with token authentication, ability to import existing Infisical secrets as resources, and support for project templates. - Self-hosted improvements: Usage and billing visibility for enabled features, ability to delete users, and support for multiple super admins. - UI and UX updates: Improved secret import interface on the overview page, password reset without backup PDF. - CLI enhancements: Various improvements including multiline secret support and ability to pass headers. @@ -93,7 +93,7 @@ The changelog below reflects new product developments and updates on a monthly b - Added support for OIDC group mapping in [Keycloak](https://infisical.com/docs/documentation/platform/sso/keycloak-oidc/overview), enabling automatic mapping of Keycloak groups to Infisical for role-based access control. - Enhanced [Kubernetes operator](https://infisical.com/docs/integrations/platforms/kubernetes/overview#kubernetes-operator) with namespaced group support, bi-directional secret sync (push to Infisical), [dynamic secrets](https://infisical.com/docs/documentation/platform/dynamic-secrets/overview#dynamic-secrets) capabilities, and support for multiple operator instances. - Restructured navigation with dedicated sections for Secrets Management, [Certificate Management (PKI)](https://infisical.com/docs/documentation/platform/pki/overview), [Key Management (KMS)](https://infisical.com/docs/documentation/platform/kms/overview#key-management-service-kms), and [SSH Key Management](https://infisical.com/docs/documentation/platform/ssh). -- Added [ephemeral Terraform resource](https://infisical.com/docs/integrations/frameworks/terraform#terraform-provider) support and improved secret sync architecture. +- Added [ephemeral Terraform resource](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) support and improved secret sync architecture. - Released [.NET provider](https://github.com/Infisical/infisical-dotnet-configuration) with first-party Azure authentication support and Azure CLI integration. - Implemented secret Access Visibility allowing users to view all entities with access to specific secrets in the secret side panel. - Added secret filtering by metadata and SSH assigned certificates (Version 1). @@ -212,7 +212,7 @@ The changelog below reflects new product developments and updates on a monthly b - Completed Postgres migration initiative with restructed Fastify-based backend. - Reduced size of Infisical Node.js SDK by โ‰ˆ90%. - Added secret fallback support to all SDK's. -- Added Machine Identity support to [Terraform Provider](https://github.com/Infisical/terraform-provider-infisical). +- Added Machine Identity support to [Terraform Provider](https://registry.terraform.io/providers/Infisical/infisical/latest/docs). - Released [.NET SDK](https://infisical.com/docs/sdks/languages/csharp). - Added symmetric encryption support to all SDK's. - Fixed secret reminders bug, where reminders were not being updated correctly. @@ -276,7 +276,7 @@ The changelog below reflects new product developments and updates on a monthly b ## June 2023 -- Released the [Terraform Provider](https://infisical.com/docs/integrations/frameworks/terraform#5-run-terraform). +- Released the [Terraform Provider](https://registry.terraform.io/providers/Infisical/infisical/latest/docs). - Updated the usage and billing page. Added the free trial for the professional tier. - Added native integrations with [Checkly](https://infisical.com/docs/integrations/cloud/checkly), [Hashicorp Vault](https://infisical.com/docs/integrations/cloud/hashicorp-vault), and [Cloudflare Pages](https://infisical.com/docs/integrations/cloud/cloudflare-pages). - Completed a penetration test with a `very good` result. diff --git a/docs/contributing/getting-started/overview.mdx b/docs/contributing/getting-started/overview.mdx index 79bd97c95..35912fc8c 100644 --- a/docs/contributing/getting-started/overview.mdx +++ b/docs/contributing/getting-started/overview.mdx @@ -10,7 +10,7 @@ should approach the development and contribution process. Infisical has two major code-bases. One for the platform code, and one for SDKs. The contribution process has some key differences between the two, so we've split the documentation into two sections: - The [Infisical Platform](https://github.com/Infisical/infisical), the Infisical platform itself. -- The [Infisical SDK](https://github.com/Infisical/sdk), the official Infisical client SDKs. +- The [Infisical SDK](https://infisical.com/docs/sdks/overview), the official Infisical client SDKs. diff --git a/docs/contributing/sdk/developing.mdx b/docs/contributing/sdk/developing.mdx deleted file mode 100644 index 82fd28be8..000000000 --- a/docs/contributing/sdk/developing.mdx +++ /dev/null @@ -1,408 +0,0 @@ ---- -title: "Local development" -description: "This guide will help you contribute to the Infisical SDK." ---- - -## Fork and clone the repo - -[Fork](https://docs.github.com/en/get-started/quickstart/fork-a-repo) the [repository](https://github.com/Infisical/sdk) to your own GitHub account and then [clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) it to your local device. - -Once, you've done that, create a new branch: - -```console -git checkout -b MY_BRANCH_NAME -``` - -## Set up environment variables - -Start by creating a .env file at the root of the Infisical directory then copy the contents of the file below into the .env file. - - - ```env - # This is required for running tests locally. - # Rename this file to ".env" and fill in the values below. - - # Please make sure that the machine identity has access to the project you are testing in. - # https://infisical.com/docs/documentation/platform/identities/universal-auth - INFISICAL_UNIVERSAL_CLIENT_ID=MACHINE_IDENTITY_CLIENT_ID - INFISICAL_UNIVERSAL_CLIENT_SECRET=MACHINE_IDENTITY_CLIENT_SECRET - - # The ID of the Infisical project where we will create the test secrets. - # NOTE: The project must have a dev environment. (This is created by default when you create a project.) - INFISICAL_PROJECT_ID=INFISICAL_TEST_PROJECT_ID - - # The Infisical site URL. If you are testing with a local Infisical instance, then this should be set to "http://localhost:8080". - INFISICAL_SITE_URL=https://app.infisical.com - -```` - - - - The above values are required for running tests locally. Before opening a pull request, make sure to run `cargo test` to ensure that all tests pass. - - - -## Guidelines - -### Predictable and consistent -When adding new functionality (such as new functions), it's very important that the functionality is added to _all_ the SDK's. This is to ensure that the SDK's are predictable and consistent across all languages. If you are adding new functionality, please make sure to add it to all the SDK's. - -### Handling errors -Error handling is very important when writing SDK's. We want to make sure that the SDK's are easy to use, and that the user gets a good understanding of what went wrong when something fails. When adding new functionality, please make sure to add proper error handling. [Read more about error handling here](#error-handling). - -### Tests -If you add new functionality or modify existing functionality, please write tests thats properly cover the new functionality. You can run tests locally by running `cargo test` from the root directory. You must always run tests before opening a pull request. - -### Code style -Please follow the default rust styling guide when writing code for the base SDK. [Read more about rust code style here](https://doc.rust-lang.org/nightly/style-guide/#the-default-rust-style). - - -## Prerequisites for contributing - -### Understanding the terms - -In the guide we use some terms that might be unfamiliar to you. Here's a quick explanation of the terms we use: -- **Base SDK**: The base SDK is the SDK that all other SDK's are built on top of. The base SDK is written in Rust, and is responsible for executing commands and parsing the input and output to and from JSON. -- **Commands**: Commands are what's being sent from the target language to the command handler. The command handler uses the command to execute the corresponding function in the base SDK. Commands are in reality just a JSON string that tells the command handler what function to execute, and what input to use. -- **Command handler**: The command handler is the part of the base SDK that takes care of executing commands. It also takes care of parsing the input and output to and from JSON. -- **Target language**: The target language refers to the actual SDK code. For example, the [Node.js SDK](https://www.npmjs.com/package/@infisical/sdk) is a "target language", and so is the [Python SDK](https://pypi.org/project/infisical-python/). - - -### Understanding the execution flow -After the target language SDK is initiated, it uses language-specific bindings to interact with the base SDK. -These bindings are instantiated, setting up the interface for command execution. A client within the command handler is created, which issues commands to the base SDK. -When a command is executed, it is first validated. If valid, the command handler locates the corresponding command to perform. If the command executes successfully, the command handler returns the output to the target language SDK, where it is parsed and returned to the user. -If the command handler fails to validate the input, an error will be returned to the target language SDK. - - - - - - - - -### Rust knowledge - -Contributing to the SDK requires intermediate to advanced knowledge of Rust concepts such as lifetimes, traits, generics, and async/await _(futures)_, and more. - -### Rust setup -The base SDK is written in rust. Therefore you must have rustc and cargo installed. You can install rustc and cargo by following the instructions [here](https://www.rust-lang.org/tools/install). - -You shouldn't have to use the rust cross compilation toolchain, as all compilation is done through a collection of Github Actions. However. If you need to test cross compilation, please do so with Github Actions. - -### Tests -If you add new functionality or modify existing functionality, please write tests thats properly cover the new functionality. You can run tests locally by running `cargo test` from the root directory. - -### Language-specific crates -The language-specific crates should ideally never have to be modified, as they are simply a wrapper for the `infisical-json` crate, which executes "commands" from the base SDK. If you need to create a new target-language specific crate, please try to create native bindings for the target language. Some languages don't have direct support for native bindings (Java as an example). In those cases we can use the C bindings (`crates/infisical-c`) in the target language. - - - - -## Generate types -Having almost seemless type safety from the base SDK to the target language is critical, as writing types for each language has a lot of drawbacks such as duplicated code, and lots of overhead trying to keep the types up-to-date and in sync across a large collection of languages. Therefore we decided to use [QuickType](https://quicktype.io/) and [Serde](https://serde.rs/) to help us generate types for each language. In our Rust base SDK (`crates/infisical`), we define all the inputs/outputs. - -If you are interested in reading about QuickType works under the hood, you can [read more here](http://blog.quicktype.io/under-the-hood/). - -This is an example of a type defined in Rust (both input and output). For this to become a generated type, you'll need to add it to our schema generator. More on that further down. -```rust -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Debug, JsonSchema)] -#[serde(rename_all = "camelCase")] -// Input: -pub struct CreateSecretOptions { - pub environment: String, // environment - pub secret_comment: Option, // secretComment - pub path: Option, // secretPath - pub secret_value: String, // secretValue - pub skip_multiline_encoding: Option, // skipMultilineEncoding - pub r#type: Option, // shared / personal - pub project_id: String, // workspaceId - pub secret_name: String, // secretName (PASSED AS PARAMETER IN REQUEST) -} - -// Output: -#[derive(Serialize, Deserialize, Debug, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateSecretResponse { - pub secret: Secret, // "Secret" is defined elsewhere. -} -```` - -### Adding input types to the schema generator - -You will _only_ have to define outputs in our schema generator, then QuickType will take care of the rest behind the scenes. You can find the Rust crate that takes care of type generation here: `crates/sdk-schemas/src/main.rs`. - -Simply add the output _(also called response)_, to the `write_schema_for_response!` macro. This will let QuickType know that it should generate types for the given structs. The main function will look something like this: - -```rust -fn main() -> Result<()> { - // Input types for new Client - write_schema_for!(infisical_json::client::ClientSettings); - // Input types for Client::run_command - write_schema_for!(infisical_json::command::Command); - - // Output types for Client::run_command - // Only add structs which are direct results of SDK commands. - write_schema_for_response! { - infisical::manager::secrets::GetSecretResponse, - infisical::manager::secrets::ListSecretsResponse, - infisical::manager::secrets::UpdateSecretResponse, - infisical::manager::secrets::DeleteSecretResponse, - infisical::manager::secrets::CreateSecretResponse, // <-- This is the output from the above example! - infisical::auth::AccessTokenSuccessResponse - }; - - Ok(()) -} -``` - -### Generating the types for the target language - -Once you've added the output to the schema generator, you can generate the types for the target language by running the following command from the root directory: - -```console -$ npm install -$ npm run schemas -``` - -If you change any of the structs defined in the base SDK, you will need to run this script to re-generate the types. - -This command will run the `schemas.ts` file found in the `support/scripts` folder. If you are adding a new language, it's important that you add the language to the code. - -This is an example of how how we generate types for Node.js: - -```ts -const ts = await quicktype({ - inputData, - lang: "typescript", - rendererOptions: {} -}); -await ensureDir("./languages/node/src/infisical_client"); -writeToFile("./languages/node/src/infisical_client/schemas.ts", ts.lines); -``` - -## Building bindings -We've tried to streamline the building process as much as possible. So you shouldn't have to worry much about building bindings, as it should just be a few commands. - -### Node.js -Building bindings for Node.js is very straight foward. The command below will generate NAPI bindings for Node.js, and move the bindings to the correct folder. We use [NAPI-RS](https://napi.rs/) to generate the bindings. - -```console -$ cd languages/node -$ npm run build -``` - -### Python -To generate and use python bindings you will need to run the following commands. -The Python SDK is located inside the crates folder. This is a limitation of the maturin tool, forcing us to structure the project in this way. - -```console -$ pip install -U pip maturin -$ cd crates/infisical-py -$ python3 -m venv .venv -$ source .venv/bin/activate -$ maturin develop -``` - - - After running the commands above, it's very important that you rename the generated .so file to `infisical_py.so`. After renaming it you also need to move it into the root of the `crates/infisical-py` folder. - - -### Java -Java uses the C bindings to interact with the base SDK. To build and use the C bindings in Java, please follow the instructions below. - -```console -$ cd crates/infisical-c -$ cargo build --release -$ cd ../../languages/java -``` - - After generating the C bindings, the generated .so or .dll has been created in the `/target` directory at the root of the project. - You have to manually move the generated file into the `languages/java/src/main/resources` directory. - - -## Error handling - -### Error handling in the base SDK - -The base SDK should never panic. If an error occurs, we should return a `Result` with an error message. We have a custom Result type defined in the `error.rs` file in the base SDK. - -All our errors are defined in an enum called `Error`. The `Error` enum is defined in the `error.rs` file in the base SDK. The `Error` enum is used in the `Result` type, which is used as the return type for all functions in the base SDK. - -```rust -#[derive(Debug, Error)] -pub enum Error { - // Secret not found - #[error("Secret with name '{}' not found.", .secret_name)] - SecretNotFound { secret_name: String }, - - // .. other errors - - // Errors that are not specific to the base SDK. - #[error(transparent)] - Reqwest(#[from] reqwest::Error), - #[error(transparent)] - Serde(#[from] serde_json::Error), - #[error(transparent)] - Io(#[from] std::io::Error), -} -``` - -### Returning an error - -You can find many examples of how we return errors in the SDK code. A relevant example is for creating secrets, which can be found in `crates/infisical/src/api/secrets/create_secret.rs`. When the error happened due to a request error to our API, we have an API error handler. This prevents duplicate code and keeps error handling consistent across the SDK. You can find the api error handler in the `error.rs` file. - -### Error handling in the target language SDK's. - -All data sent to the target language SDK has the same format. The format is an object with 3 fields: `success (boolean)`, `data (could be anything or nothing)`, and `errorMessage (string or null)`. - -The `success` field is used to determine if the request was successful or not. The `data` field is used to return data from the SDK. The `errorMessage` field is used to return an error message if the request was not successful. - -This means that if the success if false or if the error message is not null, something went wrong and we should throw an error on the target-language level, with the error message. - -## Command handler - -### What is the command handler - -The command handler (the `infisical-json` crate), takes care of executing commands sent from the target language. It also takes care of parsing the input and output to and from JSON. The command handler is the only part of the base SDK that should be aware of JSON. The rest of the base SDK should be completely unaware of JSON, and only work with the Rust structs defined in the base SDK. - -The command handler exposes a function called `run_command`, which is what we use in the target language to execute commands. The function takes a json string as input, and returns a json string as output. We use helper functions generated by QuickType to convert the input and output to and from JSON. - -### Creating new SDK methods - -Creating new commands is necessary when adding new methods to the SDK's. Defining a new command is a 3-step process in most cases. - -#### 1. Define the input and output structs - -Earlier in this guide, we defined the input and output structs for the `CreateSecret` command. We will use that as an example here as well. - -#### 2. Creating the method in the base SDK - -The first step is to create the method in the base SDK. This step will be different depending on what method you are adding. In this example we're going to assume you're adding a function for creating a new secret. - -After you created the function for creating the secret, you'll need need to add it to the ClientSecrets implementation. We do it this way to keep the code organized and easy to read. The ClientSecrets struct is located in the `crates/infisical/src/manager/secrets.rs` file. - -```rust -pub struct ClientSecrets<'a> { - pub(crate) client: &'a mut crate::Client, -} - -impl<'a> ClientSecrets<'a> { - pub async fn create(&mut self, input: &CreateSecretOptions) -> Result { - create_secret(self.client, input).await // <-- This is the function you created! - } -} - -impl<'a> Client { - pub fn secrets(&'a mut self) -> ClientSecrets<'a> { - ClientSecrets { client: self } - } -} -``` - -#### 3. Define a new command - -We define new commands in the `crates/infisical-json/src/command.rs` file. The `Command` enum is what we use to define new commands. - -In the codesnippet below we define a new command called `CreateSecret`. The `CreateSecret` command takes a `CreateSecretOptions` struct as input. We don't have to define the output, because QuickType's converter helps us with figuring out the return type for each command. - -````rust -```rust -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, JsonSchema, Debug)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub enum Command { - GetSecret(GetSecretOptions), - ListSecrets(ListSecretsOptions), - CreateSecret(CreateSecretOptions), // <-- The new command! - UpdateSecret(UpdateSecretOptions), - DeleteSecret(DeleteSecretOptions), -} -```` - -#### 4. Add the command to the command handler - -After defining the command, we need to add it to the command handler itself. This takes place in the `crates/infisical-json/src/client.rs` file. The `run_command` function is what we use to execute commands. - -In the Client implementation we try to parse the JSON string into a `Command` enum. If the parsing is successful, we match the command and execute the corresponding function. - -```rust -match cmd { - Command::GetSecret(req) => self.0.secrets().get(&req).await.into_string(), - Command::ListSecrets(req) => self.0.secrets().list(&req).await.into_string(), - Command::UpdateSecret(req) => self.0.secrets().update(&req).await.into_string(), - Command::DeleteSecret(req) => self.0.secrets().delete(&req).await.into_string(), - - // This is the new command: - Command::CreateSecret(req) => self.0.secrets().create(&req).await.into_string(), -} -``` - -#### 5. Implementing the new command in the target language SDK's - -We did it! We've now added a new command to the base SDK. The last step is to implement the new command in the target language SDK's. The process is a little different from language to language, but in this example we're going to assume that we're adding a new command to the Node.js SDK. - -First you'll need to generate the new type schemas, we added a new command, input struct, and output struct. [Read more about generating types here](#generating-the-types-for-the-target-language). - -Secondly you need to build the new node bindings so we can use the new functionality in the Node.js SDK. You can do this by running the following command from the `languages/node` directory: - -```console -$ npm install -$ npm run build -``` - -The build command will execute a build script in the `infisical-napi` crate, and move the generated bindings to the appropriate folder. - -After building the new bindings, you can access the new functionality in the Node.js SDK source. - -```ts -// 'binding' is a js file that makes it easier to access the methods in the bindings. (it's auto generated when running npm run build) -import * as rust from "../../binding"; -// We can import the newly generated types from the schemas.ts file. (Generated with QuickType!) -import type { CreateSecretOptions, CreateSecretResponse } from "./schemas"; -// This is the QuickType converter that we use to create commands with! It takes care of all JSON parsing and serialization. -import { Convert, ClientSettings } from "./schemas"; - -export class InfisicalClient { - #client: rust.Client; - - constructor(settings: ClientSettings) { - const settingsJson = settings == null ? null : Convert.clientSettingsToJson(settings); - this.#client = new rust.InfisicalClient(settingsJson); - } - - // ... getSecret - // ... listSecrets - // ... updateSecret - // ... deleteSecret - - async createSecret(options: CreateSecretOptions): Promise { - // The runCommand will return a JSON string, which we can parse into a CreateSecretResponse. - const command = await this.#client.runCommand( - Convert.commandToJson({ - createSecret: options - }) - ); - const response = Convert.toResponseForCreateSecretResponse(command); // <-- This is the QuickType converter in action! - - // If the response is not successful or the data is null, we throw an error. - if (!response.success || response.data == null) { - throw new Error(response.errorMessage ?? "Something went wrong"); - } - - // To make it easier to work with the response, we return the secret directly. - return response.data.secret; - } -} -``` - -And that's it! We've now added a new command to the base SDK, and implemented it in the Node.js SDK. The process is very similar for all other languages, but the code will look a little different. - -## Conclusion - -The SDK has a lot of moving parts, and it can be a little overwhelming at first. But once you get the hang of it, it's actually quite simple. If you have any questions, feel free to reach out to us on [Slack](https://infisical.com/slack), or [open an issue](https://github.com/Infisical/sdk/issues) on GitHub. diff --git a/docs/docs.json b/docs/docs.json index 82988328c..9ea476487 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -10,97 +10,53 @@ "styling": { "codeblocks": "dark" }, - "favicon": "/favicon.png", + "favicon": "/favicon.ico", "navigation": { "tabs": [ { - "tab": "Documentation", + "tab": "Platform", "groups": [ { "group": "Getting Started", "pages": [ + "documentation/getting-started/overview", "documentation/getting-started/introduction", { - "group": "Quickstart", - "pages": ["documentation/guides/local-development"] - }, - { - "group": "Guides", + "group": "Concepts", "pages": [ - "documentation/guides/introduction", - "documentation/guides/node", - "documentation/guides/python", - "documentation/guides/nextjs-vercel", - "documentation/guides/microsoft-power-apps", - "documentation/guides/organization-structure" + "documentation/getting-started/concepts/deployment-models", + "documentation/getting-started/concepts/platform-hierarchy", + "documentation/getting-started/concepts/platform-iam", + "documentation/getting-started/concepts/client-integrations", + "documentation/getting-started/concepts/audit-logs" ] }, { - "group": "Setup", - "pages": ["documentation/setup/networking"] + "group": "Guides", + "pages": ["documentation/guides/organization-structure"] } ] }, { - "group": "Platform", + "group": "Platform Reference", "pages": [ "documentation/platform/organization", - "documentation/platform/project", - "documentation/platform/folder", { - "group": "Secrets", + "group": "Projects", "pages": [ - "documentation/platform/secret-versioning", - "documentation/platform/pit-recovery", - "documentation/platform/secret-reference", - "documentation/platform/webhooks" - ] - }, - { - "group": "Internal PKI", - "pages": [ - "documentation/platform/pki/overview", - "documentation/platform/pki/private-ca", - "documentation/platform/pki/external-ca", - "documentation/platform/pki/subscribers", - "documentation/platform/pki/certificates", - "documentation/platform/pki/acme-ca", - "documentation/platform/pki/est", - "documentation/platform/pki/alerting", + "documentation/platform/project", + "documentation/platform/project-templates", { - "group": "Integrations", + "group": "KMS Configuration", "pages": [ - "documentation/platform/pki/pki-issuer", - "documentation/platform/pki/integration-guides/gloo-mesh" + "documentation/platform/kms-configuration/overview", + "documentation/platform/kms-configuration/aws-kms", + "documentation/platform/kms-configuration/aws-hsm", + "documentation/platform/kms-configuration/gcp-kms" ] } ] }, - { - "group": "Infisical SSH", - "pages": [ - "documentation/platform/ssh/overview", - "documentation/platform/ssh/host-groups" - ] - }, - { - "group": "Key Management (KMS)", - "pages": [ - "documentation/platform/kms/overview", - "documentation/platform/kms/hsm-integration", - "documentation/platform/kms/kubernetes-encryption", - "documentation/platform/kms/kmip" - ] - }, - { - "group": "KMS Configuration", - "pages": [ - "documentation/platform/kms-configuration/overview", - "documentation/platform/kms-configuration/aws-kms", - "documentation/platform/kms-configuration/aws-hsm", - "documentation/platform/kms-configuration/gcp-kms" - ] - }, { "group": "Identities", "pages": [ @@ -140,57 +96,56 @@ ] }, { - "group": "Secret Rotation", + "group": "App Connections", "pages": [ - "documentation/platform/secret-rotation/overview", - "documentation/platform/secret-rotation/auth0-client-secret", - "documentation/platform/secret-rotation/aws-iam-user-secret", - "documentation/platform/secret-rotation/azure-client-secret", - "documentation/platform/secret-rotation/ldap-password", - "documentation/platform/secret-rotation/mssql-credentials", - "documentation/platform/secret-rotation/mysql-credentials", - "documentation/platform/secret-rotation/okta-client-secret", - "documentation/platform/secret-rotation/oracledb-credentials", - "documentation/platform/secret-rotation/postgres-credentials" + "integrations/app-connections", + "integrations/app-connections/overview", + { + "group": "Connections", + "pages": [ + "integrations/app-connections/1password", + "integrations/app-connections/auth0", + "integrations/app-connections/aws", + "integrations/app-connections/azure-app-configuration", + "integrations/app-connections/azure-adcs", + "integrations/app-connections/azure-client-secrets", + "integrations/app-connections/azure-devops", + "integrations/app-connections/azure-key-vault", + "integrations/app-connections/bitbucket", + "integrations/app-connections/camunda", + "integrations/app-connections/checkly", + "integrations/app-connections/cloudflare", + "integrations/app-connections/databricks", + "integrations/app-connections/digital-ocean", + "integrations/app-connections/flyio", + "integrations/app-connections/gcp", + "integrations/app-connections/github", + "integrations/app-connections/github-radar", + "integrations/app-connections/gitlab", + "integrations/app-connections/hashicorp-vault", + "integrations/app-connections/heroku", + "integrations/app-connections/humanitec", + "integrations/app-connections/ldap", + "integrations/app-connections/mssql", + "integrations/app-connections/mysql", + "integrations/app-connections/netlify", + "integrations/app-connections/oci", + "integrations/app-connections/okta", + "integrations/app-connections/oracledb", + "integrations/app-connections/postgres", + "integrations/app-connections/railway", + "integrations/app-connections/render", + "integrations/app-connections/supabase", + "integrations/app-connections/teamcity", + "integrations/app-connections/terraform-cloud", + "integrations/app-connections/vercel", + "integrations/app-connections/windmill", + "integrations/app-connections/zabbix" + ] + } ] }, - { - "group": "Dynamic Secrets", - "pages": [ - "documentation/platform/dynamic-secrets/overview", - "documentation/platform/dynamic-secrets/aws-elasticache", - "documentation/platform/dynamic-secrets/aws-iam", - "documentation/platform/dynamic-secrets/azure-entra-id", - "documentation/platform/dynamic-secrets/cassandra", - "documentation/platform/dynamic-secrets/elastic-search", - "documentation/platform/dynamic-secrets/gcp-iam", - "documentation/platform/dynamic-secrets/github", - "documentation/platform/dynamic-secrets/ldap", - "documentation/platform/dynamic-secrets/mongo-atlas", - "documentation/platform/dynamic-secrets/mongo-db", - "documentation/platform/dynamic-secrets/mssql", - "documentation/platform/dynamic-secrets/mysql", - "documentation/platform/dynamic-secrets/oracle", - "documentation/platform/dynamic-secrets/postgresql", - "documentation/platform/dynamic-secrets/rabbit-mq", - "documentation/platform/dynamic-secrets/redis", - "documentation/platform/dynamic-secrets/sap-ase", - "documentation/platform/dynamic-secrets/sap-hana", - "documentation/platform/dynamic-secrets/snowflake", - "documentation/platform/dynamic-secrets/totp", - "documentation/platform/dynamic-secrets/kubernetes", - "documentation/platform/dynamic-secrets/vertica" - ] - }, - { - "group": "Gateway", - "pages": [ - "documentation/platform/gateways/overview", - "documentation/platform/gateways/gateway-security", - "documentation/platform/gateways/networking" - ] - }, - "documentation/platform/project-templates", + "documentation/platform/event-subscriptions", { "group": "Workflow Integrations", "pages": [ @@ -206,21 +161,20 @@ "documentation/platform/external-migrations/vault" ] }, + "documentation/platform/admin-panel/server-admin", + "documentation/platform/secret-sharing" + ] + }, + { + "group": "Connectivity", + "pages": [ + "documentation/setup/networking", { - "group": "Admin Consoles", + "group": "Gateway", "pages": [ - "documentation/platform/admin-panel/overview", - "documentation/platform/admin-panel/server-admin", - "documentation/platform/admin-panel/org-admin-console" - ] - }, - "documentation/platform/secret-sharing", - { - "group": "Secret Scanning", - "pages": [ - "documentation/platform/secret-scanning/overview", - "documentation/platform/secret-scanning/bitbucket", - "documentation/platform/secret-scanning/github" + "documentation/platform/gateways/overview", + "documentation/platform/gateways/gateway-security", + "documentation/platform/gateways/networking" ] } ] @@ -231,6 +185,7 @@ { "group": "User Authentication", "pages": [ + "integrations/user-authentication", "documentation/platform/auth-methods/email-password", { "group": "SSO", @@ -290,6 +245,7 @@ { "group": "Machine Identities", "pages": [ + "integrations/machine-authentication", "documentation/platform/identities/alicloud-auth", "documentation/platform/identities/aws-auth", "documentation/platform/identities/azure-auth", @@ -321,6 +277,7 @@ } ] }, + "documentation/platform/identities/auth-templates", "documentation/platform/token", "documentation/platform/mfa", "documentation/platform/github-org-sync" @@ -349,6 +306,7 @@ }, "self-hosting/guides/upgrading-infisical", "self-hosting/configuration/envars", + "self-hosting/guides/releases", "self-hosting/configuration/requirements", { "group": "Guides", @@ -356,7 +314,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" ] }, { @@ -387,10 +346,7 @@ }, { "group": "Architecture", - "pages": [ - "internals/architecture/components", - "internals/architecture/cloud" - ] + "pages": ["internals/architecture/components", "internals/architecture/cloud"] }, "internals/security", "internals/service-tokens" @@ -415,28 +371,108 @@ "contributing/platform/backend/how-to-create-a-feature", "contributing/platform/backend/folder-structure" ] - }, - { - "group": "Contributing to SDK", - "pages": ["contributing/sdk/developing"] } ] } ] }, { - "tab": "Integrations", - "groups": [ + "tab": "Products", + "menu": [ { - "group": "Infrastructure Integrations", - "pages": [ - "integrations/platforms/ansible", - "integrations/platforms/apache-airflow", + "item": "Secrets Management", + "groups": [ { - "group": "Container orchestrators", + "group": "Secrets Management", "pages": [ + "documentation/platform/secrets-mgmt/overview", { - "group": "Kubernetes", + "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": [ + "integrations/secret-rotations", + "documentation/platform/secret-rotation/overview", + "documentation/platform/secret-rotation/auth0-client-secret", + "documentation/platform/secret-rotation/aws-iam-user-secret", + "documentation/platform/secret-rotation/azure-client-secret", + "documentation/platform/secret-rotation/ldap-password", + "documentation/platform/secret-rotation/mssql-credentials", + "documentation/platform/secret-rotation/mysql-credentials", + "documentation/platform/secret-rotation/okta-client-secret", + "documentation/platform/secret-rotation/oracledb-credentials", + "documentation/platform/secret-rotation/postgres-credentials" + ] + }, + { + "group": "Dynamic Secrets", + "pages": [ + "integrations/dynamic-secrets", + "documentation/platform/dynamic-secrets/overview", + "documentation/platform/dynamic-secrets/aws-elasticache", + "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", + "documentation/platform/dynamic-secrets/ldap", + "documentation/platform/dynamic-secrets/mongo-atlas", + "documentation/platform/dynamic-secrets/mongo-db", + "documentation/platform/dynamic-secrets/mssql", + "documentation/platform/dynamic-secrets/mysql", + "documentation/platform/dynamic-secrets/oracle", + "documentation/platform/dynamic-secrets/postgresql", + "documentation/platform/dynamic-secrets/rabbit-mq", + "documentation/platform/dynamic-secrets/redis", + "documentation/platform/dynamic-secrets/sap-ase", + "documentation/platform/dynamic-secrets/sap-hana", + "documentation/platform/dynamic-secrets/snowflake", + "documentation/platform/dynamic-secrets/totp", + "documentation/platform/dynamic-secrets/kubernetes", + "documentation/platform/dynamic-secrets/vertica" + ] + }, + "documentation/platform/webhooks" + ] + }, + { + "group": "Infrastructure Integrations", + "pages": [ + "integrations/platforms/ansible", + "integrations/platforms/apache-airflow", + { + "group": "Kubernetes Operator", "pages": [ "integrations/platforms/kubernetes/overview", "integrations/platforms/kubernetes/infisical-secret-crd", @@ -446,220 +482,261 @@ }, "integrations/platforms/kubernetes-injector", "integrations/platforms/kubernetes-csi", - "integrations/platforms/docker-swarm-with-agent", - "integrations/platforms/ecs-with-agent" + { + "group": "Agent", + "pages": [ + "integrations/platforms/infisical-agent", + "integrations/platforms/docker-swarm-with-agent", + "integrations/platforms/ecs-with-agent" + ] + }, + { + "group": "Docker", + "pages": [ + "integrations/platforms/docker-intro", + "integrations/platforms/docker", + "integrations/platforms/docker-pass-envs", + "integrations/platforms/docker-compose" + ] + }, + "integrations/frameworks/packer", + "integrations/frameworks/pulumi", + "integrations/frameworks/terraform" ] }, { - "group": "Docker", + "group": "Secret Syncs", "pages": [ - "integrations/platforms/docker-intro", - "integrations/platforms/docker", - "integrations/platforms/docker-pass-envs", - "integrations/platforms/docker-compose" + "integrations/secret-syncs", + "integrations/secret-syncs/overview", + { + "group": "Syncs", + "pages": [ + "integrations/secret-syncs/1password", + "integrations/secret-syncs/aws-parameter-store", + "integrations/secret-syncs/aws-secrets-manager", + "integrations/secret-syncs/azure-app-configuration", + "integrations/secret-syncs/azure-devops", + "integrations/secret-syncs/azure-key-vault", + "integrations/secret-syncs/bitbucket", + "integrations/secret-syncs/camunda", + "integrations/secret-syncs/checkly", + "integrations/secret-syncs/cloudflare-pages", + "integrations/secret-syncs/cloudflare-workers", + "integrations/secret-syncs/databricks", + "integrations/secret-syncs/digital-ocean-app-platform", + "integrations/secret-syncs/flyio", + "integrations/secret-syncs/gcp-secret-manager", + "integrations/secret-syncs/github", + "integrations/secret-syncs/gitlab", + "integrations/secret-syncs/hashicorp-vault", + "integrations/secret-syncs/heroku", + "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/netlify", + "integrations/secret-syncs/oci-vault", + "integrations/secret-syncs/railway", + "integrations/secret-syncs/render", + "integrations/secret-syncs/supabase", + "integrations/secret-syncs/teamcity", + "integrations/secret-syncs/terraform-cloud", + "integrations/secret-syncs/vercel", + "integrations/secret-syncs/windmill", + "integrations/secret-syncs/zabbix" + ] + } ] }, - "integrations/platforms/infisical-agent", - "integrations/frameworks/packer", - "integrations/frameworks/pulumi", - "integrations/frameworks/terraform" - ] - }, - { - "group": "App Connections", - "pages": [ - "integrations/app-connections/overview", { - "group": "Connections", + "group": "Native Integrations", "pages": [ - "integrations/app-connections/1password", - "integrations/app-connections/auth0", - "integrations/app-connections/aws", - "integrations/app-connections/azure-app-configuration", - "integrations/app-connections/azure-client-secrets", - "integrations/app-connections/azure-devops", - "integrations/app-connections/azure-key-vault", - "integrations/app-connections/bitbucket", - "integrations/app-connections/camunda", - "integrations/app-connections/checkly", - "integrations/app-connections/cloudflare", - "integrations/app-connections/databricks", - "integrations/app-connections/digital-ocean", - "integrations/app-connections/flyio", - "integrations/app-connections/gcp", - "integrations/app-connections/github", - "integrations/app-connections/github-radar", - "integrations/app-connections/gitlab", - "integrations/app-connections/hashicorp-vault", - "integrations/app-connections/heroku", - "integrations/app-connections/humanitec", - "integrations/app-connections/ldap", - "integrations/app-connections/mssql", - "integrations/app-connections/mysql", - "integrations/app-connections/oci", - "integrations/app-connections/okta", - "integrations/app-connections/oracledb", - "integrations/app-connections/postgres", - "integrations/app-connections/railway", - "integrations/app-connections/render", - "integrations/app-connections/supabase", - "integrations/app-connections/teamcity", - "integrations/app-connections/terraform-cloud", - "integrations/app-connections/vercel", - "integrations/app-connections/windmill", - "integrations/app-connections/zabbix" - ] - } - ] - }, - { - "group": "Secret Syncs", - "pages": [ - "integrations/secret-syncs/overview", - { - "group": "Syncs", - "pages": [ - "integrations/secret-syncs/1password", - "integrations/secret-syncs/aws-parameter-store", - "integrations/secret-syncs/aws-secrets-manager", - "integrations/secret-syncs/azure-app-configuration", - "integrations/secret-syncs/azure-devops", - "integrations/secret-syncs/azure-key-vault", - "integrations/secret-syncs/bitbucket", - "integrations/secret-syncs/camunda", - "integrations/secret-syncs/checkly", - "integrations/secret-syncs/cloudflare-pages", - "integrations/secret-syncs/cloudflare-workers", - "integrations/secret-syncs/databricks", - "integrations/secret-syncs/digital-ocean-app-platform", - "integrations/secret-syncs/flyio", - "integrations/secret-syncs/gcp-secret-manager", - "integrations/secret-syncs/github", - "integrations/secret-syncs/gitlab", - "integrations/secret-syncs/hashicorp-vault", - "integrations/secret-syncs/heroku", - "integrations/secret-syncs/humanitec", - "integrations/secret-syncs/oci-vault", - "integrations/secret-syncs/railway", - "integrations/secret-syncs/render", - "integrations/secret-syncs/supabase", - "integrations/secret-syncs/teamcity", - "integrations/secret-syncs/terraform-cloud", - "integrations/secret-syncs/vercel", - "integrations/secret-syncs/windmill", - "integrations/secret-syncs/zabbix" - ] - } - ] - }, - { - "group": "Native Integrations", - "pages": [ - { - "group": "AWS", - "pages": [ - "integrations/cloud/aws-parameter-store", - "integrations/cloud/aws-secret-manager", - "integrations/cloud/aws-amplify" + { + "group": "AWS", + "pages": [ + "integrations/cloud/aws-parameter-store", + "integrations/cloud/aws-secret-manager", + "integrations/cloud/aws-amplify" + ] + }, + "integrations/cloud/vercel", + "integrations/cloud/azure-key-vault", + "integrations/cloud/azure-app-configuration", + "integrations/cloud/azure-devops", + "integrations/cloud/gcp-secret-manager", + { + "group": "Cloudflare", + "pages": ["integrations/cloud/cloudflare-pages", "integrations/cloud/cloudflare-workers"] + }, + "integrations/cloud/terraform-cloud", + "integrations/cloud/databricks", + { + "group": "View more", + "pages": [ + "integrations/cloud/digital-ocean-app-platform", + "integrations/cloud/heroku", + "integrations/cloud/netlify", + "integrations/cloud/flyio", + "integrations/cloud/railway", + "integrations/cloud/render", + "integrations/cloud/laravel-forge", + "integrations/cloud/supabase", + "integrations/cloud/northflank", + "integrations/cloud/hasura-cloud", + "integrations/cloud/qovery", + "integrations/cloud/hashicorp-vault", + "integrations/cloud/cloud-66", + "integrations/cloud/windmill" + ] + } ] }, - "integrations/cloud/vercel", - "integrations/cloud/azure-key-vault", - "integrations/cloud/azure-app-configuration", - "integrations/cloud/azure-devops", - "integrations/cloud/gcp-secret-manager", { - "group": "Cloudflare", + "group": "CI/CD Integrations", "pages": [ - "integrations/cloud/cloudflare-pages", - "integrations/cloud/cloudflare-workers" + "integrations/cicd/jenkins", + "integrations/cicd/githubactions", + "integrations/cicd/gitlab", + "integrations/cicd/bitbucket", + "integrations/cloud/teamcity", + { + "group": "View more", + "pages": [ + "integrations/cicd/circleci", + "integrations/cicd/travisci", + "integrations/cicd/rundeck", + "integrations/cicd/codefresh", + "integrations/cloud/checkly", + "integrations/cicd/octopus-deploy" + ] + } ] }, - "integrations/cloud/terraform-cloud", - "integrations/cloud/databricks", { - "group": "View more", + "group": "Framework Integrations", "pages": [ - "integrations/cloud/digital-ocean-app-platform", - "integrations/cloud/heroku", - "integrations/cloud/netlify", - "integrations/cloud/flyio", - "integrations/cloud/railway", - "integrations/cloud/render", - "integrations/cloud/laravel-forge", - "integrations/cloud/supabase", - "integrations/cloud/northflank", - "integrations/cloud/hasura-cloud", - "integrations/cloud/qovery", - "integrations/cloud/hashicorp-vault", - "integrations/cloud/cloud-66", - "integrations/cloud/windmill" + "integrations/framework-integrations", + "integrations/frameworks/spring-boot-maven", + "integrations/frameworks/react", + "integrations/frameworks/vue", + "integrations/frameworks/express", + { + "group": "View more", + "pages": [ + "integrations/frameworks/nextjs", + "integrations/frameworks/nestjs", + "integrations/frameworks/sveltekit", + "integrations/frameworks/nuxt", + "integrations/frameworks/gatsby", + "integrations/frameworks/remix", + "integrations/frameworks/vite", + "integrations/frameworks/fiber", + "integrations/frameworks/django", + "integrations/frameworks/flask", + "integrations/frameworks/laravel", + "integrations/frameworks/rails", + "integrations/frameworks/dotnet", + "integrations/platforms/pm2", + "integrations/frameworks/ab-initio" + ] + } + ] + }, + { + "group": "Build Tool Integrations", + "pages": ["integrations/build-tools/gradle"] + }, + { + "group": "Others", + "pages": ["integrations/external/backstage"] + } + ] + }, + { + "item": "Secrets Scanning", + "groups": [ + { + "group": "Secrets Scanning", + "pages": [ + "documentation/platform/secret-scanning/overview", + { + "group": "Concepts", + "pages": ["documentation/platform/secret-scanning/concepts/secret-scanning"] + } + ] + }, + { + "group": "Product Reference", + "pages": [ + "documentation/platform/secret-scanning/usage", + "documentation/platform/secret-scanning/bitbucket", + "documentation/platform/secret-scanning/github", + "documentation/platform/secret-scanning/gitlab" ] } ] }, { - "group": "CI/CD Integrations", - "pages": [ - "integrations/cicd/jenkins", - "integrations/cicd/githubactions", - "integrations/cicd/gitlab", - "integrations/cicd/bitbucket", - "integrations/cloud/teamcity", + "item": "Infisical PKI", + "groups": [ { - "group": "View more", + "group": "Infisical PKI", "pages": [ - "integrations/cicd/circleci", - "integrations/cicd/travisci", - "integrations/cicd/rundeck", - "integrations/cicd/codefresh", - "integrations/cloud/checkly", - "integrations/cicd/octopus-deploy" + "documentation/platform/pki/overview", + "documentation/platform/pki/private-ca", + "documentation/platform/pki/external-ca", + "documentation/platform/pki/subscribers", + "documentation/platform/pki/certificates", + "documentation/platform/pki/acme-ca", + "documentation/platform/pki/azure-adcs", + "documentation/platform/pki/est", + "documentation/platform/pki/alerting", + { + "group": "Integrations", + "pages": [ + "documentation/platform/pki/pki-issuer", + "documentation/platform/pki/integration-guides/gloo-mesh" + ] + } ] } ] }, { - "group": "Framework Integrations", - "pages": [ - "integrations/frameworks/spring-boot-maven", - "integrations/frameworks/react", - "integrations/frameworks/vue", - "integrations/frameworks/express", + "item": "Infisical SSH", + "groups": [ { - "group": "View more", + "group": "Infisical SSH", "pages": [ - "integrations/frameworks/nextjs", - "integrations/frameworks/nestjs", - "integrations/frameworks/sveltekit", - "integrations/frameworks/nuxt", - "integrations/frameworks/gatsby", - "integrations/frameworks/remix", - "integrations/frameworks/vite", - "integrations/frameworks/fiber", - "integrations/frameworks/django", - "integrations/frameworks/flask", - "integrations/frameworks/laravel", - "integrations/frameworks/rails", - "integrations/frameworks/dotnet", - "integrations/platforms/pm2", - "integrations/frameworks/ab-initio" + "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"] } ] }, { - "group": "Build Tool Integrations", - "pages": ["integrations/build-tools/gradle"] - }, - { - "group": "Others", - "pages": ["integrations/external/backstage"] + "item": "Infisical KMS", + "groups": [ + { + "group": "Infisical KMS", + "pages": [ + "documentation/platform/kms/overview", + "documentation/platform/kms/hsm-integration", + "documentation/platform/kms/kubernetes-encryption", + "documentation/platform/kms/kmip" + ] + } + ] } ] }, { - "tab": "CLI", + "tab": "CLI Reference", "groups": [ { "group": "Command line", @@ -685,11 +762,7 @@ "cli/commands/reset", { "group": "infisical scan", - "pages": [ - "cli/commands/scan", - "cli/commands/scan-git-changes", - "cli/commands/scan-install" - ] + "pages": ["cli/commands/scan", "cli/commands/scan-git-changes", "cli/commands/scan-install"] } ] }, @@ -721,6 +794,10 @@ "group": "Admin", "pages": ["api-reference/endpoints/admin/bootstrap-instance"] }, + { + "group": "Events", + "pages": ["api-reference/endpoints/events/project-events"] + }, { "group": "Identities", "pages": [ @@ -904,6 +981,7 @@ "api-reference/endpoints/organizations/memberships", "api-reference/endpoints/organizations/update-membership", "api-reference/endpoints/organizations/delete-membership", + "api-reference/endpoints/organizations/bulk-delete-memberships", "api-reference/endpoints/organizations/list-identity-memberships", "api-reference/endpoints/organizations/workspaces" ] @@ -911,6 +989,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", @@ -1017,9 +1096,7 @@ "pages": [ { "group": "Kubernetes", - "pages": [ - "api-reference/endpoints/dynamic-secrets/kubernetes/create-lease" - ] + "pages": ["api-reference/endpoints/dynamic-secrets/kubernetes/create-lease"] }, "api-reference/endpoints/dynamic-secrets/create", "api-reference/endpoints/dynamic-secrets/update", @@ -1203,6 +1280,21 @@ "api-reference/endpoints/secret-scanning/data-sources/github/scan", "api-reference/endpoints/secret-scanning/data-sources/github/scan-resource" ] + }, + { + "group": "GitLab", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/gitlab/list", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/create", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/update", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/delete", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/scan", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource" + ] } ] }, @@ -1290,6 +1382,18 @@ "api-reference/endpoints/app-connections/aws/delete" ] }, + { + "group": "Azure ADCS", + "pages": [ + "api-reference/endpoints/app-connections/azure-adcs/list", + "api-reference/endpoints/app-connections/azure-adcs/available", + "api-reference/endpoints/app-connections/azure-adcs/get-by-id", + "api-reference/endpoints/app-connections/azure-adcs/get-by-name", + "api-reference/endpoints/app-connections/azure-adcs/create", + "api-reference/endpoints/app-connections/azure-adcs/update", + "api-reference/endpoints/app-connections/azure-adcs/delete" + ] + }, { "group": "Azure App Configuration", "pages": [ @@ -1541,6 +1645,18 @@ "api-reference/endpoints/app-connections/mysql/delete" ] }, + { + "group": "Netlify", + "pages": [ + "api-reference/endpoints/app-connections/netlify/list", + "api-reference/endpoints/app-connections/netlify/available", + "api-reference/endpoints/app-connections/netlify/get-by-id", + "api-reference/endpoints/app-connections/netlify/get-by-name", + "api-reference/endpoints/app-connections/netlify/create", + "api-reference/endpoints/app-connections/netlify/update", + "api-reference/endpoints/app-connections/netlify/delete" + ] + }, { "group": "OCI", "pages": [ @@ -1961,6 +2077,19 @@ "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] }, + { + "group": "Netlify", + "pages": [ + "api-reference/endpoints/secret-syncs/netlify/list", + "api-reference/endpoints/secret-syncs/netlify/get-by-id", + "api-reference/endpoints/secret-syncs/netlify/get-by-name", + "api-reference/endpoints/secret-syncs/netlify/create", + "api-reference/endpoints/secret-syncs/netlify/update", + "api-reference/endpoints/secret-syncs/netlify/delete", + "api-reference/endpoints/secret-syncs/netlify/sync-secrets", + "api-reference/endpoints/secret-syncs/netlify/remove-secrets" + ] + }, { "group": "OCI", "pages": [ @@ -2322,10 +2451,11 @@ "sdks/languages/node", "sdks/languages/python", "sdks/languages/java", - "sdks/languages/csharp", + "sdks/languages/dotnet", "sdks/languages/cpp", "sdks/languages/rust", "sdks/languages/go", + "sdks/languages/php", "sdks/languages/ruby" ] } @@ -2438,7 +2568,7 @@ }, { "label": "Terraform", - "href": "https://infisical.com/docs/integrations/frameworks/terraform" + "href": "https://registry.terraform.io/providers/Infisical/infisical/latest/docs" }, { "label": "Ansible", @@ -2550,5 +2680,11 @@ "koala": { "publicApiKey": "pk_b50d7184e0e39ddd5cdb43cf6abeadd9b97d" } - } + }, + "redirects": [ + { + "source": "/sdks/languages/csharp", + "destination": "/sdks/languages/dotnet" + } + ] } diff --git a/docs/documentation/getting-started/concepts/audit-logs.mdx b/docs/documentation/getting-started/concepts/audit-logs.mdx new file mode 100644 index 000000000..e67f06f5a --- /dev/null +++ b/docs/documentation/getting-started/concepts/audit-logs.mdx @@ -0,0 +1,40 @@ +--- +title: "Audit Logs" +sidebarTitle: "Audit Logs" +description: "Understand how Infisical logs activity and supports external audit streaming." +--- + +Infisical records a detailed audit trail of actions across the platform โ€” providing deep visibility into access, changes, and usage for security and compliance purposes. + +Every interaction with Infisical resources generates an audit event. These events are immutable and include metadata such as the actor, event type, affected resources, timestamp, IP address, and client source. + +Audit logs enable teams to: + +- Monitor access and changes to secrets, certificates, and infrastructure. +- Investigate incidents with full context around who did what, when, and how. +- Meet compliance and governance requirements with structured activity records. + +To learn more, refer to the [audit logs documentation](/documentation/platform/audit-logs). + +## Log Coverage + +Infisical tracks dozens of event types across the platform โ€” including secret access, permission changes, certificate issuance, SSH session activity, and identity management. + +Each audit entry includes structured fields that make it easy to search, filter, and correlate across systems. For example: + +- Event Type: Action that occurred (e.g., `create-secret`, `issue-ssh-cert`). +- Actor: Who performed the action (user or machine identity). +- Resource: What was affected (e.g., project, secret, certificate). +- Context: IP address, user agent, permissions, and more. + +## External Log Streaming + +For centralized monitoring and long-term retention, Infisical supports [audit log streaming](/documentation/platform/audit-log-streams/audit-log-streams) to external systems. + +You can forward logs to SIEM platforms, storage buckets, or observability stacks using JSON-based collectors. Infisical integrates well with tools like [Fluent Bit](/documentation/platform/audit-log-streams/audit-log-streams-with-fluentbit#deploy-fluent-bit), enabling teams to route logs to destinations such as: + +- AWS S3 +- Elasticsearch +- Splunk +- Datadog +- Cloud-native log pipelines diff --git a/docs/documentation/getting-started/concepts/client-integrations.mdx b/docs/documentation/getting-started/concepts/client-integrations.mdx new file mode 100644 index 000000000..bcd935830 --- /dev/null +++ b/docs/documentation/getting-started/concepts/client-integrations.mdx @@ -0,0 +1,31 @@ +--- +title: "Client Ecosystem" +sidebarTitle: "Client Ecosystem" +description: "Get an overview of the CLI, SDKs, agents, APIs, and integrations that interact with Infisical." +--- + +Infisical provides a flexible interface for integrating into development workflows and infrastructure. Around it is a rich ecosystem of clients and integrations that allow users and systems to interact with Infisical across any environment. + +These clients enable access to secrets, certificates, and other resources from wherever theyโ€™re neededโ€”whether thatโ€™s a developerโ€™s terminal, a CI/CD pipeline, or a running Kubernetes workload. + +## Available Clients and Interfaces + +Infisical offers a non-exhaustive set of clients and interfaces to support a wide range of use cases: + +- [CLI](/cli/overview): A powerful command-line interface for developers and operators to interact with Infisical from local or automated environments. Commonly used for secret access, SSH credential issuance, and more. + +- [SDKs](/sdks/overview): Official client libraries for languages like Go, Node.js, and Python make it easy to integrate Infisical directly into applications and internal tooling. + +- [HTTP API](/api-reference/overview/introduction): A fully documented RESTful API powers all core functionality and enables advanced or custom integrations. + +- [Agents](/integrations/platforms/infisical-agent): Lightweight background processes that can fetch and sync secrets or credentials into local environments, containers, or file systems. + +- [Kubernetes Operator](/integrations/platforms/kubernetes/overview): A native controller that syncs Infisical secrets into Kubernetes as native Secrets, and supports secure workload integration. + +- [External Secrets Operator (ESO)](https://external-secrets.io/latest/provider/infisical): Allows Infisical to act as a backend provider for syncing secrets into Kubernetes `Secret` objects using the widely adopted External Secrets Operator. + +- [Kubernetes PKI Issuer](/documentation/platform/pki/pki-issuer): A controller that issues X.509 certificates from Infisical PKI using the cert-manager Issuer and Certificate CRDs. + +- [Secret Syncs](/integrations/secret-syncs/overview): Native integrations to forward secrets to services like GitHub, GitLab, AWS Secrets Manager, Vercel, and more. + +This modular ecosystem lets teams use Infisical alongside their existing stackโ€”without requiring opinionated workflows or lock-in. diff --git a/docs/documentation/getting-started/concepts/deployment-models.mdx b/docs/documentation/getting-started/concepts/deployment-models.mdx new file mode 100644 index 000000000..39078a142 --- /dev/null +++ b/docs/documentation/getting-started/concepts/deployment-models.mdx @@ -0,0 +1,52 @@ +--- +title: "Using Infisical: Cloud or Self-Hosted" +sidebarTitle: "Cloud vs. Self-Host" +description: "Choose between Infisical Cloud or a self-managed deployment" +--- + +Infisical can be used in two ways: via [Infisical Cloud](https://app.infisical.com), a managed offering, or through a self-hosted deployment within your own infrastructure. + +Both options provide the same core platform capabilities. The decision depends on your operational model, trust boundaries, and compliance requirements. While Infisical Cloud comes with built-in security and operational guarantees, a self-hosted deployment gives you full controlโ€”but also full responsibility for securing and maintaining the system. + +## Infisical Cloud + +Infisical Cloud is our managed service found at [app.infisical.com](https://app.infisical.com). It includes automated updates, availability guarantees, and secure infrastructure operations. + +For most teams, Infisical Cloud is the recommended way to get started. It simplifies adoption by removing the need to manage deployment, scaling, or maintenance internally. + +Use this if: + +- You prefer not to operate infrastructure or handle upgrades +- You require a secure, production-grade hosted service +- You want to adopt Infisical with minimal operational overhead + + +

+ By default, Infisical Cloud is a secure, multi-tenant service. For + enterprises with stricter isolation or regulatory needs, dedicated cloud + instances are available. +

+

Contact sales@infisical.com to learn more.

+
+ +## Self-Hosted Infisical + +Infisical can also be deployed and managed within your own infrastructure. This approach provides full control over platform configuration, data storage, and operational security. In this model, your team is responsible for maintaining uptime, monitoring, patching, and integrations. + +Use this if: + +- You require complete control over data, deployment, and security posture +- Your compliance model mandates self-managed or on-premise systems +- You need to tightly integrate with internal tooling and infrastructure + +Infisical supports multiple deployment methods, including [Docker](/self-hosting/deployment-options/standalone-infisical), [Docker Compose](/self-hosting/deployment-options/docker-compose), [Kubernetes](/self-hosting/deployment-options/kubernetes-helm), and [Linux package](/self-hosting/deployment-options/native/linux-package/installation). + +To learn more, refer to the [self-hosting documentation](/self-hosting/overview). + + +

+ The open-source core is available under the MIT license. Additional + enterprise features and support are available with a commercial license. +

+

Contact sales@infisical.com to learn more.

+
diff --git a/docs/documentation/getting-started/concepts/platform-hierarchy.mdx b/docs/documentation/getting-started/concepts/platform-hierarchy.mdx new file mode 100644 index 000000000..7697f935b --- /dev/null +++ b/docs/documentation/getting-started/concepts/platform-hierarchy.mdx @@ -0,0 +1,41 @@ +--- +title: "Platform Hierarchy" +sidebarTitle: "Platform Hierarchy" +description: "Understand how organizations and projects are structured in Infisical." +--- + +Infisical is structured around organizations and projects, allowing teams to manage multiple products, access scopes, and use cases within a single account while keeping boundaries and responsibilities clearly defined. + +## Organizations + +An [organization](/documentation/platform/organization) typically represents a company or high-level entity (e.g. Acme Corp). It acts as the umbrella for all projects, members, and billing settings. + +[Users](/documentation/platform/identities/user-identities) are invited to an organization and assigned [organization-level roles](/documentation/platform/access-controls/role-based-access-controls#organization-level-access-controls) that determine what they can manageโ€”such as members, machine identities, and billing details. + +![organization](/images/platform/organization/organization.png) + +## Projects + +A [project](/documentation/platform/project) belongs to an organization and defines a specific scope of work. Each project has a product type such as Secrets Management, SSH, or PKI that determines what features are available in that project. + +For example: + +- A Secrets Management project manages application secrets across environments. + +- An SSH project enables certificate-based access to infrastructure. + +- A PKI project manages certificate authorities and X.509 certificate workflows. + +Users are added to a project and assigned [project-level roles](/documentation/platform/access-controls/role-based-access-controls#project-level-access-controls) that determine what they can manageโ€”such as secrets, access policies, or certificate authorities. A user can have different roles across projects, allowing for flexible and fine-grained access control that reflects how teams operate in practice. + +![organization projects](/images/platform/organization/organization-projects.png) + +## Key Characteristics + +- Projects are isolated in terms of configuration, permissions, and product workflows. + +- Access is managed independently at both the organization and project level. + +- All projects within an organization share the same billing and user directory. + +Teams can adopt Infisical incrementallyโ€”starting with one product and expanding as needed. diff --git a/docs/documentation/getting-started/concepts/platform-iam.mdx b/docs/documentation/getting-started/concepts/platform-iam.mdx new file mode 100644 index 000000000..87c343269 --- /dev/null +++ b/docs/documentation/getting-started/concepts/platform-iam.mdx @@ -0,0 +1,29 @@ +--- +title: "Platform Identity and Access Management" +sidebarTitle: "Platform IAM" +description: "Understand how users, machine identities, roles, and permissions are managed." +--- + +Infisical uses identity-based access control to govern how users and systems interact with secrets, certificates, infrastructure, and other resources on the platform. + +There are two types of identities: + +- [User identities](/documentation/platform/identities/user-identities): Represent individuals such as developers or administrators that typically access the platform via browser. + +- [Machine identities](/documentation/platform/identities/machine-identities): Represent systems such as CI pipelines or applications that programmatically interact with the platform. + +Each identity is granted access based on its assigned roles and permissions and must authenticate with the platform in order to access any resources. + +To learn more, refer to the [identities documentation](/documentation/platform/identities/overview). + +## Roles and Access + +Infisical provides a robust and flexible access control system. The primary authorization mechanism is [role-based access control (RBAC)](/documentation/platform/access-controls/role-based-access-controls), where identities are assigned roles at two access control levels: + +- [Organization-level access control](/documentation/platform/access-controls/role-based-access-controls#organization-level-access-controls): Control billing, member management, and platform-wide settings + +- [Project-level access control](/documentation/platform/access-controls/role-based-access-controls#project-level-access-controls): Control access to specific product resources like secrets, SSH hosts, or certificates + +Beyond RBAC, Infisical also supports additional project-level permissioning features, [including attribute-based access control (ABAC)](/documentation/platform/access-controls/abac/overview), [temporary access grants](/documentation/platform/access-controls/temporary-access), and [additional privileges](/documentation/platform/access-controls/additional-privileges) for select project types. + +To learn more, refer to the [access control documentation](/documentation/platform/access-controls/overview). diff --git a/docs/documentation/getting-started/introduction-new.mdx b/docs/documentation/getting-started/introduction-new.mdx deleted file mode 100644 index c8eee8739..000000000 --- a/docs/documentation/getting-started/introduction-new.mdx +++ /dev/null @@ -1,107 +0,0 @@ ---- -mode: 'custom' ---- - -export function openSearch() { - document.getElementById('search-bar-entry').click(); -} - -
-
- -
-
-
- Infisical Documentation -
-

- What can we help you build? -

- -
-
- -
- -
- Choose a topic below or simply{' '} - get started -
- - - - Practical guides and best practices to get you up and running quickly. - - - Comprehensive details about the Infisical API. - - - Learn more about Infisical's architecture and underlying security. - - - Read self-hosting instruction for Infisical. - - - Infisical's growing number of third-party integrations. - - - News about features and changes in Pinecone and related tools. - - - -
\ No newline at end of file diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index d73c28ab5..f773019ec 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -1,106 +1,40 @@ --- title: "What is Infisical?" sidebarTitle: "What is Infisical?" -description: "An Introduction to the Infisical secret management platform." +description: "The open source platform for managing secrets, certificates, and secure infrastructure access." --- -**[Infisical](https://infisical.com)** is the open source secret management platform that developers use to centralize their application configuration and secrets like API keys and database credentials as well as manage their internal PKI. Additionally, developers use Infisical to prevent secrets leaks to git and securely share secrets amongst engineers. +## What is Infisical? + +[Infisical](https://infisical.com) is the [open source](https://github.com/Infisical/infisical), all-in-one platform for secrets, certificates, and privileged access management. + +It provides modern security workflows โ€” including secrets rotation, dynamic credentials, access approvals, and SSH certificate-based access โ€” all within one platform designed for developers, infrastructure, and security teams. Start managing secrets securely with [Infisical Cloud](https://app.infisical.com) or learn how to [host Infisical](/self-hosting/overview) yourself. - - - Get started with Infisical Cloud in just a few minutes. - - - Self-host Infisical on your own infrastructure. - - +## Why use Infisical? -## Why Infisical? +Managing secrets, credentials, and infrastructure access is a critical concern for engineering teams. As infrastructure scales and environments become more complex, [secrets start to sprawl](https://infisical.com/blog/what-is-secret-sprawl) โ€” across codebases, CI/CD pipelines, configuration files, and cloud services. This makes them difficult to track, rotate, and secure. -Infisical helps developers achieve secure centralized secret management and provides all the tools to easily manage secrets in various environments and infrastructure components. In particular, here are some of the most common points that developers mention after adopting Infisical: +Without proper management, secret sprawl turns into risk: hardcoded credentials, unrotated keys, fragmented access controls that attackers can exploit amongst other things. -- Streamlined **local development** processes (switching .env files to [Infisical CLI](/cli/commands/run) and removing secrets from developer machines). -- **Best-in-class developer experience** with an easy-to-use [Web Dashboard](/documentation/platform/project). -- Simple secret management inside **[CI/CD pipelines](/integrations/cicd/githubactions)** and staging environments. -- Secure and compliant secret management practices in **[production environments](/sdks/overview)**. -- **Facilitated workflows** around [secret change management](/documentation/platform/pr-workflows), [access requests](/documentation/platform/access-controls/access-requests), [temporary access provisioning](/documentation/platform/access-controls/temporary-access), and more. -- **Improved security posture** thanks to [secret scanning](/cli/scanning-overview), [granular access control policies](/documentation/platform/access-controls/overview), [automated secret rotation](https://infisical.com/docs/documentation/platform/secret-rotation/overview), and [dynamic secrets](/documentation/platform/dynamic-secrets/overview) capabilities. +Infisical addresses this challenge by providing an all-in-one platform and workflows to: -## How does Infisical work? +- Securely store and manage application secrets from development to production. +- Scan code and pipelines for exposed credentials. +- Automate X.509 certificate issuance and renewal. +- Manage SSH access using short-lived, policy-driven certificates. +- Encrypt and decrypt sensitive data with centralized key control. +- Audit every access, credential use, and change. -To make secret management effortless and secure, Infisical follows a certain structure for enabling secret management workflows as defined below. +Infisical is designed to integrate cleanly into your stackโ€”improving security without adding complexity. -**Identities** in Infisical are users or machine which have a certain set of roles and permissions assigned to them. Such identities are able to manage secrets in various **Clients** throughout the entire infrastructure. To do that, identities have to verify themselves through one of the available **Authentication Methods**. +## What does Infisical include? -As a result, the 3 main concepts that are important to understand are: +Infisical consists of several tightly integrated products, each designed to solve a specific part of the infrastructure security surface: -- **[Identities](/documentation/platform/identities/overview)**: users or machines with a set permissions assigned to them. -- **[Clients](/integrations/platforms/kubernetes)**: Infisical-developed tools for managing secrets in various infrastructure components (e.g., [Kubernetes Operator](/integrations/platforms/kubernetes), [Infisical Agent](/integrations/platforms/infisical-agent), [CLI](/cli/usage), [SDKs](/sdks/overview), [API](/api-reference/overview/introduction), [Web Dashboard](/documentation/platform/organization)). -- **[Authentication Methods](/documentation/platform/identities/universal-auth)**: ways for Identities to authenticate inside different clients (e.g., SAML SSO for Web Dashboard, Universal Auth for Infisical Agent, AWS Auth etc.). - -## How to get started with Infisical? - -Depending on your use case, it might be helpful to look into some of the resources and guides provided below. - - - - Inject secrets into any application process/environment. - - - Fetch secrets with any programming language on demand. - - - Inject secrets into Docker containers. - - - Fetch and save secrets as native Kubernetes secrets. - - - Fetch secrets via HTTP request. - - - Explore integrations for GitHub, Vercel, AWS, and more. - - +- [Secrets Management](/documentation/platform/secrets-mgmt/overview): Securely store, access, and distribute secrets across environments with fine-grained controls, automatic rotation, and audit logging. +- [Secrets Scanning](/documentation/platform/secret-scanning/overview): Detect hardcoded secrets in code, CI pipelines, and infrastructureโ€”integrated with GitHub, GitLab, Bitbucket, and more. +- [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. +- [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. +- [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. diff --git a/docs/documentation/getting-started/overview.mdx b/docs/documentation/getting-started/overview.mdx new file mode 100644 index 000000000..769990987 --- /dev/null +++ b/docs/documentation/getting-started/overview.mdx @@ -0,0 +1,77 @@ +--- +title: "Overview" +sidebarTitle: "Overview" +description: "The open source platform for managing secrets, certificates, and secure infrastructure access." +--- + + + Learn what Infisical is and how it can help you manage secrets, certificates, + and secure access across your infrastructure. + + +## Products + + + + Securely store, manage, and control access to sensitive application secrets across your environments. + + + + Automatically detect and alert on hardcoded secrets in source code, CI pipelines, and infrastructure. + + + Automate the issuance and management of X.509 certificates across your infrastructure using modern protocols like EST. + + + Replace static SSH keys with short-lived SSH certificates to simplify access and improve security. + + + + + + Encrypt and decrypt sensitive data using a centralized key management system. + + + +## Resources + + + + Explore Infisicalโ€™s command-line interface for managing secrets, + certificates, and system operations via terminal. + + + Browse Infisicalโ€™s API documentation to programmatically interact with + secrets, access controls, and certificate workflows. + + + + + Learn how to deploy and operate Infisical on your own infrastructure with full + control and data ownership. + + diff --git a/docs/documentation/platform/access-controls/access-requests.mdx b/docs/documentation/platform/access-controls/access-requests.mdx index cc0add8d8..cf2643db8 100644 --- a/docs/documentation/platform/access-controls/access-requests.mdx +++ b/docs/documentation/platform/access-controls/access-requests.mdx @@ -25,6 +25,11 @@ This functionality works in the following way: {/* ![Access Request Review](/images/platform/access-controls/review-access-request.png) */} ![Access Request Bypass](/images/platform/access-controls/access-request-bypass.png) + + Optionally, approvers can edit the duration of an access request to reduce how long access will be granted by clicking the **Edit** icon next to the duration. + ![Edit Access Request](/images/platform/access-controls/edit-access-request.png) + + If the access request matches with a policy that allows break-glass approval bypasses, the requester may bypass the policy and get access to the resource diff --git a/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx b/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx index 2ee4dff92..fab5f16b7 100644 --- a/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx +++ b/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx @@ -6,84 +6,133 @@ description: "Learn how to stream Infisical Audit Logs to external logging provi Audit log streams is a paid feature. - If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, - then you should contact team@infisical.com to purchase an enterprise license to use it. + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, then you should contact team@infisical.com to purchase an enterprise license to use it. -Infisical Audit Log Streaming enables you to transmit your organization's Audit Logs to external logging providers for monitoring and analysis. - -The logs are formatted in JSON, requiring your logging provider to support JSON-based log parsing. - +Infisical Audit Log Streaming enables you to transmit your organization's audit logs to external logging providers for monitoring and analysis. ## Overview - - - ![stream create](/images/platform/audit-log-streams/stream-create.png) - - - ![stream create](/images/platform/audit-log-streams/stream-inputs.png) + + 1. Navigate to **Organization Settings** + 2. Select the **Audit Log Streams** tab + 3. Click **Add Log Stream** - Provide the following values - - The HTTPS endpoint URL of the logging provider that collects the JSON stream. - - - The HTTP headers for the logging provider for identification and authentication. - + ![stream create](/images/platform/audit-log-streams/stream-create.png) + + + If your log provider is included in this list, select it. Otherwise click on **Custom** to input your own Endpoint URL and headers. + + ![select provider](/images/platform/audit-log-streams/select-provider.png) + + + Depending on your chosen provider, you'll be asked to input different credentials. + + For **Custom**, you need to input an endpoint URL and headers. + + ![custom provider](/images/platform/audit-log-streams/custom-provider.png) + + Once you're finished, click **Create Log Stream**. + + + Your audit logs are now ready to be streamed. + + ![stream list](/images/platform/audit-log-streams/stream-list.png) -![stream listt](/images/platform/audit-log-streams/stream-list.png) -Your Audit Logs are now ready to be streamed. - ## Example Providers -### Better Stack + + + You can stream to Better Stack using a **Custom** log stream. - - - ![better stack connect source](/images/platform/audit-log-streams/betterstack-create-source.png) - - - - ![better stack connect](/images/platform/audit-log-streams/betterstack-source-details.png) + + + On Better Stack, select **Connect Source** and click **Create source** after providing a name. - 1. Copy the **endpoint** from Better Stack to the **Endpoint URL** field. - 3. Create a new header with key **Authorization** and set the value as **Bearer \**. - - + ![better stack connect source](/images/platform/audit-log-streams/betterstack-create-source.png) -### Datadog + Once your source is created, take note of the **endpoint** and **Source token** for the next step. - - - ![api key create](/images/platform/audit-log-streams/datadog-api-sidebar.png) - - - ![api key form](/images/platform/audit-log-streams/data-create-api-key.png) - ![api key form](/images/platform/audit-log-streams/data-dog-api-key.png) - - - ![datadog url](/images/platform/audit-log-streams/datadog-logging-endpoint.png) + ![better stack connect](/images/platform/audit-log-streams/betterstack-source-details.png) + + + On Infisical, create a new audit log stream and select the **Custom** option. - 1. Navigate to the [Datadog Send Logs API documentation](https://docs.datadoghq.com/api/latest/logs/?code-lang=curl&site=us5#send-logs). - 2. Pick your Datadog account region. - 3. Obtain your Datadog logging endpoint URL. - - - ![datadog api key details](/images/platform/audit-log-streams/datadog-source-details.png) + ![select custom](/images/platform/audit-log-streams/select-custom.png) - 1. Copy the **logging endpoint** from Datadog to the **Endpoint URL** field. - 2. Copy the **API Key** from previous step - 3. Create a new header with key **DD-API-KEY** and set the value as **API Key**. - - + 1. Fill in the endpoint URL with your Better Stack source endpoint + 2. Create a new header with key `Authorization` and set the value as `Bearer ` -## Audit Log Stream Data + ![custom provider](/images/platform/audit-log-streams/custom-provider.png) -Each log entry sent to the external logging provider will follow the same structure. + Once you're finished, click **Create Log Stream**. + + + + + You can stream to Datadog using the **Datadog** provider log stream. + + + + ![api key create](/images/platform/audit-log-streams/datadog-api-sidebar.png) + + + ![api key form](/images/platform/audit-log-streams/data-create-api-key.png) + ![api key form](/images/platform/audit-log-streams/data-dog-api-key.png) + + + On Infisical, create a new audit log stream and select the **Datadog** provider option. + + Input your **Datadog Region** and the **Token** obtained from step 2. + + ![datadog details](/images/platform/audit-log-streams/datadog-details.png) + + Once you're finished, click **Create Log Stream**. + + + + + You can stream to Splunk using the **Splunk** provider log stream. + + + + Navigate to **Settings** > **Data Inputs**. + + ![splunk data inputs](/images/platform/audit-log-streams/splunk-data-inputs.png) + + Click on **HTTP Event Collector**. + + ![splunk http collector](/images/platform/audit-log-streams/splunk-http-collector.png) + + Click on **New Token** in the top left. + + ![splunk new token](/images/platform/audit-log-streams/splunk-new-token.png) + + Provide a name and click **Next**. + + ![splunk name](/images/platform/audit-log-streams/splunk-name.png) + + On the next page, click **Review** and then **Submit** at the top. On the final page you'll see your token. + + Copy the **Token Value** and your Splunk hostname from the URL to be used for later. + + ![splunk credentials](/images/platform/audit-log-streams/splunk-credentials.png) + + + On Infisical, create a new audit log stream and select the **Splunk** provider option. + + Input your **Splunk Hostname** and the **Token** obtained from step 1. + + ![splunk details](/images/platform/audit-log-streams/splunk-details.png) + + Once you're finished, click **Create Log Stream**. + + + + ### Example Log Entry @@ -117,106 +166,109 @@ Each log entry sent to the external logging provider will follow the same struct ``` ### Audit Logs Structure + + + Streamed audit log structure **varies based on provider**, but they all share the audit log fields shown below. + + - The unique identifier for the log entry. + The unique identifier for the log entry. - The entity responsible for performing or causing the event; this can be a user or service. + The entity responsible for performing or causing the event; this can be a user or service. - The metadata associated with the actor. This varies based on the actor type. + The metadata associated with the actor. This varies based on the actor type. - - This metadata is present when the `actor` field is set to `user`. + + + This metadata is present when the `actor` field is set to `user`. - - The unique identifier for the actor. - - - The email address of the actor. - - - The username of the actor. - - + + The unique identifier for the actor. + + + The email address of the actor. + + + The username of the actor. + + + + This metadata is present when the `actor` field is set to `identity`. - - This metadata is present when the `actor` field is set to `identity`. + + The unique identifier for the identity. + + + The name of the identity. + + + + This metadata is present when the `actor` field is set to `service`. - - The unique identifier for the identity. - - - The name of the identity. - - - - - This metadata is present when the `actor` field is set to `service`. - - - The unique identifier for the service. - - - The name of the service. - - - - - - If the `actor` field is set to `platform`, `scimClient`, or `unknownUser`, the `actorMetadata` field will be an empty object. - + + The unique identifier for the service. + + + The name of the service. + + + + + If the `actor` field is set to `platform`, `scimClient`, or `unknownUser`, the `actorMetadata` field will be an empty object. + - The IP address of the actor. + The IP address of the actor. - The type of event that occurred. Below you can see a list of possible event types. More event types will be added in the future as we expand our audit logs further. + The type of event that occurred. Below you can see a list of possible event types. More event types will be added in the future as we expand our audit logs further. - `get-secrets`, `delete-secrets`, `get-secret`, `create-secret`, `update-secret`, `delete-secret`, `get-workspace-key`, `authorize-integration`, `update-integration-auth`, `unauthorize-integration`, `create-integration`, `delete-integration`, `add-trusted-ip`, `update-trusted-ip`, `delete-trusted-ip`, `create-service-token`, `delete-service-token`, `create-identity`, `update-identity`, `delete-identity`, `login-identity-universal-auth`, `add-identity-universal-auth`, `update-identity-universal-auth`, `get-identity-universal-auth`, `create-identity-universal-auth-client-secret`, `revoke-identity-universal-auth-client-secret`, `get-identity-universal-auth-client-secret`, `create-environment`, `update-environment`, `delete-environment`, `add-workspace-member`, `remove-workspace-member`, `create-folder`, `update-folder`, `delete-folder`, `create-webhook`, `update-webhook-status`, `delete-webhook`, `webhook-triggered`, `get-secret-imports`, `create-secret-import`, `update-secret-import`, `delete-secret-import`, `update-user-workspace-role`, `update-user-workspace-denied-permissions`, `create-certificate-authority`, `get-certificate-authority`, `update-certificate-authority`, `delete-certificate-authority`, `get-certificate-authority-csr`, `get-certificate-authority-cert`, `sign-intermediate`, `import-certificate-authority-cert`, `get-certificate-authority-crl`, `issue-cert`, `get-cert`, `delete-cert`, `revoke-cert`, `get-cert-body`, `create-pki-alert`, `get-pki-alert`, `update-pki-alert`, `delete-pki-alert`, `create-pki-collection`, `get-pki-collection`, `update-pki-collection`, `delete-pki-collection`, `get-pki-collection-items`, `add-pki-collection-item`, `delete-pki-collection-item`, `org-admin-accessed-project`, `create-certificate-template`, `update-certificate-template`, `delete-certificate-template`, `get-certificate-template`, `create-certificate-template-est-config`, `update-certificate-template-est-config`, `get-certificate-template-est-config`, `update-project-slack-config`, `get-project-slack-config`, `integration-synced`, `create-shared-secret`, `delete-shared-secret`, `read-shared-secret`. + `get-secrets`, `delete-secrets`, `get-secret`, `create-secret`, `update-secret`, `delete-secret`, `get-workspace-key`, `authorize-integration`, `update-integration-auth`, `unauthorize-integration`, `create-integration`, `delete-integration`, `add-trusted-ip`, `update-trusted-ip`, `delete-trusted-ip`, `create-service-token`, `delete-service-token`, `create-identity`, `update-identity`, `delete-identity`, `login-identity-universal-auth`, `add-identity-universal-auth`, `update-identity-universal-auth`, `get-identity-universal-auth`, `create-identity-universal-auth-client-secret`, `revoke-identity-universal-auth-client-secret`, `get-identity-universal-auth-client-secret`, `create-environment`, `update-environment`, `delete-environment`, `add-workspace-member`, `remove-workspace-member`, `create-folder`, `update-folder`, `delete-folder`, `create-webhook`, `update-webhook-status`, `delete-webhook`, `webhook-triggered`, `get-secret-imports`, `create-secret-import`, `update-secret-import`, `delete-secret-import`, `update-user-workspace-role`, `update-user-workspace-denied-permissions`, `create-certificate-authority`, `get-certificate-authority`, `update-certificate-authority`, `delete-certificate-authority`, `get-certificate-authority-csr`, `get-certificate-authority-cert`, `sign-intermediate`, `import-certificate-authority-cert`, `get-certificate-authority-crl`, `issue-cert`, `get-cert`, `delete-cert`, `revoke-cert`, `get-cert-body`, `create-pki-alert`, `get-pki-alert`, `update-pki-alert`, `delete-pki-alert`, `create-pki-collection`, `get-pki-collection`, `update-pki-collection`, `delete-pki-collection`, `get-pki-collection-items`, `add-pki-collection-item`, `delete-pki-collection-item`, `org-admin-accessed-project`, `create-certificate-template`, `update-certificate-template`, `delete-certificate-template`, `get-certificate-template`, `create-certificate-template-est-config`, `update-certificate-template-est-config`, `get-certificate-template-est-config`, `update-project-slack-config`, `get-project-slack-config`, `integration-synced`, `create-shared-secret`, `delete-shared-secret`, `read-shared-secret`. - The metadata associated with the event. This varies based on the event type. + The metadata associated with the event. This varies based on the event type. - The user agent of the actor, if applicable. + The user agent of the actor, if applicable. - The type of user agent. + The type of user agent. - The expiration date of the log entry. When this date is reached, the log entry will be deleted from Infisical. + The expiration date of the log entry. When this date is reached, the log entry will be deleted from Infisical. - The creation date of the log entry. + The creation date of the log entry. - The last update date of the log entry. This is unlikely to be out of sync with the `createdAt` field, as we do not update log entries after they've been created. + The last update date of the log entry. This is unlikely to be out of sync with the `createdAt` field, as we do not update log entries after they've been created. - The unique identifier for the organization where the event occurred. + The unique identifier for the organization where the event occurred. - The unique identifier for the project where the event occurred. + The unique identifier for the project where the event occurred. - The `projectId` field will only be present if the event occurred at the project level, not the organization level. + The `projectId` field will only be present if the event occurred at the project level, not the organization level. - The name of the project where the event occurred. + The name of the project where the event occurred. - The `projectName` field will only be present if the event occurred at the project level, not the organization level. + The `projectName` field will only be present if the event occurred at the project level, not the organization level. 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/event-subscriptions.mdx b/docs/documentation/platform/event-subscriptions.mdx new file mode 100644 index 000000000..ca6ceebd1 --- /dev/null +++ b/docs/documentation/platform/event-subscriptions.mdx @@ -0,0 +1,118 @@ +--- +title: "Event Subscriptions" +sidebarTitle: "Events" +description: "Subscribe to events in Infisical for real-time updates" +--- + + + **Note:** Event Subscriptions is a paid feature. - **Infisical Cloud users:** Event Subscriptions is available under + the **Enterprise Tier**. - **Self-Hosted Infisical:** Please contact [sales@infisical.com](mailto:sales@infisical.com) + to purchase an enterprise license. + + +Event Subscriptions in Infisical allow you to receive real-time notifications when specific actions occur within your account or organization. These notifications include changes to secrets, users, teams, and many more **coming soon**. + +## How It Works + +- Server receives message over pubsub connection indicating changes have occurred +- Server processes the change notification +- Updated data is synchronized across all connected Infisical instances +- Client applications receive real-time updates through [Server-Sent Events (SSE)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) +- All servers maintain consistent state without manual intervention + +This ensures your infrastructure stays up-to-date automatically, without requiring restarts or manual synchronization. + + + Event Subscriptions are designed for real-time communication and do not include persistence or replay + capabilitiesโ€”events are delivered once and are not stored for future retrieval. + + +## Supported Resources + +You can currently subscribe to notifications for the following resources and event types: + +- **Secrets** + - `secret:created`: Triggered when a secret is created + - `secret:updated`: Triggered when a secret is updated + - `secret:deleted`: Triggered when a secret is deleted + +## Permissions Setup + +To receive events on a supported resource, the identity must have `Subscribe` action permission on that resource. + +Follow these steps to set up the necessary permissions: + + + + ![Select Project](/images/platform/events/select-project.png) + +On your project page, open **Project Settings** from the sidebar. + +In the Project name section, click **Copy Project ID** to copy your Project ID, or extract it from the URL: +`https://app.infisical.com/project//settings` + + + + + ![Project Detail](/images/platform/events/project-detail.png) ![Project + Access](/images/platform/events/project-access.png) Navigate to **Access Management**, then select **Project Roles**. + + + + ![Project Role](/images/platform/events/project-role.png) You can either edit an existing role or create a new role + for event subscriptions. + + + + ![Role Detail](/images/platform/events/role-detail.png) Select the specific resources that the role should have access + to. ![Add policy](/images/platform/events/add-policy.png) + + + + ![Policy setting](/images/platform/events/policy-setting.png) + + Ensure the **Subscribe** action is selected for the relevant resources and events. + + ## Conditions + + By default, the role will have access to all events for the selected resources in this project. + + + + ![Policy setting](/images/platform/events/access-full.png) + + + ![Policy setting](/images/platform/events/access-path.png) + + + ![Policy setting](/images/platform/events/access-dev.png) + + + + + +## Getting Started + +Currently, events are only available via [API](/api-reference/endpoints/events) but will soon be available in our SDKs, Kubernetes Operator, and more. + +### API Usage + +You need an auth token to use this API. To get an authentication token, follow the authentication guide for one of our supported auth methods from the [machine identities documentation](/documentation/platform/identities/machine-identities#authentication-methods). + +#### Creating a Subscription + +![Postman Subscription](/images/platform/events/postman-subscribe.png) + +**Request Parameters:** + +- `projectId`: Project whose events you want to subscribe to +- `register`: List of event filters + - `conditions`: Conditions to filter events on + - `environmentSlug`: Project environment + - `secretPath`: Path of the secrets + +![Postman Subscription Response](/images/platform/events/postman-sse-response.png) + +The subscribe endpoint responds with a `text/event-stream` content type to initiate SSE streaming. + +For more specific details, please refer to our [API Reference](/api-reference/endpoints/events). diff --git a/docs/documentation/platform/external-migrations/vault.mdx b/docs/documentation/platform/external-migrations/vault.mdx index ef139e8e4..8254e104f 100644 --- a/docs/documentation/platform/external-migrations/vault.mdx +++ b/docs/documentation/platform/external-migrations/vault.mdx @@ -8,12 +8,12 @@ description: "Learn how to migrate secrets from Vault to Infisical." Migrating from Vault Self-Hosted or Dedicated Vault is a straight forward process with our inbuilt migration option. In order to migrate from Vault, you'll need to provide Infisical an access token to your Vault instance. -Currently the Vault migration only supports migrating secrets from the KV v2 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). +Currently the Vault migration only supports migrating secrets from the KV V2 and V1 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). ### Prerequisites -- A Vault instance with the KV v2 secrets engine enabled. +- A Vault instance with the KV secret engine enabled. - An access token to your Vault instance. diff --git a/docs/documentation/platform/github-org-sync.mdx b/docs/documentation/platform/github-org-sync.mdx index 519e12db8..71dd9dbf6 100644 --- a/docs/documentation/platform/github-org-sync.mdx +++ b/docs/documentation/platform/github-org-sync.mdx @@ -45,6 +45,64 @@ Once configured, the GitHub Organization Synchronization feature functions as fo When a user logs in via the GitHub OAuth flow and selects the configured organization, the system will then automatically synchronize the teams they are a part of in GitHub with corresponding groups in Infisical. +## Manual Team Sync + +You can manually synchronize GitHub teams for all organization members who have previously logged in with GitHub. This bulk sync operation updates team memberships without requiring users to log in again. + + + + To perform manual syncs, you'll need to create a GitHub Personal Access Token with the appropriate permissions. GitHub offers two types of tokens: + + + + 1. Go to [GitHub Settings โ†’ Personal Access Tokens โ†’ Tokens (classic)](https://github.com/settings/tokens) + 2. Click **Generate new token** โ†’ **Generate new token (classic)** + 3. Give your token a descriptive name (e.g., "Infisical GitHub Sync") + 4. Set an appropriate expiration date + 5. Select the **read:org** scope - Required to read organization team information + 6. Click **Generate token** + 7. Copy the token immediately (you won't be able to see it again) + + ![Classic Token Creation](../../images/platform/external-syncs/github-classic-token.png) + + + 1. Go to [GitHub Settings โ†’ Personal Access Tokens โ†’ Fine-grained tokens](https://github.com/settings/personal-access-tokens/new) + 2. Click **Generate new token** + 3. Give your token a descriptive name (e.g., "Infisical GitHub Sync") + 4. Set an appropriate expiration date + 5. Select your organization under **Resource owner** + 6. Under **Organization permissions**, set **Members** to **Read** + 7. Click **Generate token** + 8. Copy the token immediately (you won't be able to see it again) + + ![Fine-grained Token Creation](../../images/platform/external-syncs/github-fine-grained-token.png) + + + + + + 1. Navigate to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. + 2. Click the **Configure** button next to your GitHub Organization configuration. + 3. In the configuration modal, you'll find an optional **GitHub Access Token** field. + 4. Paste the token you generated in the previous step. + 5. Click **Update** to save the configuration. + + ![Token Configuration Modal](../../images/platform/external-syncs/github-token-config-modal.png) + + + + Once you have configured the GitHub access token: + + 1. Navigate to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. + 2. You'll see a **Sync Now** section with a button to trigger the manual sync. + 3. Click **Sync Now** to synchronize GitHub teams for all organization members. + + ![Manual Sync Button](../../images/platform/external-syncs/github-manual-sync-button.png) + + The sync operation will process all organization members who have previously logged in with GitHub and update their team memberships accordingly. + + + ## Troubleshooting diff --git a/docs/documentation/platform/identities/auth-templates.mdx b/docs/documentation/platform/identities/auth-templates.mdx new file mode 100644 index 000000000..5c389376f --- /dev/null +++ b/docs/documentation/platform/identities/auth-templates.mdx @@ -0,0 +1,96 @@ +--- +title: "Machine Identity Auth Templates" +description: "Learn how to use auth templates to standardize authentication configurations for machine identities." +--- + +## Concept + +Machine Identity Auth Templates allow you to create reusable authentication configurations that can be applied across multiple machine identities. This feature helps standardize authentication setups, reduces configuration drift, and simplifies identity management at scale. + +Instead of manually configuring authentication settings for each identity, you can create templates with predefined authentication parameters and apply them to multiple identities. This ensures consistency and reduces the likelihood of configuration errors. + +Key Benefits: + +- **Standardization**: Ensure consistent authentication configurations across identities +- **Efficiency**: Reduce time spent configuring individual identities +- **Governance**: Centrally manage and update authentication parameters +- **Scalability**: Easily apply proven configurations to new identities + +## Managing Auth Templates + +Auth templates are managed in **Organization Settings > Access Control > Identities** under the **Identity Auth Templates** section. + +![Identity Auth Templates Section](/images/platform/identities/auth-templates/templates-section.png) + +### Creating a Template + + + + In your organization settings, go to **Access Control > Identities** and scroll down to the **Identity Auth Templates** section. + + + + Click **Create Template** to open the template creation modal. + + ![Create Template Button](/images/platform/identities/auth-templates/create-template-button.png) + + Select the authentication method you want to create a template for (currently supports LDAP Auth). + + + + Fill in the template configuration based on your chosen authentication method. + + + + **For LDAP Auth templates**, configure the following fields: + + ![LDAP Auth Template](/images/platform/identities/auth-templates/ldap-template.png) + + - **Template Name**: A descriptive name for your template + - **URL**: The LDAP server to connect to such as `ldap://ldap.your-org.com`, `ldaps://ldap.myorg.com:636` _(for connection over SSL/TLS)_, etc. + - **Bind DN**: The DN to bind to the LDAP server with. + - **Bind Pass**: The password to bind to the LDAP server with. + - **Search Base / DN**: Base DN under which to perform user search such as `ou=Users,dc=acme,dc=com`. + - **CA Certificate**: The CA certificate to use when verifying the LDAP server certificate. This field is optional but recommended. + + + You can read more about LDAP Auth configuration in the [LDAP Auth documentation](/documentation/platform/identities/ldap-auth/general). + + + + + + +### Using Templates + +Once created, templates can be applied when configuring authentication methods for machine identities. When adding an auth method to an identity, you'll have the option to select from available templates or configure manually. + +![Attach Template](/images/platform/identities/auth-templates/machine-identity-page.png) +![Attach Template Form](/images/platform/identities/auth-templates/attach-template-form.png) + +### Managing Template Usage + +You can view which identities are using a specific template by clicking **View Usages** in the template's dropdown menu. + +![Template Usages](/images/platform/identities/auth-templates/template-usages.png) +![Template Usages Modal](/images/platform/identities/auth-templates/template-usages-modal.png) + +## FAQ + + + + Yes, you can edit existing templates. After editing a template, changes to templates will automatically update identities that are already using them. + + + + If you delete a template that's currently being used by identities, those identities will continue to function with their existing configuration. However, the link to the template will be broken, and you won't be able to use the template for new identities. + + + + Yes, click **View Usages** in the template's dropdown menu to see all identities currently using that template. + + + + Currently, auth templates support LDAP Auth. Support for additional authentication methods will be added in future releases. + + \ No newline at end of file diff --git a/docs/documentation/platform/identities/ldap-auth/general.mdx b/docs/documentation/platform/identities/ldap-auth/general.mdx index 7fb2798c7..01395b68c 100644 --- a/docs/documentation/platform/identities/ldap-auth/general.mdx +++ b/docs/documentation/platform/identities/ldap-auth/general.mdx @@ -5,6 +5,12 @@ description: "Learn how to authenticate with Infisical using LDAP." **LDAP Auth** is an LDAP based authentication method that allows you to authenticate with Infisical using a machine identity configured with an [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) directory. +## Templates + +You can create reusable LDAP authentication templates to standardize configurations across multiple machine identities. Templates help ensure consistency, reduce configuration errors, and simplify identity management at scale. + +To create and manage LDAP auth templates, see our [Machine Identity Auth Templates documentation](/documentation/platform/identities/auth-templates). Once you've created a template, you can apply it when configuring LDAP auth for your identities in the guide below. + ## Guide diff --git a/docs/documentation/platform/identities/user-identities.mdx b/docs/documentation/platform/identities/user-identities.mdx index bcb470a3e..31e4bf242 100644 --- a/docs/documentation/platform/identities/user-identities.mdx +++ b/docs/documentation/platform/identities/user-identities.mdx @@ -5,18 +5,19 @@ description: "Read more about the concept of user identities in Infisical." ## Concept -A **user identity** (also known as **user**) represents a developer, admin, or any other human entity interacting with resources in Infisical. +A **user identity** (also known as **user**) represents a developer, admin, or any other human entity interacting with resources in Infisical. -Users can be added manually (through Web UI) or programmatically (e.g., API) to [organizations](../organization) and [projects](../projects). +Users can be added manually (through Web UI) or programmatically (e.g., API) to [organizations](../organization) and [projects](../projects). -Upon being added to an organization and projects, users assume a certain set of roles and permissions that represents their identity. +Upon being added to an organization and projects, users assume a certain set of roles and permissions that represents their identity. -![organization members](../../../images/platform/organization/organization-members.png) +![organization users](/images/platform/organization/organization-users.png) ## Authentication methods -To interact with various resources in Infisical, users are able to utilize a number of authentication methods: -- **Email & Password**: the most common authentication method that is used for authentication into Web Dashboard and Infisical CLI. It is recommended to utilize [Multi-factor Authentication](/documentation/platform/mfa) in addition to it. -- **SSO**: Infisical natively integrates with a number of SSO identity providers like [Google](/documentation/platform/sso/google), [GitHub](/documentation/platform/sso/github), and [GitLab](/documentation/platform/sso/gitlab). -- **SAML SSO**: It is also possible to set up SAML SSO integration with identity providers like [Okta](/documentation/platform/sso/okta), [Microsoft Entra ID](/documentation/platform/sso/azure) (formerly known as Azure AD), [JumpCloud](/documentation/platform/sso/jumpcloud), [Google](/documentation/platform/sso/google-saml), and more. +To interact with various resources in Infisical, users are able to utilize a number of authentication methods: + +- **Email & Password**: the most common authentication method that is used for authentication into Web Dashboard and Infisical CLI. It is recommended to utilize [Multi-factor Authentication](/documentation/platform/mfa) in addition to it. +- **SSO**: Infisical natively integrates with a number of SSO identity providers like [Google](/documentation/platform/sso/google), [GitHub](/documentation/platform/sso/github), and [GitLab](/documentation/platform/sso/gitlab). +- **SAML SSO**: It is also possible to set up SAML SSO integration with identity providers like [Okta](/documentation/platform/sso/okta), [Microsoft Entra ID](/documentation/platform/sso/azure) (formerly known as Azure AD), [JumpCloud](/documentation/platform/sso/jumpcloud), [Google](/documentation/platform/sso/google-saml), and more. - **LDAP**: For organizations with more advanced needs, Infisical also provides user authentication with [LDAP](/documentation/platform/ldap/overview) that includes a number of LDAP providers. diff --git a/docs/documentation/platform/organization.mdx b/docs/documentation/platform/organization.mdx index 6c3b218ab..aea842d3f 100644 --- a/docs/documentation/platform/organization.mdx +++ b/docs/documentation/platform/organization.mdx @@ -3,74 +3,94 @@ title: "Organizations" description: "Learn more and understand the concept of Infisical organizations." --- -An Infisical organization is a set of [projects](./project) that use the same billing. Organizations allow one or more users to control billing and project permissions for all of the projects belonging to the organization. Each project belongs to an organization. +Infisical is structured around organizations and [projects](/documentation/platform/project). + +## Organizations + +An organization represents a company or high-level entity (e.g. Acme Corp) and acts as the root scope for managing members and machine identities, projects, usage and billing, global integrations and configuration (such as single sign-on, provisioning, etc), and more. + +Within an organization, you can create any number of projectsโ€”each tied to a specific product type such as Secrets Management or PKI that determines the functionality available. + +![organization](/images/platform/organization/organization.png) ## Projects -The **Projects** page is where you can view the projects that you have access to within your organization -as well as create a new project. +The _Projects_ tab shows a list of projects that you have access to. -![organization](../../images/platform/organization/organization-projects.png) +If you're an organization admin, you also have the option to view _All Projects_โ€”a complete view of every project within the organization, including those you are not currently a member ofโ€” and gain access to any project. -## Settings +Admins can gain access to any project in the organization by opening the options menu (โ‹ฎ) next to a project and selecting Access. This will add you to the project as an admin and allow full visibility and control. -The **Settings** page lets you manage information about your organization including: +![organization projects](/images/platform/organization/organization-projects.png) -- **Name**: The name of your organization. -- **Slug**: The slug of your organization. -- **Default Organization Member Role**: The role assigned to users when joining your organization unless otherwise specified. -- **Incident Contacts**: Emails that should be alerted if anything abnormal is detected within the organization. -- **Enabled Products**: Products which are enabled for your organization. This setting strictly affects the sidebar UI; disabling a product does not disable its API or routes. +## Roles and Access Control -![organization settings general](../../images/platform/organization/organization-settings-general.png) +The _Access Control_ tab lets you view and manage roles and permissions for users, machine identities, and groups across your organization. -- Security and Authentication: A set of setting to enforce or manage [SAML](/documentation/platform/sso/overview), [OIDC](/documentation/platform/sso/overview), [SCIM](/documentation/platform/scim/overview), [LDAP](/documentation/platform/ldap/overview), and other authentication configurations. +Users are invited to an organization and assigned organization-level roles such as `Admin` or `Member`. You can also define [custom roles](/documentation/platform/access-controls/role-based-access-controls#creating-custom-roles) at the organization level to fit your permission model. -![organization settings auth](../../images/platform/organization/organization-settings-auth.png) +![organization users](/images/platform/organization/organization-users.png) - - You can adjust the maximum time a user token will remain valid for your organization. After this period, users will be required to re-authenticate. This helps improve security by enforcing regular sign-ins. - +Infisical supports [user identities](/documentation/platform/identities/user-identities) (representing people) and [machine identities](/documentation/platform/identities/machine-identities) (representing services, CI/CD pipelines, or agents). The same roles and permissions can be applied to either type of identity. -## Access Control +To manage access at scale, Infisical also supports [user groups](/documentation/platform/groups) โ€” roles assigned to a group apply to all of its members automatically. -The **Access Control** page is where you can manage identities (both people and machines) that are part of your organization. -You can add or remove additional members as well as modify their permissions. +Note that Infisical distinguishes between organization-level and project-level access control: -![organization members](../../images/platform/organization/organization-members.png) -![organization identities](../../images/platform/organization/organization-machine-identities.png) +- [Organization-level access control](/documentation/platform/access-controls/role-based-access-controls#organization-level-access-controls): Roles and permissions governing access to organization-level resources and controls such as billing, member management, and identity provider configuration. +- [Project-level access control](/documentation/platform/access-controls/role-based-access-controls#project-level-access-controls): Roles and permissions governing access to resources and workflows within a specific project (e.g., secrets, certificates, SSH hosts). -In the **Organization Roles** tab, you can edit current or create new custom roles for members within the organization. +![organization roles](/images/platform/organization/organization-roles.png) + +To learn more about how permissions work in detail, refer to the [access control documentation](/documentation/platform/access-controls/overview). - Note that Role-Based Access Management (RBAC) is partly a paid feature. - - Infisical provides immutable roles like `admin`, `member`, etc. - at the organization and project level for free. +Infisical provides immutable roles such as `admin` and `member` for free. If you're using Infisical Cloud, the ability to create custom roles is available under the **Pro Tier**. + If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. -![organization roles](../../images/platform/organization/organization-members-roles.png) - -As you can see next, Infisical supports granular permissions that you can tailor to each role. -If you need certain members to only be able to access billing details, for example, then you can -assign them that permission only. - -![organization role permissions](../../images/platform/organization/organization-members-roles-add-perm.png) - ## Usage & Billing -The **Usage & Billing** page applies only to [Infisical Cloud](https://app.infisical.com) and is where you can -manage your plan and billing information. +The _Usage & Billing_ tab provides an overview of your organization's billing information and platform usage. -This includes the following items: +Infisical calculates usage at the organization levelโ€”aggregating activity across all projects and product types (e.g., Secrets Management, SSH, PKI). From this tab, you can track usage, view billing details, and manage your Infisical Cloud subscription. -- Current plan: The current plan information such as what tier your organization is on and what features/limits apply to this tier. -- Licenses: The license keys for self-hosted instances of Infisical (if applicable). -- Receipts: The receipts of monthly/annual invoices. -- Billing: The billing details of your organization including payment methods on file, tax IDs (if applicable), etc. +![organization billing](/images/platform/organization/organization-billing.png) -![organization usage and billing](../../images/platform/organization/organization-usage-billing.png) +## Audit Logs + +Infisical provides a unified view of [audit logs](/documentation/platform/audit-logs) at the organization level. All platform activityโ€”including secret access, certificate issuance, platform logins across the organization โ€”is recorded and searchable in a central log view. + +Audit logs are also viewable at the project level, where they are scoped to show only events relevant to that specific project. This allows project administrators to monitor activity and investigate changes without requiring organization-wide access. + +## App Connections + +Infisical supports [app connections](/integrations/app-connections/overview) โ€” integrations configured at the organization level with third-party platforms such as AWS, GCP, GitHub, and many others. + +Once configured, these connections can be reused across multiple projects as part of any feature that requires third-party integrationsโ€”such as [secret syncing](/integrations/secret-syncs/overview) or [dynamic credential generation](/documentation/platform/dynamic-secrets/overview). + +![organization app connections](/images/platform/organization/organization-app-connections.png) + +To learn more, refer to the [app connections documentation](/integrations/app-connections/overview). + +## Organization Settings + +The _Organization Settings_ tab lets you configure global behavior and security controls for the organization. + +Key configuration areas include: + +- General: Manage the organizationโ€™s name, slug, and default role for newly invited members. +- Single Sign-On (SSO): Enable [SAML](/documentation/platform/sso/overview), [LDAP](/documentation/platform/ldap/overview), or [OIDC-based](/documentation/platform/sso/general-oidc/overview) authentication for user login. +- Provisioning: Enable [SCIM](/documentation/platform/scim/overview) to automatically provision and deprovision users and groups from an identity provider. +- Security Policies: Enforce MFA and configure session duration limits. +- Encryption: Integrate with external KMS systems or bring your own encryption keys (BYOK). +- [Audit Log Streaming](/documentation/platform/audit-log-streams/audit-log-streams): Forward audit events to third-party logging tools like SIEMs or cloud storage. +- Workflow Integrations: Trigger [Slack](/documentation/platform/workflow-integrations/slack-integration) or [Microsoft Teams](/documentation/platform/workflow-integrations/microsoft-teams-integration) notifications for events like access requests. +- [Project Templates](/documentation/platform/project-templates): Define default environments, roles, and settings to standardize project creation. +- KMIP (Enterprise): Connect to KMIP-compatible HSMs for hardware-backed key storage and operations. + +![organization settings](/images/platform/organization/organization-settings.png) diff --git a/docs/documentation/platform/pki/acme-ca.mdx b/docs/documentation/platform/pki/acme-ca.mdx index 495d20917..bf130da9e 100644 --- a/docs/documentation/platform/pki/acme-ca.mdx +++ b/docs/documentation/platform/pki/acme-ca.mdx @@ -14,7 +14,7 @@ ACME is a protocol that automates the process of certificate issuance and renewa ```mermaid graph TD A[ACME CA Provider
e.g., Let's Encrypt] <-->|ACME v2 Protocol| B[Infisical] - B -->|Creates TXT Records
via Route53| C[DNS Validation] + B -->|Creates TXT Records
via Route53/Cloudflare| C[DNS Validation] B -->|Manages Certificates| D[Subscribers] ``` @@ -28,8 +28,8 @@ We recommend reading about [ACME protocol](https://tools.ietf.org/html/rfc8555) A typical workflow for using Infisical with ACME Certificate Authorities consists of the following steps: -1. Setting up AWS Route53 credentials with appropriate DNS permissions. -2. Creating an AWS connection in Infisical to store the Route53 credentials. +1. Setting up AWS Route53 or Cloudflare credentials with appropriate DNS permissions. +2. Creating an AWS/Cloudflare connection in Infisical to store the credentials. 3. Registering an ACME Certificate Authority (like Let's Encrypt) with Infisical. 4. Creating subscribers that use the ACME CA as their issuing authority. 5. Managing certificate lifecycle events such as issuance, renewal, and revocation through Infisical. @@ -55,59 +55,75 @@ This automated process eliminates the need for manual intervention in domain val In the following steps, we explore how to set up ACME Certificate Authority integration with Infisical using Let's Encrypt as an example. - - Before proceeding with the ACME CA registration, you need to set up an AWS connection with the appropriate permissions for DNS validation: + + Before proceeding with the ACME CA registration, you need to set up an App Connection with the appropriate permissions for DNS validation: - 1. Navigate to your Organization Settings > App Connections and create a new AWS connection. + + + 1. Navigate to your Organization Settings > App Connections and create a new AWS connection. - 2. Ensure your AWS connection has the following minimum permissions for Route53 DNS validation: + 2. Ensure your AWS connection has the following minimum permissions for Route53 DNS validation: - ```json - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": "route53:GetChange", - "Resource": "arn:aws:route53:::change/*" - }, - { - "Effect": "Allow", - "Action": "route53:ListHostedZonesByName", - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "route53:ListResourceRecordSets" - ], - "Resource": [ - "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" - ] - }, - { - "Effect": "Allow", - "Action": [ - "route53:ChangeResourceRecordSets" - ], - "Resource": [ - "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" - ], - "Condition": { - "ForAllValues:StringEquals": { - "route53:ChangeResourceRecordSetsRecordTypes": [ - "TXT" + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "route53:GetChange", + "Resource": "arn:aws:route53:::change/*" + }, + { + "Effect": "Allow", + "Action": "route53:ListHostedZonesByName", + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "route53:ListResourceRecordSets" + ], + "Resource": [ + "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" + ] + }, + { + "Effect": "Allow", + "Action": [ + "route53:ChangeResourceRecordSets" + ], + "Resource": [ + "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" + ], + "Condition": { + "ForAllValues:StringEquals": { + "route53:ChangeResourceRecordSetsRecordTypes": [ + "TXT" + ] + } + } + } ] } - } - } - ] - } - ``` + ``` - Replace `YOUR_HOSTED_ZONE_ID` with your actual Route53 hosted zone ID. + Replace `YOUR_HOSTED_ZONE_ID` with your actual Route53 hosted zone ID. - For detailed instructions on setting up an AWS connection, see the [AWS Connection](/integrations/app-connections/aws) documentation. + For detailed instructions on setting up an AWS connection, see the [AWS Connection](/integrations/app-connections/aws) documentation. + + + 1. Navigate to your Organization Settings > App Connections and create a new Cloudflare connection. + + 2. Ensure your Cloudflare token has the following minimum permissions for DNS validation: + + ``` + Account:Account Settings:Read + Zone:DNS:Edit + ``` + + For detailed instructions on setting up a Cloudflare connection, see the [Cloudflare Connection](/integrations/app-connections/cloudflare) documentation. + + @@ -127,10 +143,12 @@ In the following steps, we explore how to set up ACME Certificate Authority inte - **Type**: Select "ACME" as the External CA type. - **Name**: Enter a name for the ACME CA (e.g., "lets-encrypt-production"). - **DNS App Connection**: Select from available DNS app connections or configure a new one. This connection provides Infisical with the credentials needed to create and remove DNS records for ACME validation. - - **Hosted Zone ID**: Enter your Route53 hosted zone ID (e.g., Z04044I124N1GOOMCOYX1) for the domain(s) you'll be requesting certificates for. + - **Zone ID**: Enter the Zone ID for the domain(s) you'll be requesting certificates for. - **Directory URL**: Enter the ACME v2 directory URL for your chosen CA provider (e.g., `https://acme-v02.api.letsencrypt.org/directory` for Let's Encrypt). - **Account Email**: Email address to associate with your ACME account. This email will receive important notifications about your certificates. - **Enable Direct Issuance**: Toggle on to allow direct certificate issuance without requiring subscribers. + - **EAB Key Identifier (KID)**: (Optional) The Key Identifier (KID) provided by your ACME CA for External Account Binding (EAB). This is required by some ACME providers (e.g., ZeroSSL, DigiCert) to link your ACME account to an external account you've pre-registered with them. + - **EAB HMAC Key**: (Optional) The HMAC Key provided by your ACME CA for External Account Binding (EAB). This key is used in conjunction with the KID to prove ownership of the external account during ACME account registration. Finally, press **Create** to register the ACME CA with Infisical. @@ -140,7 +158,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte ![pki external ca list](/images/platform/pki/ca/external-ca/external-ca-list.png) From here, you can: - + - View the status of the ACME CA registration - Edit the configuration settings - Disable or re-enable the ACME CA @@ -152,9 +170,9 @@ In the following steps, we explore how to set up ACME Certificate Authority inte To register an ACME CA with Infisical using the API, make a request to the Create External CA endpoint: - + ### Sample request - + ```bash Request curl 'https://app.infisical.com/api/v1/pki/ca/acme' \ -H 'Authorization: Bearer ' \ @@ -180,9 +198,9 @@ In the following steps, we explore how to set up ACME Certificate Authority inte } }' ``` - + ### Sample response - + ```bash Response { "id": "c48b701e-a20c-4a9a-8119-68f54e5fbb05", @@ -226,7 +244,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte 1. Infisical generates a key pair for the certificate 2. Sends a Certificate Signing Request (CSR) to the ACME CA 3. Receives a DNS-01 challenge from the ACME provider - 4. Creates a TXT record in Route53 to satisfy the challenge + 4. Creates a TXT record in Route53/Cloudflare to satisfy the challenge 5. Notifies the ACME provider that the challenge is ready for validation 6. Once validated, the ACME provider issues the certificate 7. Infisical stores and manages the certificate for your subscriber @@ -235,7 +253,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte The issued certificate and private key are now available through Infisical and can be: - + - Downloaded directly from the Infisical UI - Retrieved via the Infisical API for programmatic access using the [latest certificate bundle endpoint](/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle) @@ -261,39 +279,52 @@ Let's Encrypt is a free, automated, and open Certificate Authority that provides Always test your ACME integration using Let's Encrypt's staging environment first. This allows you to verify your DNS configuration and certificate issuance process without consuming your production rate limits. +## Example: DigiCert Integration + +DigiCert is a leading commercial Certificate Authority providing a wide range of trusted SSL/TLS certificates. Infisical can integrate with [DigiCert's ACME](https://docs.digicert.com/en/certcentral/certificate-tools/certificate-lifecycle-automation-guides/third-party-acme-integration/request-and-manage-certificates-with-acme.html) service to automate the provisioning and management of these certificates. + +- **Directory URL**: `https://acme.digicert.com/v2/acme/directory` +- **External Account Binding (EAB)**: Required. You will need a Key Identifier (KID) and HMAC Key from your DigiCert account to register the ACME CA in Infisical. +- **Certificate Validity**: Typically 90 days, with automatic renewal through Infisical. +- **Trusted By**: All major browsers and operating systems. + + + When integrating with DigiCert ACME, ensure you have obtained the necessary External Account Binding (EAB) Key Identifier (KID) and HMAC Key from your DigiCert account. + + ## FAQ - Currently, Infisical supports DNS-01 validation through AWS Route53. The DNS-01 challenge method is preferred for ACME integrations because it: - + Currently, Infisical supports DNS-01 validation through AWS Route53 or Cloudflare. The DNS-01 challenge method is preferred for ACME integrations because it: + - Works with wildcard certificates - Doesn't require your servers to be publicly accessible - Can be fully automated without manual intervention - + Support for additional DNS providers is planned for future releases. Yes! ACME CAs like Let's Encrypt support wildcard certificates (e.g., `*.example.com`) when using DNS-01 validation. Simply specify the wildcard domain in your subscriber configuration. - + Note that wildcard certificates still require DNS-01 validation - HTTP-01 validation cannot be used for wildcard certificates. Most ACME providers issue certificates with 90-day validity periods. This shorter validity period is designed to: - + - Encourage automation of certificate management - Reduce the impact of compromised certificates - Ensure systems stay up-to-date with certificate management practices - + When configured, Infisical automatically handles certificate renewal for subscribers. Yes! You can register multiple ACME CAs in the same project: - + - Different providers for different domains or use cases - Staging and production environments for the same provider - Backup providers for redundancy - + Each subscriber can be configured to use a specific ACME CA based on your requirements. - \ No newline at end of file + diff --git a/docs/documentation/platform/pki/azure-adcs.mdx b/docs/documentation/platform/pki/azure-adcs.mdx new file mode 100644 index 000000000..b0cea565e --- /dev/null +++ b/docs/documentation/platform/pki/azure-adcs.mdx @@ -0,0 +1,206 @@ +--- +title: "Certificates with Azure ADCS" +description: "Learn how to issue and manage certificates using Microsoft Active Directory Certificate Services (ADCS) with Infisical." +--- + +Issue and manage certificates using Microsoft Active Directory Certificate Services (ADCS) for enterprise-grade certificate management integrated with your existing Windows infrastructure. + +## Prerequisites + +Before setting up ADCS integration, ensure you have: + +- Microsoft Active Directory Certificate Services (ADCS) server running and accessible +- Domain administrator account with certificate management permissions +- ADCS web enrollment enabled on your server +- Network connectivity from Infisical to the ADCS server +- **IP whitelisting**: Your ADCS server must allow connections from Infisical's IP addresses + - For Infisical Cloud instances, see [Networking Configuration](/documentation/setup/networking) for the list of IPs to whitelist + - For self-hosted instances, whitelist your Infisical server's IP address +- Azure ADCS app connection configured (see [Azure ADCS Connection](/integrations/app-connections/azure-adcs)) + +## Complete Workflow: From Setup to Certificate Issuance + +This section walks you through the complete end-to-end process of setting up Azure ADCS integration and issuing your first certificate. + + + + In your Infisical project, go to your **Certificate Project** โ†’ **Certificate Authority** to access the external CAs page. + + ![External CA Page](/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png) + + + + Click **Create CA** and configure: + - **Type**: Choose **Azure AD Certificate Service** + - **Name**: Friendly name for this CA (e.g., "Production ADCS CA") + - **App Connection**: Choose your ADCS connection from the dropdown + + ![External CA Form](/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png) + + + + Once created, your Azure ADCS Certificate Authority will appear in the list and be ready for use. + + ![External CA Created](/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png) + + + + Go to **Subscribers** to access the subscribers page. + + ![Subscribers Page](/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png) + + + + Click **Add Subscriber** and configure: + - **Name**: Unique subscriber name (e.g., "web-server-certs") + - **Certificate Authority**: Select your ADCS CA + - **Common Name**: Certificate CN (e.g., "api.example.com") + - **Certificate Template**: Select from dynamically loaded ADCS templates + - **Subject Alternative Names**: DNS names, IP addresses, or email addresses + - **TTL**: Certificate validity period (e.g., "1y" for 1 year) + - **Additional Subject Fields**: Organization, OU, locality, state, country, email (if required by template) + + ![Subscribers Form](/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png) + + + + Your subscriber is now created and ready to issue certificates. + + ![Subscriber Created](/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png) + + + + Click into your subscriber and click **Order Certificate** to generate a new certificate using your ADCS template. + + ![Issue New Certificate](/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png) + + + + Your certificate has been successfully issued by the ADCS server and is ready for use. + + ![Certificate Created](/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png) + + + + Navigate to **Certificates** to view detailed information about all issued certificates, including expiration dates, serial numbers, and certificate chains. + + ![Certificates Page](/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png) + + + +## Certificate Templates + +Infisical automatically retrieves available certificate templates from your ADCS server, ensuring you can only select templates that are properly configured and accessible. The system dynamically discovers templates during the certificate authority setup and certificate issuance process. + +### Common Template Types + +ADCS templates you might see include: +- **Web Server**: For SSL/TLS certificates with server authentication +- **Computer**: For machine authentication certificates +- **User**: For client authentication certificates +- **Basic EFS**: For Encrypting File System certificates +- **EFS Recovery Agent**: For EFS data recovery +- **Administrator**: For administrative certificates +- **Subordinate Certification Authority**: For issuing CA certificates + +### Template Requirements + +Ensure your ADCS templates are configured with: +- **Enroll permissions** for your connection account +- **Auto-enroll permissions** if using automated workflows +- **Subject name requirements** matching your certificate requests +- **Key usage extensions** appropriate for your use case + + +**Dynamic Template Discovery**: Infisical queries your ADCS server in real-time to populate available templates. Only templates you have permission to use will be displayed during certificate issuance. + + +## Certificate Issuance Limitations + +### Immediate Issuance Only + + +**Manual Approval Not Supported**: Infisical currently supports only **immediate certificate issuance**. Certificates that require manual approval or are held by ADCS policies cannot be issued through Infisical yet. + + +For successful certificate issuance, ensure your ADCS templates and policies are configured to: +- **Auto-approve** certificate requests without manual intervention +- **Not require** administrator approval for the templates you plan to use +- **Allow** the connection account to request and receive certificates immediately + +### What Happens with Manual Approval + +If a certificate request requires manual approval: +1. The request will be submitted to ADCS successfully +2. Infisical will attempt to retrieve the certificate with exponential backoff (up to 5 retries over ~1 minute) +3. If the certificate is not approved within this timeframe, the request will **fail** +4. **No background polling**: Currently, Infisical does not check for certificates that might be approved hours or days later + + +**Future Enhancement**: Background polling for delayed certificate approvals is planned for future releases. + + +### Certificate Revocation + + +Certificate revocation is **not supported** by the Azure ADCS connector due to security and complexity considerations. + + +## Advanced Configuration + +### Custom Validity Periods + +Enable custom certificate validity periods on your ADCS server: + +```cmd +# Run on ADCS server as Administrator +certutil -setreg policy\EditFlags +EDITF_ATTRIBUTEENDDATE +net stop certsvc +net start certsvc +``` + +This allows Infisical to control certificate expiration dates directly. + +## Troubleshooting + +### Common Issues + +**Certificate Request Denied** +- Verify ADCS template permissions for your connection account +- Check template subject name requirements +- Ensure template allows the requested key algorithm and size + +**Revocation Service Unavailable** +- Verify IIS is running and the revocation endpoint is accessible +- Check IIS application pool permissions +- Test endpoint connectivity from Infisical + +**Template Not Found** +- Verify template exists on ADCS server and is published +- Check that your connection account has enrollment permissions for the template +- Ensure the template is properly configured and available in the ADCS web enrollment interface +- Templates are dynamically loaded - refresh the PKI Subscriber form if templates don't appear + +**Certificate Request Pending/Timeout** +- Check if your ADCS template requires manual approval - Infisical only supports immediate issuance +- Verify the certificate template is configured for auto-approval +- Ensure your connection account has sufficient permissions to request certificates without approval +- Review ADCS server policies that might be holding the certificate request + +**Network Connectivity Issues** +- Verify your ADCS server's firewall allows connections from Infisical +- For Infisical Cloud: Ensure Infisical's IP addresses are whitelisted (see [Networking Configuration](/documentation/setup/networking)) +- For self-hosted: Whitelist your Infisical server's IP address on the ADCS server +- Test HTTPS connectivity to the ADCS web enrollment endpoint +- Check for any network security appliances blocking the connection + +**Authentication Failures** +- Verify ADCS connection credentials +- Check domain account permissions +- Ensure network connectivity to ADCS server + +**SSL/TLS Certificate Errors** +- For ADCS servers with self-signed or private certificates: disable "Reject Unauthorized" in the SSL tab of your Azure ADCS app connection, or provide the certificate in PEM format +- Common SSL errors: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `SELF_SIGNED_CERT_IN_CHAIN`, `CERT_HAS_EXPIRED` +- The SSL configuration applies to all HTTPS communications between Infisical and your ADCS server +- Only HTTPS URLs are supported - HTTP connections are not allowed for security reasons diff --git a/docs/documentation/platform/pki/external-ca.mdx b/docs/documentation/platform/pki/external-ca.mdx index 02285cac6..efe029149 100644 --- a/docs/documentation/platform/pki/external-ca.mdx +++ b/docs/documentation/platform/pki/external-ca.mdx @@ -13,11 +13,11 @@ In addition to creating a Private CA hierarchy, Infisical allows you to integrat ```mermaid graph TD B[Infisical] -->|Manages Certificates| D[Subscribers] - + A1[Public CAs
Let's Encrypt, ZeroSSL] -->|ACME Protocol| B A2[Enterprise CAs
Vault PKI, Step CA] -->|ACME Protocol| B A3[Cloud CAs
ACME-compatible services] -->|ACME Protocol| B - + A4[Future: Enterprise CAs] -.->|EST/SCEP Protocols| B A5[Future: Cloud CAs] -.->|REST APIs| B ``` @@ -54,7 +54,7 @@ ACME (Automatic Certificate Management Environment) is a widely adopted protocol **Public Certificate Authorities:** - Let's Encrypt - Free, automated SSL/TLS certificates -- ZeroSSL - Free and premium SSL certificates +- ZeroSSL - Free and premium SSL certificates - Buypass - Norwegian CA with free ACME certificates **Enterprise Certificate Authorities:** @@ -73,7 +73,7 @@ External CA integration is ideal for various scenarios: ### Public-Facing Services Use publicly trusted CAs for websites and services that need browser compatibility: - Web applications and APIs -- Load balancers and CDNs +- Load balancers and CDNs - Public-facing microservices ### Compliance Requirements @@ -137,18 +137,18 @@ Get started with External CA integration: Currently, Infisical supports any Certificate Authority that implements the ACME protocol, including: - + - **Public CAs**: Let's Encrypt, ZeroSSL, Buypass - - **Enterprise CAs**: HashiCorp Vault PKI, Step CA + - **Enterprise CAs**: HashiCorp Vault PKI, Step CA - **Cloud CAs**: ACME-compatible managed services - - Integration uses DNS-01 validation through Route53. Learn more about [supported DNS validation methods](/documentation/platform/pki/acme-ca#what-dns-validation-methods-are-supported). - + + Integration uses DNS-01 validation through Route53 or Cloudflare. Learn more about [supported DNS validation methods](/documentation/platform/pki/acme-ca#what-dns-validation-methods-are-supported). + Support for additional integration protocols (EST, SCEP, direct APIs) is planned for future releases. Yes. You can have both Private CAs (root and intermediate) and External CAs in the same project, allowing you flexibility in how you issue certificates for different use cases. This hybrid approach enables you to: - + - Use Private CAs for internal services and applications - Use External CAs for public-facing services - Apply consistent management practices across all certificate types @@ -156,37 +156,37 @@ Get started with External CA integration: The types of certificates you can issue depend on the External CA provider and type: - + - **Public CAs**: Typically support Domain Validation (DV) certificates, with some offering Organization Validation (OV) - **Enterprise CAs**: Support internal certificates, device certificates, and custom certificate types - **Cloud CAs**: Support various certificate types depending on the service - + Certificate capabilities vary by provider and integration method. Certificate reissuance is handled automatically by Infisical based on the CA type: - + - **Public CAs**: Automatic reissuance using ACME protocol with the same certificate extensions before expiration - **Other CA types**: Certificate management methods depend on the specific integration (when available) - + All certificate lifecycle events are tracked and managed through Infisical's unified interface, ensuring continuous certificate validity. Authentication methods vary by CA type: - + - **Public CAs**: ACME account registration with email and account keys - **Enterprise CAs**: Client certificates, username/password, or domain authentication (when available) - **Cloud CAs**: API keys, OAuth tokens, or service account authentication (when available) - + Infisical securely stores and manages all authentication credentials. Yes, Infisical provides policy enforcement capabilities: - + - Certificate template constraints - Monitoring and alerting policies - Access controls for certificate operations - + These policies ensure consistent governance across both internal and external certificate sources. - \ No newline at end of file + diff --git a/docs/documentation/platform/pki/overview.mdx b/docs/documentation/platform/pki/overview.mdx index 8ee9b113d..b2351813c 100644 --- a/docs/documentation/platform/pki/overview.mdx +++ b/docs/documentation/platform/pki/overview.mdx @@ -1,13 +1,13 @@ --- -title: "Internal PKI" +title: "Infisical PKI" sidebarTitle: "Overview" description: "Learn how to create a Private CA hierarchy and issue X.509 certificates." --- -Infisical can be used to create a Private Certificate Authority (CA) hierarchy and issue X.509 certificates for internal use. This allows you to manage your own PKI infrastructure and issue digital certificates for subscribers such as services, applications, and devices. +Infisical can be used to create and manage Certificate Authorities (CAs) and issue X.509 certificates. This allows you to manage PKI infrastructure and issue digital certificates for subscribers such as services, applications, and devices. Infisical's PKI offering is split into three components: -- [Certificate Authorities](/documentation/platform/pki/private-ca): Create and manage private CAs, including root and intermediate CAs. +- [Certificate Authorities](/documentation/platform/pki/private-ca): Create and manage CAs, including root and intermediate CAs. - [Subscribers](/documentation/platform/pki/subscribers): Define and manage entities that will request X.509 certificates from CAs. This module provides a centralized view of all subscribers, enabling you to issue certificates and monitor their status. - [Certificates](/documentation/platform/pki/certificates): Track and monitor issued X.509 certificates, maintaining a comprehensive inventory of all active and expired certificates. diff --git a/docs/documentation/platform/project.mdx b/docs/documentation/platform/project.mdx index dcf447169..7d0df2e22 100644 --- a/docs/documentation/platform/project.mdx +++ b/docs/documentation/platform/project.mdx @@ -1,116 +1,51 @@ --- -title: "Projects" +title: "Overview" description: "Learn more and understand the concept of Infisical projects." --- -A project in Infisical belongs to an [organization](./organization) and contains a number of environments, folders, and secrets. -Only users and machine identities who belong to a project can access resources inside of it according to predefined permissions. +## Projects -Infisical also allows users to request project access. Refer to the [project access request section](./access-controls/project-access-requests) +A project defines a specific scope of work for a given product line in Infisical. -## Project environments +Projects are created within an [organization](/documentation/platform/organization), and an organization can contain multiple projects across different product types. -For both visual and organizational structure, Infisical allows splitting up secrets into environments (e.g., development, staging, production). In project settings, such environments can be -customized depending on the intended use case. +## Project Types -![project secrets overview](../../images/platform/project/project-environments.png) +Infisical supports project types, each representing a different security product with its own dashboard, workflows, and capabilities. -## Secrets Overview +![project types](/images/platform/project/project-types.png) -The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](./folder) across environments. -This is useful for comparing secrets, identifying if anything is missing, and making quick changes. +The supported project types are: -![project secrets overview](../../images/platform/project/project-secrets-overview-open.png) +- [Secrets Management](/documentation/platform/secrets-mgmt/overview): Securely store, access, and distribute secrets across environments with fine-grained controls, automatic rotation, and audit logging. +- [Secrets Scanning](/documentation/platform/secret-scanning/overview): Detect hardcoded secrets in code, CI pipelines, and infrastructureโ€”integrated with GitHub, GitLab, Bitbucket, and more. +- [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. +- [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. +- [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. -## Secrets Dashboard +## Roles and Access Control -The **Secrets Dashboard** page appears when you press to manage the secrets of a specific environment. +[Users](/documentation/platform/identities/user-identities) and [machine identities](/documentation/platform/identities/machine-identities) must be added to a project to access its resources. Each identity is assigned a [project-level role](/documentation/platform/access-controls/role-based-access-controls#project-level-access-controls) that defines what they can manageโ€”such as secrets, certificates, or SSH access. These roles apply to both individuals and [user groups](/documentation/platform/groups), enabling scalable access across teams and environments. -![project dashboard](../../images/dashboard.png) +Project access is strictly scoped: only members of a project can view or manage its resources. If someone needs access but isnโ€™t part of the project, they can submit an access request. -### Secrets +Each project in Infisical has its own [access control model](/documentation/platform/access-controls/role-based-access-controls#project-level-access-controls), distinct from [organization-level access control](/documentation/platform/access-controls/role-based-access-controls#organization-level-access-controls). While organization roles govern broader administrative access, project-level roles control what users, groups, and machine identities can do within the boundaries of a specific projectโ€”such as managing secrets, issuing certificates, or configuring SSH access. -To add a secret, press **Add Secret** button at the top of the dashboard. +Depending on the project type (e.g. Secrets Management, PKI, SSH), project-level access control supports advanced features like [temporary access](/documentation/platform/access-controls/temporary-access), [access requests](/documentation/platform/access-controls/access-requests), and [additional privileges](/documentation/platform/access-controls/additional-privileges). -![project add secret](../../images/platform/project/project-secrets-add.png) +![project roles](/images/platform/project/project-roles.png) -For a new project, it can be convenient to populate the dashboard by dropping a `.env` file into the provided pane as shown below: +To learn more about how permissions work in detail, refer to the [access control documentation](/documentation/platform/access-controls/overview). -![project drop env file](../../images/platform/project/project-secrets-drop-env.png) +## Audit Logs -To delete a secret, hover over it and press the **X** button that appears on the right side. +Infisical provides [audit logging](/documentation/platform/audit-logs) at the project level to help teams monitor activity and maintain accountability within a specific project. These logs capture all relevant eventsโ€”such as secret access, certificate issuance, and SSH activityโ€”that occur within the boundaries of that project. -![project delete secret](../../images/platform/project/project-secrets-delete.png) +Unlike the organization-level audit view, which aggregates logs across all projects in one centralized interface, the project-level audit view is scoped to a single project. This enables relevant project admins and contributors to review activity relevant to their work without having broader access to audit logs in other projects that they are not part of. -To delete multiple secrets at once, hover over and select the secrets you'd like to delete -and press the **Delete** button that appears at the top. +## Project Settings -![project delete secret batch](../../images/platform/project/project-secrets-delete-batch.png) +Each project has its own settings panel, with options that vary depending on the selected product type. These may include +setup and configuration for environments, tags, behaviors, encryption strategies, and other options. -### Search - -To search for specific secrets by their key name, you can use the search bar. - -![project search](../../images/platform/project/project-secrets-search.png) - -To assist you with finding secrets, you can also group them by similar prefixes and filter them by tags (if applicable). - -![project filter](../../images/platform/project/project-secrets-filter.png) - -### Hide/Un-hide - -To view/hide all secrets at once, toggle the hide or un-hide button. - -![project filter](../../images/platform/project/project-secrets-unhide.png) - -### Download as .env - -To download/export secrets back into a `.env` file, press the download button. - -![project download back env](../../images/platform/project/project-secrets-download-env.png) - -### Tags - -To better organize similar secrets, hover over them and label them with a tag. - -![project tag secret](../../images/platform/project/project-secrets-tag.png) - -### Comments - -To provide more context about a given secret, especially for your team, hover over it and press the comment button. - -![project comment secret](../../images/platform/project/project-secrets-comment.png) - -### Personal overrides - -Infisical employs the concept of **shared** and **personal** secrets to address the need -for common and custom secret values, or branching, amongst members of a team during software development. -To provide a helpful analogy: A shared value is to a `main` branch as a personal value is to a custom branch. - -Consider: - -- A team with users A, B, user C. -- A project with an environment containing a shared secret called D with the value E. - -Suppose user A overrides the value of secret D with the value F. - -Then: - -- If user A fetches the secret D back, they get the value F. -- If users B and C fetch the secret D back, they both get the value E. - - - Please keep in mind that secret reminders won't work with personal overrides. - - -![project override secret](../../images/platform/project/project-secrets-override.png) - -### Drawer - -To view the full details of each secret, you can hover over it and press on the ellipses button. - -![project secrets ellipses](../../images/platform/project/project-secrets-ellipses.png) - -This opens up a side-drawer: - -![project secrets drawer](../../images/platform/project/project-secrets-drawer.png) +Project settings are fully independent and reflect the capabilities of the associated product. diff --git a/docs/documentation/platform/secret-rotation/azure-client-secret.mdx b/docs/documentation/platform/secret-rotation/azure-client-secret.mdx index 046b772f6..c2da0a6a1 100644 --- a/docs/documentation/platform/secret-rotation/azure-client-secret.mdx +++ b/docs/documentation/platform/secret-rotation/azure-client-secret.mdx @@ -6,6 +6,7 @@ description: "Learn how to automatically rotate Azure Client Secrets." ## Prerequisites - Create an [Azure Client Secret Connection](/integrations/app-connections/azure-client-secrets). +- Ensure your network security policies allow incoming requests from Infisical to this rotation provider, if network restrictions apply. ## Create an Azure Client Secret Rotation in Infisical diff --git a/docs/documentation/platform/secret-rotation/ldap-password.mdx b/docs/documentation/platform/secret-rotation/ldap-password.mdx index feb3a664d..ba75e0af7 100644 --- a/docs/documentation/platform/secret-rotation/ldap-password.mdx +++ b/docs/documentation/platform/secret-rotation/ldap-password.mdx @@ -14,6 +14,7 @@ description: "Learn how to automatically rotate LDAP passwords." ## Prerequisites - Create an [LDAP Connection](/integrations/app-connections/ldap) with the **Secret Rotation** requirements +- Ensure your network security policies allow incoming requests from Infisical to this rotation provider, if network restrictions apply. ## Create an LDAP Password Rotation in Infisical diff --git a/docs/documentation/platform/secret-rotation/mssql-credentials.mdx b/docs/documentation/platform/secret-rotation/mssql-credentials.mdx index c20622f26..f609d11c5 100644 --- a/docs/documentation/platform/secret-rotation/mssql-credentials.mdx +++ b/docs/documentation/platform/secret-rotation/mssql-credentials.mdx @@ -30,6 +30,7 @@ An example creation statement might look like: To learn more about Microsoft SQL Server's permission system, please visit their [documentation](https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-transact-sql?view=sql-server-ver16). +3. Ensure your network security policies allow incoming requests from Infisical to this rotation provider, if network restrictions apply. ## Create a Microsoft SQL Server Credentials Rotation in Infisical diff --git a/docs/documentation/platform/secret-rotation/mysql-credentials.mdx b/docs/documentation/platform/secret-rotation/mysql-credentials.mdx index d0088a29e..349b5b893 100644 --- a/docs/documentation/platform/secret-rotation/mysql-credentials.mdx +++ b/docs/documentation/platform/secret-rotation/mysql-credentials.mdx @@ -25,7 +25,7 @@ description: "Learn how to automatically rotate MySQL credentials." To learn more about the MySQL permission system, please visit their [documentation](https://dev.mysql.com/doc/refman/8.4/en/grant.html). - +3. Ensure your network security policies allow incoming requests from Infisical to this rotation provider, if network restrictions apply. ## Create a MySQL Credentials Rotation in Infisical diff --git a/docs/documentation/platform/secret-rotation/oracledb-credentials.mdx b/docs/documentation/platform/secret-rotation/oracledb-credentials.mdx index fb0887d33..0f72d62fa 100644 --- a/docs/documentation/platform/secret-rotation/oracledb-credentials.mdx +++ b/docs/documentation/platform/secret-rotation/oracledb-credentials.mdx @@ -31,6 +31,7 @@ description: "Learn how to automatically rotate Oracle Database credentials." To learn more about the Oracle Database permission system, please visit their [documentation](https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/configuring-privilege-and-role-authorization.html). +3. Ensure your network security policies allow incoming requests from Infisical to this rotation provider, if network restrictions apply. ## Create an Oracle Database Credentials Rotation in Infisical diff --git a/docs/documentation/platform/secret-rotation/postgres-credentials.mdx b/docs/documentation/platform/secret-rotation/postgres-credentials.mdx index 55175d967..e8cddb0f4 100644 --- a/docs/documentation/platform/secret-rotation/postgres-credentials.mdx +++ b/docs/documentation/platform/secret-rotation/postgres-credentials.mdx @@ -27,6 +27,7 @@ description: "Learn how to automatically rotate PostgreSQL credentials." To learn more about PostgreSQL's permission system, please visit their [documentation](https://www.postgresql.org/docs/current/sql-grant.html). +3. Ensure your network security policies allow incoming requests from Infisical to this rotation provider, if network restrictions apply. ## Create a PostgreSQL Credentials Rotation in Infisical 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/gitlab.mdx b/docs/documentation/platform/secret-scanning/gitlab.mdx new file mode 100644 index 000000000..52f52f505 --- /dev/null +++ b/docs/documentation/platform/secret-scanning/gitlab.mdx @@ -0,0 +1,103 @@ +--- +title: "GitLab Secret Scanning" +sidebarTitle: "GitLab" +description: "Learn how to configure secret scanning for GitLab." +--- + +## Prerequisites + +- Create a [GitLab Connection](/integrations/app-connections/gitlab) with Secret Scanning permissions + +## Create a GitLab Data Source in Infisical + + + + 1. Navigate to your Secret Scanning Project's Dashboard and click the **Add Data Source** button. + ![Secret Scanning Dashboard](/images/platform/secret-scanning/github/github-data-source-step-1.png) + + 2. Select the **GitLab** option. + ![Select GitLab](/images/platform/secret-scanning/gitlab/step-2.png) + + 3. Configure which workspace and repositories you would like to scan. Then click **Next**. + ![Data Source Configuration](/images/platform/secret-scanning/gitlab/step-3.png) + + - **GitLab Connection** - the connection that has access to the repositories you want to scan. + - **Scope** - the GitLab scope to scan secrets in. + - **Project** - scan an individual GitLab project. + - **Group** - scan one or more projects belonging to a GitLab group. + - **Scan Repositories** - when using **Group Scope**, select which repositories you would like to scan. + - **All Repositories** - Infisical will scan all repositories associated with your connection. + - **Select Repositories** - Infisical will scan the selected repositories. + - **Auto-Scan Enabled** - whether Infisical should automatically perform a scan when a push is made to configured repositories. + + 4. Give your data source a name and description (optional). Then click **Next**. + ![Data Source Details](/images/platform/secret-scanning/gitlab/step-4.png) + + - **Name** - the name of the data source. Must be slug-friendly. + - **Description** (optional) - a description of this data source. + + 5. Review your data source, then click **Create Data Source**. + ![Data Source Review](/images/platform/secret-scanning/gitlab/step-5.png) + + 6. Your **GitLab Data Source** is now available and will begin a full scan if **Auto-Scan** is enabled. + ![Data Source Created](/images/platform/secret-scanning/gitlab/step-6.png) + + 7. You can view repositories and scan results by clicking on your data source. + ![Data Source Page](/images/platform/secret-scanning/gitlab/step-7.png) + + 8. In addition, you can review any findings from the **Findings Page**. + ![Findings Page](/images/platform/secret-scanning/gitlab/step-8.png) + + + To create a GitLab Data Source, make an API request to the [Create GitLab Data Source](/api-reference/endpoints/secret-scanning/data-sources/gitlab/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-scanning/data-sources/gitlab \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-gitlab-source", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my gitlab data source", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "isAutoScanEnabled": true, + "config": { + "scope": "project", + "projectId": 123456789, + "projectName": "my-group/my-project" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "dataSource": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "externalId": "1234567890", + "name": "my-gitlab-source", + "description": "my gitlab data source", + "isAutoScanEnabled": true, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "type": "gitlab", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "gitlab", + "name": "my-gitlab-app", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "config": { + "scope": "project", + "projectId": 123456789, + "projectName": "my-group/my-project" + } + } + } + ``` + + 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/secret-versioning.mdx b/docs/documentation/platform/secret-versioning.mdx index 6a0efb8b8..7ff8694f0 100644 --- a/docs/documentation/platform/secret-versioning.mdx +++ b/docs/documentation/platform/secret-versioning.mdx @@ -8,6 +8,7 @@ Every time a secret change is performed, a new version of the same secret is cre Such versions can be accessed visually by opening up the [secret sidebar](/documentation/platform/project#drawer) (as seen below) or [retrieved via API](/api-reference/endpoints/secrets/read) by specifying the `version` query parameter. +![secret versioning overview](../../images/platform/secret-versioning-overview.png) ![secret versioning](../../images/platform/secret-versioning.png) The secret versioning functionality is heavily connected to [Point-in-time Recovery](/documentation/platform/pit-recovery) of secrets in Infisical. 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..df3885055 --- /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](https://registry.terraform.io/providers/Infisical/infisical/latest/docs), [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](https://registry.terraform.io/providers/Infisical/infisical/latest/docs): 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 new file mode 100644 index 000000000..c1905d629 --- /dev/null +++ b/docs/documentation/platform/secrets-mgmt/overview.mdx @@ -0,0 +1,17 @@ +--- +title: "Secrets Management" +sidebarTitle: "Overview" +description: "Learn how to securely store, access, and manage sensitive application secrets." +--- + +Infisical provides a flexible platform for managing application secrets โ€” such as API keys, database credentials, application configuration, and more โ€” across every stage of the development lifecycle from local development to production. + +It helps teams eliminate hardcoded secrets, enforce access controls, and adopt secure workflows like secret rotation, dynamic secrets, and secrets syncs to external platforms. + +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](/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/secrets-mgmt/project.mdx b/docs/documentation/platform/secrets-mgmt/project.mdx new file mode 100644 index 000000000..3e7c7cbc7 --- /dev/null +++ b/docs/documentation/platform/secrets-mgmt/project.mdx @@ -0,0 +1,115 @@ +--- +title: "Projects" +description: "Learn more and understand the concept of Infisical projects." +--- + +A secrets management project in Infisical is a dedicated workspace for managing application secrets such as API keys, database credentials, configuration, etc. used by your applications. + +Secrets are organized into a clear hierarchy of environments, folders, and individual secrets, making it easy to manage values across different stages of your development lifecycle (e.g., development, staging, production). + +## Project environments + +For both visual and organizational structure, Infisical allows splitting up secrets into environments (e.g., development, staging, production). In project settings, such environments can be +customized depending on the intended use case. + +![project secrets overview](/images/platform/project/project-environments.png) + +## Secrets Overview + +The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](./folder) across environments. +This is useful for comparing secrets, identifying if anything is missing, and making quick changes. + +![project secrets overview](/images/platform/project/project-secrets-overview-open.png) + +## Secrets Dashboard + +The **Secrets Dashboard** page appears when you press to manage the secrets of a specific environment. + +![project dashboard](/images/dashboard.png) + +### Secrets + +To add a secret, press **Add Secret** button at the top of the dashboard. + +![project add secret](/images/platform/project/project-secrets-add.png) + +For a new project, it can be convenient to populate the dashboard by dropping a `.env` file into the provided pane as shown below: + +![project drop env file](/images/platform/project/project-secrets-drop-env.png) + +To delete a secret, hover over it and press the **X** button that appears on the right side. + +![project delete secret](/images/platform/project/project-secrets-delete.png) + +To delete multiple secrets at once, hover over and select the secrets you'd like to delete +and press the **Delete** button that appears at the top. + +![project delete secret batch](/images/platform/project/project-secrets-delete-batch.png) + +### Search + +To search for specific secrets by their key name, you can use the search bar. + +![project search](/images/platform/project/project-secrets-search.png) + +To assist you with finding secrets, you can also group them by similar prefixes and filter them by tags (if applicable). + +![project filter](/images/platform/project/project-secrets-filter.png) + +### Hide/Un-hide + +To view/hide all secrets at once, toggle the hide or un-hide button. + +![project filter](/images/platform/project/project-secrets-unhide.png) + +### Download as .env + +To download/export secrets back into a `.env` file, press the download button. + +![project download back env](/images/platform/project/project-secrets-download-env.png) + +### Tags + +To better organize similar secrets, hover over them and label them with a tag. + +![project tag secret](/images/platform/project/project-secrets-tag.png) + +### Comments + +To provide more context about a given secret, especially for your team, hover over it and press the comment button. + +![project comment secret](/images/platform/project/project-secrets-comment.png) + +### Personal overrides + +Infisical employs the concept of **shared** and **personal** secrets to address the need +for common and custom secret values, or branching, amongst members of a team during software development. +To provide a helpful analogy: A shared value is to a `main` branch as a personal value is to a custom branch. + +Consider: + +- A team with users A, B, user C. +- A project with an environment containing a shared secret called D with the value E. + +Suppose user A overrides the value of secret D with the value F. + +Then: + +- If user A fetches the secret D back, they get the value F. +- If users B and C fetch the secret D back, they both get the value E. + + + Please keep in mind that secret reminders won't work with personal overrides. + + +![project override secret](/images/platform/project/project-secrets-override.png) + +### Drawer + +To view the full details of each secret, you can hover over it and press on the ellipses button. + +![project secrets ellipses](/images/platform/project/project-secrets-ellipses.png) + +This opens up a side-drawer: + +![project secrets drawer](/images/platform/project/project-secrets-drawer.png) 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/host-groups.mdx b/docs/documentation/platform/ssh/host-groups.mdx index 877291183..373141e05 100644 --- a/docs/documentation/platform/ssh/host-groups.mdx +++ b/docs/documentation/platform/ssh/host-groups.mdx @@ -1,5 +1,5 @@ --- -title: "Infisical SSH" +title: "Host Groups" sidebarTitle: "Host Groups" description: "Learn how to organize SSH hosts into groups and manage access policies at scale." --- diff --git a/docs/documentation/platform/ssh/overview.mdx b/docs/documentation/platform/ssh/overview.mdx index a252e1f71..9de14478e 100644 --- a/docs/documentation/platform/ssh/overview.mdx +++ b/docs/documentation/platform/ssh/overview.mdx @@ -1,201 +1,15 @@ --- 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/favicon.ico b/docs/favicon.ico new file mode 100644 index 000000000..a76dd0339 Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/favicon.png b/docs/favicon.png deleted file mode 100644 index 2a2ec8d1c..000000000 Binary files a/docs/favicon.png and /dev/null differ diff --git a/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-created.png b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-created.png new file mode 100644 index 000000000..dff826130 Binary files /dev/null and b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-created.png differ diff --git a/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form-ssl.png b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form-ssl.png new file mode 100644 index 000000000..c919a1a87 Binary files /dev/null and b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form-ssl.png differ diff --git a/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png new file mode 100644 index 000000000..002836a58 Binary files /dev/null and b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png differ diff --git a/docs/images/app-connections/azure-adcs/azure-adcs-select-connection.png b/docs/images/app-connections/azure-adcs/azure-adcs-select-connection.png new file mode 100644 index 000000000..16321c3dd Binary files /dev/null and b/docs/images/app-connections/azure-adcs/azure-adcs-select-connection.png differ diff --git a/docs/images/app-connections/azure/app-configuration/create-client-secrets-method.png b/docs/images/app-connections/azure/app-configuration/create-client-secrets-method.png new file mode 100644 index 000000000..063fa1f90 Binary files /dev/null and b/docs/images/app-connections/azure/app-configuration/create-client-secrets-method.png differ diff --git a/docs/images/app-connections/azure/devops/create-client-secrets-method.png b/docs/images/app-connections/azure/devops/create-client-secrets-method.png new file mode 100644 index 000000000..b0563dcb9 Binary files /dev/null and b/docs/images/app-connections/azure/devops/create-client-secrets-method.png differ diff --git a/docs/images/app-connections/azure/key-vault/create-client-secrets-method.png b/docs/images/app-connections/azure/key-vault/create-client-secrets-method.png new file mode 100644 index 000000000..b857c99fb Binary files /dev/null and b/docs/images/app-connections/azure/key-vault/create-client-secrets-method.png differ diff --git a/docs/images/app-connections/cloudflare/cloudflare-dns-configure-permissions.png b/docs/images/app-connections/cloudflare/cloudflare-dns-configure-permissions.png new file mode 100644 index 000000000..d0ba4e68d Binary files /dev/null and b/docs/images/app-connections/cloudflare/cloudflare-dns-configure-permissions.png differ diff --git a/docs/images/app-connections/github/create-github-app-method.png b/docs/images/app-connections/github/create-github-app-method.png index 640fb0213..e69de29bb 100644 Binary files a/docs/images/app-connections/github/create-github-app-method.png and b/docs/images/app-connections/github/create-github-app-method.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-create-application-bottom-secret-scanning.png b/docs/images/app-connections/gitlab/gitlab-create-application-bottom-secret-scanning.png new file mode 100644 index 000000000..3991af87b Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-create-application-bottom-secret-scanning.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-create-application-bottom.png b/docs/images/app-connections/gitlab/gitlab-create-application-bottom-secret-sync.png similarity index 100% rename from docs/images/app-connections/gitlab/gitlab-create-application-bottom.png rename to docs/images/app-connections/gitlab/gitlab-create-application-bottom-secret-sync.png diff --git a/docs/images/app-connections/gitlab/gitlab-create-application-top-secret-scanning.png b/docs/images/app-connections/gitlab/gitlab-create-application-top-secret-scanning.png new file mode 100644 index 000000000..49ff52e8d Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-create-application-top-secret-scanning.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-create-application-top.png b/docs/images/app-connections/gitlab/gitlab-create-application-top-secret-sync.png similarity index 100% rename from docs/images/app-connections/gitlab/gitlab-create-application-top.png rename to docs/images/app-connections/gitlab/gitlab-create-application-top-secret-sync.png diff --git a/docs/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-scanning.png b/docs/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-scanning.png new file mode 100644 index 000000000..519bc2fde Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-scanning.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-personal-access-token-form.png b/docs/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-sync.png similarity index 100% rename from docs/images/app-connections/gitlab/gitlab-personal-access-token-form.png rename to docs/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-sync.png diff --git a/docs/images/app-connections/gitlab/gitlab-project-access-token-form-secret-scanning.png b/docs/images/app-connections/gitlab/gitlab-project-access-token-form-secret-scanning.png new file mode 100644 index 000000000..c440d89c7 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-project-access-token-form-secret-scanning.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-project-access-token-form.png b/docs/images/app-connections/gitlab/gitlab-project-access-token-form-secret-sync.png similarity index 100% rename from docs/images/app-connections/gitlab/gitlab-project-access-token-form.png rename to docs/images/app-connections/gitlab/gitlab-project-access-token-form-secret-sync.png diff --git a/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png b/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png index 872d11cab..ebe9fc2ad 100644 Binary files a/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png and b/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png differ diff --git a/docs/images/app-connections/netlify/app-connection-create-api-key.png b/docs/images/app-connections/netlify/app-connection-create-api-key.png new file mode 100644 index 000000000..b7832db9e Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-create-api-key.png differ diff --git a/docs/images/app-connections/netlify/app-connection-create-form.png b/docs/images/app-connections/netlify/app-connection-create-form.png new file mode 100644 index 000000000..9435e3f94 Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-create-form.png differ diff --git a/docs/images/app-connections/netlify/app-connection-dashboard.png b/docs/images/app-connections/netlify/app-connection-dashboard.png new file mode 100644 index 000000000..aca7ccdd8 Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-dashboard.png differ diff --git a/docs/images/app-connections/netlify/app-connection-form.png b/docs/images/app-connections/netlify/app-connection-form.png new file mode 100644 index 000000000..13ebbdd42 Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-form.png differ diff --git a/docs/images/app-connections/netlify/app-connection-generated.png b/docs/images/app-connections/netlify/app-connection-generated.png new file mode 100644 index 000000000..67433626a Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-generated.png differ diff --git a/docs/images/app-connections/netlify/app-connection-key-generated.png b/docs/images/app-connections/netlify/app-connection-key-generated.png new file mode 100644 index 000000000..9c3aaf78b Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-key-generated.png differ diff --git a/docs/images/app-connections/netlify/app-connection-option.png b/docs/images/app-connections/netlify/app-connection-option.png new file mode 100644 index 000000000..5e9782a8c Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-option.png differ diff --git a/docs/images/app-connections/netlify/app-connection-profile.png b/docs/images/app-connections/netlify/app-connection-profile.png new file mode 100644 index 000000000..eeda6a5a8 Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-profile.png differ diff --git a/docs/images/platform/access-controls/edit-access-request.png b/docs/images/platform/access-controls/edit-access-request.png new file mode 100644 index 000000000..c5db2f5f9 Binary files /dev/null and b/docs/images/platform/access-controls/edit-access-request.png differ diff --git a/docs/images/platform/audit-log-streams/custom-provider.png b/docs/images/platform/audit-log-streams/custom-provider.png new file mode 100644 index 000000000..e860e2e90 Binary files /dev/null and b/docs/images/platform/audit-log-streams/custom-provider.png differ diff --git a/docs/images/platform/audit-log-streams/datadog-details.png b/docs/images/platform/audit-log-streams/datadog-details.png new file mode 100644 index 000000000..29b1464be Binary files /dev/null and b/docs/images/platform/audit-log-streams/datadog-details.png differ diff --git a/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png b/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png deleted file mode 100644 index 7960b1145..000000000 Binary files a/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png and /dev/null differ diff --git a/docs/images/platform/audit-log-streams/datadog-source-details.png b/docs/images/platform/audit-log-streams/datadog-source-details.png deleted file mode 100644 index 5ae25b0b3..000000000 Binary files a/docs/images/platform/audit-log-streams/datadog-source-details.png and /dev/null differ diff --git a/docs/images/platform/audit-log-streams/select-custom.png b/docs/images/platform/audit-log-streams/select-custom.png new file mode 100644 index 000000000..7a55b24a7 Binary files /dev/null and b/docs/images/platform/audit-log-streams/select-custom.png differ diff --git a/docs/images/platform/audit-log-streams/select-provider.png b/docs/images/platform/audit-log-streams/select-provider.png new file mode 100644 index 000000000..c289fd3af Binary files /dev/null and b/docs/images/platform/audit-log-streams/select-provider.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-credentials.png b/docs/images/platform/audit-log-streams/splunk-credentials.png new file mode 100644 index 000000000..8b8624700 Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-credentials.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-data-inputs.png b/docs/images/platform/audit-log-streams/splunk-data-inputs.png new file mode 100644 index 000000000..3446f89f3 Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-data-inputs.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-details.png b/docs/images/platform/audit-log-streams/splunk-details.png new file mode 100644 index 000000000..b7ece41fc Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-details.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-http-collector.png b/docs/images/platform/audit-log-streams/splunk-http-collector.png new file mode 100644 index 000000000..d8090095c Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-http-collector.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-name.png b/docs/images/platform/audit-log-streams/splunk-name.png new file mode 100644 index 000000000..2c382539e Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-name.png differ diff --git a/docs/images/platform/audit-log-streams/splunk-new-token.png b/docs/images/platform/audit-log-streams/splunk-new-token.png new file mode 100644 index 000000000..d4c53569d Binary files /dev/null and b/docs/images/platform/audit-log-streams/splunk-new-token.png differ diff --git a/docs/images/platform/audit-log-streams/stream-create.png b/docs/images/platform/audit-log-streams/stream-create.png index 949278e3d..1244fa558 100644 Binary files a/docs/images/platform/audit-log-streams/stream-create.png and b/docs/images/platform/audit-log-streams/stream-create.png differ diff --git a/docs/images/platform/audit-log-streams/stream-inputs.png b/docs/images/platform/audit-log-streams/stream-inputs.png deleted file mode 100644 index 6b9d7c57b..000000000 Binary files a/docs/images/platform/audit-log-streams/stream-inputs.png and /dev/null differ diff --git a/docs/images/platform/audit-log-streams/stream-list.png b/docs/images/platform/audit-log-streams/stream-list.png index c5cc5598b..ad355c3b0 100644 Binary files a/docs/images/platform/audit-log-streams/stream-list.png and b/docs/images/platform/audit-log-streams/stream-list.png differ 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/images/platform/events/access-dev.png b/docs/images/platform/events/access-dev.png new file mode 100644 index 000000000..3f44fc51b Binary files /dev/null and b/docs/images/platform/events/access-dev.png differ diff --git a/docs/images/platform/events/access-full.png b/docs/images/platform/events/access-full.png new file mode 100644 index 000000000..d41260c17 Binary files /dev/null and b/docs/images/platform/events/access-full.png differ diff --git a/docs/images/platform/events/access-path.png b/docs/images/platform/events/access-path.png new file mode 100644 index 000000000..3bfa9b28b Binary files /dev/null and b/docs/images/platform/events/access-path.png differ diff --git a/docs/images/platform/events/add-auth-method-form.png b/docs/images/platform/events/add-auth-method-form.png new file mode 100644 index 000000000..ba181ff48 Binary files /dev/null and b/docs/images/platform/events/add-auth-method-form.png differ diff --git a/docs/images/platform/events/add-auth-method.png b/docs/images/platform/events/add-auth-method.png new file mode 100644 index 000000000..583f13d09 Binary files /dev/null and b/docs/images/platform/events/add-auth-method.png differ diff --git a/docs/images/platform/events/add-conditions.png b/docs/images/platform/events/add-conditions.png new file mode 100644 index 000000000..bf0580b22 Binary files /dev/null and b/docs/images/platform/events/add-conditions.png differ diff --git a/docs/images/platform/events/add-policy.png b/docs/images/platform/events/add-policy.png new file mode 100644 index 000000000..00e6b76f0 Binary files /dev/null and b/docs/images/platform/events/add-policy.png differ diff --git a/docs/images/platform/events/copy-generated-token.png b/docs/images/platform/events/copy-generated-token.png new file mode 100644 index 000000000..1fb061017 Binary files /dev/null and b/docs/images/platform/events/copy-generated-token.png differ diff --git a/docs/images/platform/events/generate-auth-token-add.png b/docs/images/platform/events/generate-auth-token-add.png new file mode 100644 index 000000000..2057e2509 Binary files /dev/null and b/docs/images/platform/events/generate-auth-token-add.png differ diff --git a/docs/images/platform/events/generate-auth-token-create.png b/docs/images/platform/events/generate-auth-token-create.png new file mode 100644 index 000000000..9bccbb16f Binary files /dev/null and b/docs/images/platform/events/generate-auth-token-create.png differ diff --git a/docs/images/platform/events/generate-auth-token.png b/docs/images/platform/events/generate-auth-token.png new file mode 100644 index 000000000..40d6336a6 Binary files /dev/null and b/docs/images/platform/events/generate-auth-token.png differ diff --git a/docs/images/platform/events/identity-add-project.png b/docs/images/platform/events/identity-add-project.png new file mode 100644 index 000000000..5fdaa488e Binary files /dev/null and b/docs/images/platform/events/identity-add-project.png differ diff --git a/docs/images/platform/events/identity-add-role.png b/docs/images/platform/events/identity-add-role.png new file mode 100644 index 000000000..c20296cf8 Binary files /dev/null and b/docs/images/platform/events/identity-add-role.png differ diff --git a/docs/images/platform/events/identity-selector.png b/docs/images/platform/events/identity-selector.png new file mode 100644 index 000000000..2aaf7e56b Binary files /dev/null and b/docs/images/platform/events/identity-selector.png differ diff --git a/docs/images/platform/events/org-access-control.png b/docs/images/platform/events/org-access-control.png new file mode 100644 index 000000000..334d6dbc3 Binary files /dev/null and b/docs/images/platform/events/org-access-control.png differ diff --git a/docs/images/platform/events/policy-setting.png b/docs/images/platform/events/policy-setting.png new file mode 100644 index 000000000..cadab9b82 Binary files /dev/null and b/docs/images/platform/events/policy-setting.png differ diff --git a/docs/images/platform/events/postman-sse-response.png b/docs/images/platform/events/postman-sse-response.png new file mode 100644 index 000000000..fea1054b4 Binary files /dev/null and b/docs/images/platform/events/postman-sse-response.png differ diff --git a/docs/images/platform/events/postman-subscribe.png b/docs/images/platform/events/postman-subscribe.png new file mode 100644 index 000000000..308d257c0 Binary files /dev/null and b/docs/images/platform/events/postman-subscribe.png differ diff --git a/docs/images/platform/events/project-access.png b/docs/images/platform/events/project-access.png new file mode 100644 index 000000000..61593befd Binary files /dev/null and b/docs/images/platform/events/project-access.png differ diff --git a/docs/images/platform/events/project-detail.png b/docs/images/platform/events/project-detail.png new file mode 100644 index 000000000..65ff15642 Binary files /dev/null and b/docs/images/platform/events/project-detail.png differ diff --git a/docs/images/platform/events/project-role.png b/docs/images/platform/events/project-role.png new file mode 100644 index 000000000..b6178939f Binary files /dev/null and b/docs/images/platform/events/project-role.png differ diff --git a/docs/images/platform/events/role-detail.png b/docs/images/platform/events/role-detail.png new file mode 100644 index 000000000..7b4d6cea6 Binary files /dev/null and b/docs/images/platform/events/role-detail.png differ diff --git a/docs/images/platform/events/select-project.png b/docs/images/platform/events/select-project.png new file mode 100644 index 000000000..f964bf443 Binary files /dev/null and b/docs/images/platform/events/select-project.png differ diff --git a/docs/images/platform/external-syncs/github-classic-token.png b/docs/images/platform/external-syncs/github-classic-token.png new file mode 100644 index 000000000..f59c4f362 Binary files /dev/null and b/docs/images/platform/external-syncs/github-classic-token.png differ diff --git a/docs/images/platform/external-syncs/github-fine-grained-token.png b/docs/images/platform/external-syncs/github-fine-grained-token.png new file mode 100644 index 000000000..4924fedd0 Binary files /dev/null and b/docs/images/platform/external-syncs/github-fine-grained-token.png differ diff --git a/docs/images/platform/external-syncs/github-manual-sync-button.png b/docs/images/platform/external-syncs/github-manual-sync-button.png new file mode 100644 index 000000000..291b9f033 Binary files /dev/null and b/docs/images/platform/external-syncs/github-manual-sync-button.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-manual-sync-token.png b/docs/images/platform/external-syncs/github-org-sync-manual-sync-token.png new file mode 100644 index 000000000..5f81232cf Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-manual-sync-token.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-manual-sync.png b/docs/images/platform/external-syncs/github-org-sync-manual-sync.png new file mode 100644 index 000000000..3ff168629 Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-manual-sync.png differ diff --git a/docs/images/platform/external-syncs/github-token-config-modal.png b/docs/images/platform/external-syncs/github-token-config-modal.png new file mode 100644 index 000000000..106738d3c Binary files /dev/null and b/docs/images/platform/external-syncs/github-token-config-modal.png differ diff --git a/docs/images/platform/identities/auth-templates/attach-template-form.png b/docs/images/platform/identities/auth-templates/attach-template-form.png new file mode 100644 index 000000000..7ac01d797 Binary files /dev/null and b/docs/images/platform/identities/auth-templates/attach-template-form.png differ diff --git a/docs/images/platform/identities/auth-templates/create-template-button.png b/docs/images/platform/identities/auth-templates/create-template-button.png new file mode 100644 index 000000000..859031ea3 Binary files /dev/null and b/docs/images/platform/identities/auth-templates/create-template-button.png differ diff --git a/docs/images/platform/identities/auth-templates/ldap-template.png b/docs/images/platform/identities/auth-templates/ldap-template.png new file mode 100644 index 000000000..04e9c5a1a Binary files /dev/null and b/docs/images/platform/identities/auth-templates/ldap-template.png differ diff --git a/docs/images/platform/identities/auth-templates/machine-identity-page.png b/docs/images/platform/identities/auth-templates/machine-identity-page.png new file mode 100644 index 000000000..4574afa3f Binary files /dev/null and b/docs/images/platform/identities/auth-templates/machine-identity-page.png differ diff --git a/docs/images/platform/identities/auth-templates/template-usages-modal.png b/docs/images/platform/identities/auth-templates/template-usages-modal.png new file mode 100644 index 000000000..7f53c7a45 Binary files /dev/null and b/docs/images/platform/identities/auth-templates/template-usages-modal.png differ diff --git a/docs/images/platform/identities/auth-templates/template-usages.png b/docs/images/platform/identities/auth-templates/template-usages.png new file mode 100644 index 000000000..67e71b53a Binary files /dev/null and b/docs/images/platform/identities/auth-templates/template-usages.png differ diff --git a/docs/images/platform/identities/auth-templates/templates-section.png b/docs/images/platform/identities/auth-templates/templates-section.png new file mode 100644 index 000000000..a26ebd820 Binary files /dev/null and b/docs/images/platform/identities/auth-templates/templates-section.png differ diff --git a/docs/images/platform/organization/organization-app-connections.png b/docs/images/platform/organization/organization-app-connections.png new file mode 100644 index 000000000..7da9221fd Binary files /dev/null and b/docs/images/platform/organization/organization-app-connections.png differ diff --git a/docs/images/platform/organization/organization-billing.png b/docs/images/platform/organization/organization-billing.png new file mode 100644 index 000000000..498812938 Binary files /dev/null and b/docs/images/platform/organization/organization-billing.png differ diff --git a/docs/images/platform/organization/organization-machine-identities.png b/docs/images/platform/organization/organization-machine-identities.png index 4400b9f62..85657e137 100644 Binary files a/docs/images/platform/organization/organization-machine-identities.png and b/docs/images/platform/organization/organization-machine-identities.png differ diff --git a/docs/images/platform/organization/organization-members-roles-add-perm.png b/docs/images/platform/organization/organization-members-roles-add-perm.png deleted file mode 100644 index 0e87e3e5f..000000000 Binary files a/docs/images/platform/organization/organization-members-roles-add-perm.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-members-roles.png b/docs/images/platform/organization/organization-members-roles.png deleted file mode 100644 index 08c2d1e90..000000000 Binary files a/docs/images/platform/organization/organization-members-roles.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-members.png b/docs/images/platform/organization/organization-members.png deleted file mode 100644 index a79d3bbe0..000000000 Binary files a/docs/images/platform/organization/organization-members.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-projects.png b/docs/images/platform/organization/organization-projects.png index 82c0aee57..b8a294632 100644 Binary files a/docs/images/platform/organization/organization-projects.png and b/docs/images/platform/organization/organization-projects.png differ diff --git a/docs/images/platform/organization/organization-roles.png b/docs/images/platform/organization/organization-roles.png new file mode 100644 index 000000000..04fd0043d Binary files /dev/null and b/docs/images/platform/organization/organization-roles.png differ diff --git a/docs/images/platform/organization/organization-settings-auth.png b/docs/images/platform/organization/organization-settings-auth.png deleted file mode 100644 index fd7a946e0..000000000 Binary files a/docs/images/platform/organization/organization-settings-auth.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-settings-general.png b/docs/images/platform/organization/organization-settings-general.png deleted file mode 100644 index 9467b6005..000000000 Binary files a/docs/images/platform/organization/organization-settings-general.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-settings.png b/docs/images/platform/organization/organization-settings.png new file mode 100644 index 000000000..873852b44 Binary files /dev/null and b/docs/images/platform/organization/organization-settings.png differ diff --git a/docs/images/platform/organization/organization-usage-billing.png b/docs/images/platform/organization/organization-usage-billing.png deleted file mode 100644 index 345a437f7..000000000 Binary files a/docs/images/platform/organization/organization-usage-billing.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-users.png b/docs/images/platform/organization/organization-users.png new file mode 100644 index 000000000..489fd2346 Binary files /dev/null and b/docs/images/platform/organization/organization-users.png differ diff --git a/docs/images/platform/organization/organization.png b/docs/images/platform/organization/organization.png new file mode 100644 index 000000000..f72c53286 Binary files /dev/null and b/docs/images/platform/organization/organization.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png b/docs/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png new file mode 100644 index 000000000..e82b597eb Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png b/docs/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png new file mode 100644 index 000000000..b5666eaf0 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png new file mode 100644 index 000000000..3326f6bac Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png new file mode 100644 index 000000000..3c5ccf079 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png new file mode 100644 index 000000000..685b40492 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png b/docs/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png new file mode 100644 index 000000000..8a007adcd Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png new file mode 100644 index 000000000..61bb140b8 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png new file mode 100644 index 000000000..7e80c3763 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png new file mode 100644 index 000000000..ce7cad5c8 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png differ diff --git a/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png b/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png index ef572bfa7..bc32c23f6 100644 Binary files a/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png and b/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png differ diff --git a/docs/images/platform/project/project-roles.png b/docs/images/platform/project/project-roles.png new file mode 100644 index 000000000..c7ae3f7f3 Binary files /dev/null and b/docs/images/platform/project/project-roles.png differ diff --git a/docs/images/platform/project/project-types.png b/docs/images/platform/project/project-types.png new file mode 100644 index 000000000..8156094f9 Binary files /dev/null and b/docs/images/platform/project/project-types.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-2.png b/docs/images/platform/secret-scanning/gitlab/step-2.png new file mode 100644 index 000000000..416864f6b Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-2.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-3.png b/docs/images/platform/secret-scanning/gitlab/step-3.png new file mode 100644 index 000000000..cc37a64df Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-3.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-4.png b/docs/images/platform/secret-scanning/gitlab/step-4.png new file mode 100644 index 000000000..5e27dfd7e Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-4.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-5.png b/docs/images/platform/secret-scanning/gitlab/step-5.png new file mode 100644 index 000000000..696da80f1 Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-5.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-6.png b/docs/images/platform/secret-scanning/gitlab/step-6.png new file mode 100644 index 000000000..dc1daea17 Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-6.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-7.png b/docs/images/platform/secret-scanning/gitlab/step-7.png new file mode 100644 index 000000000..24df0f6ba Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-7.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-8.png b/docs/images/platform/secret-scanning/gitlab/step-8.png new file mode 100644 index 000000000..019f81c1e Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-8.png differ diff --git a/docs/images/platform/secret-versioning-overview.png b/docs/images/platform/secret-versioning-overview.png new file mode 100644 index 000000000..94a50bd49 Binary files /dev/null and b/docs/images/platform/secret-versioning-overview.png differ diff --git a/docs/images/platform/secret-versioning.png b/docs/images/platform/secret-versioning.png index 593e8c96f..60b57cad9 100644 Binary files a/docs/images/platform/secret-versioning.png and b/docs/images/platform/secret-versioning.png differ diff --git a/docs/images/sdks/languages/php.svg b/docs/images/sdks/languages/php.svg new file mode 100644 index 000000000..37a5e6fe7 --- /dev/null +++ b/docs/images/sdks/languages/php.svg @@ -0,0 +1,96 @@ + + + Official PHP Logo + + + + image/svg+xml + + Official PHP Logo + + + Colin Viebrock + + + + + + + + + + + + Copyright Colin Viebrock 1997 - All rights reserved. + + + 1997 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/images/secret-syncs/checkly/checkly-sync-destination.png b/docs/images/secret-syncs/checkly/checkly-sync-destination.png index bb3df8d94..522418a3e 100644 Binary files a/docs/images/secret-syncs/checkly/checkly-sync-destination.png and b/docs/images/secret-syncs/checkly/checkly-sync-destination.png differ diff --git a/docs/images/secret-syncs/netlify/select-option.png b/docs/images/secret-syncs/netlify/select-option.png new file mode 100644 index 000000000..ee18232fc Binary files /dev/null and b/docs/images/secret-syncs/netlify/select-option.png differ diff --git a/docs/images/secret-syncs/netlify/sync-created.png b/docs/images/secret-syncs/netlify/sync-created.png new file mode 100644 index 000000000..dff4f2c98 Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-created.png differ diff --git a/docs/images/secret-syncs/netlify/sync-destination.png b/docs/images/secret-syncs/netlify/sync-destination.png new file mode 100644 index 000000000..055ca0f69 Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-destination.png differ diff --git a/docs/images/secret-syncs/netlify/sync-details.png b/docs/images/secret-syncs/netlify/sync-details.png new file mode 100644 index 000000000..82c7fac8b Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-details.png differ diff --git a/docs/images/secret-syncs/netlify/sync-options.png b/docs/images/secret-syncs/netlify/sync-options.png new file mode 100644 index 000000000..d91e843df Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-options.png differ diff --git a/docs/images/secret-syncs/netlify/sync-review.png b/docs/images/secret-syncs/netlify/sync-review.png new file mode 100644 index 000000000..3795526f8 Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-review.png differ diff --git a/docs/images/secret-syncs/netlify/sync-source.png b/docs/images/secret-syncs/netlify/sync-source.png new file mode 100644 index 000000000..9f01e3475 Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-source.png differ diff --git a/docs/integrations/app-connections.mdx b/docs/integrations/app-connections.mdx new file mode 100644 index 000000000..9f4b5b80f --- /dev/null +++ b/docs/integrations/app-connections.mdx @@ -0,0 +1,8 @@ +--- +sidebarTitle: "Explore Options" +description: "Browse and search through all available app connections for Infisical." +--- + +import { AppConnectionsBrowser } from "/snippets/AppConnectionsBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/app-connections/azure-adcs.mdx b/docs/integrations/app-connections/azure-adcs.mdx new file mode 100644 index 000000000..7bc174135 --- /dev/null +++ b/docs/integrations/app-connections/azure-adcs.mdx @@ -0,0 +1,70 @@ +--- +title: "Azure ADCS Connection" +description: "Learn how to configure an Azure ADCS Connection for Infisical certificate management." +--- + +Connect Infisical to Microsoft Active Directory Certificate Services (ADCS) for automated certificate issuance and management. + +## Prerequisites + +- Microsoft Active Directory Certificate Services (ADCS) server running and accessible +- Domain administrator account with certificate management permissions +- Network connectivity from Infisical to the ADCS server +- ADCS web enrollment enabled on your server + +## Connection Setup + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **Azure ADCS Connection** option from the connection options modal. + ![Select Azure ADCS Connection](/images/app-connections/azure-adcs/azure-adcs-select-connection.png) + + + **Configuration Tab:** + - **Name**: Friendly name for this ADCS connection (e.g., "Production ADCS") + - **ADCS URL**: Your ADCS HTTPS URL (e.g., `https://adcs.yourdomain.com/certsrv`) - only HTTPS is supported + - **Username**: Domain administrator username (format: `DOMAIN\username` or `username@domain.com`) + - **Password**: Password for the domain administrator account + + **SSL Tab:** + - **SSL Certificate**: Optional PEM certificate for custom CA certificates or self-signed certificates + - **Reject Unauthorized**: Whether to reject connections with invalid SSL certificates (it's recommended to keep it enabled) + + + **Getting the SSL Certificate**: If your ADCS server uses a self-signed certificate or a certificate from a custom CA, you can obtain the certificate using OpenSSL or PowerShell: + + **Using OpenSSL:** + ```bash + openssl s_client -connect your-adcs-server:443 -showcerts + ``` + + **Using PowerShell (from inside your ADCS Server):** + ```powershell + Import-Module WebAdministration -ErrorAction SilentlyContinue + $binding = Get-WebBinding -Protocol https -Port 443 + if ($binding -and $binding.certificateHash) { + $cert = Get-ChildItem -Path "Cert:\LocalMachine\My\$($binding.certificateHash)" + if ($cert) { + $certBase64 = [System.Convert]::ToBase64String($cert.RawData) + $certPem = "-----BEGIN CERTIFICATE-----`n" + ($certBase64 -replace '(.{64})', '$1`n') + "`n-----END CERTIFICATE-----" + Write-Output $certPem + } + } + ``` + + Copy the certificate content (including `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----` lines) and paste it into the SSL Certificate field. + + + And click **Connect to ADCS** to establish the connection. + ![Connect to ADCS](/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png) + ![Connect to ADCS - SSL](/images/app-connections/azure-adcs/azure-adcs-app-connection-form-ssl.png) + + + Your **Azure ADCS Connection** is now available for use in your Infisical projects. ![Azure ADCS Connection Created](/images/app-connections/azure-adcs/azure-adcs-app-connection-created.png) + + + diff --git a/docs/integrations/app-connections/azure-app-configuration.mdx b/docs/integrations/app-connections/azure-app-configuration.mdx index 679839878..4efd19f30 100644 --- a/docs/integrations/app-connections/azure-app-configuration.mdx +++ b/docs/integrations/app-connections/azure-app-configuration.mdx @@ -3,7 +3,7 @@ title: "Azure App Configuration Connection" description: "Learn how to configure a Azure App Configuration Connection for Infisical." --- -Infisical currently only supports one method for connecting to Azure, which is OAuth. +Infisical currently only supports two methods for connecting to Azure, which are OAuth and Client Secrets. Using the Azure App Configuration connection on a self-hosted instance of Infisical requires configuring an application in Azure @@ -58,6 +58,26 @@ Infisical currently only supports one method for connecting to Azure, which is O
+ + To use client secret authentication, ensure your Azure Service Principal has the required permissions and is connected to the Azure App Configuration resources you want to use. + + **Prerequisites:** + - Set up Azure and have an existing App Configuration instance. + - The service principal must be connected to your target Azure App Configuration resource(s) + + + + + Configure the required API permissions for your App Registration to interact with Azure App Configuration: + + #### Azure App Configuration permissions + + Set the API permissions of your Azure service principal to include the following Azure App Configuration permissions: `KeyValue.Delete`, `KeyValue.Read`, and `KeyValue.Write`. + + ![Azure app config](../../images/integrations/azure-app-configuration/app-api-permissions.png) + + + ## Setup Azure Connection in Infisical @@ -66,25 +86,35 @@ Infisical currently only supports one method for connecting to Azure, which is O Navigate to the **App Connections** tab on the **Organization Settings** page. ![App Connections Tab](/images/app-connections/general/add-connection.png) - - Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/app-configuration/select-connection.png) - - - You can optionally authenticate against a specific tenant by providing the Azure Tenant or Directory ID. + + Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/app-configuration/select-connection.png) + + + + + + You can optionally authenticate against a specific tenant by providing the Azure Tenant or Directory ID. - Now select the **OAuth** method and click **Connect to Azure**. + Now select the **OAuth** method and click **Connect to Azure**. - ![Connect via Azure OAUth](/images/app-connections/azure/app-configuration/create-oauth-method.png) + ![Connect via Azure OAUth](/images/app-connections/azure/app-configuration/create-oauth-method.png) + + + You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, + you will redirect you back to Infisical's App Connections page. ![Azure App Configuration + Authorization](/images/app-connections/azure/grant-access.png) + + + + + Fill in the **Tenant ID**, **Client ID** and **Client Secret** fields with the Directory (Tenant) ID, Application (Client) ID and Client Secret you obtained in the previous step. - - - - - You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, - you will redirect you back to Infisical's App Connections page. ![Azure App Configuration - Authorization](/images/app-connections/azure/grant-access.png) - - + ![Connect via Azure OAUth](/images/app-connections/azure/app-configuration/create-client-secrets-method.png) + + + + + Your **Azure App Configuration Connection** is now available for use. ![Assume Role AWS Connection](/images/app-connections/azure/app-configuration/oauth-connection.png) diff --git a/docs/integrations/app-connections/azure-client-secrets.mdx b/docs/integrations/app-connections/azure-client-secrets.mdx index 1fb1c753f..37e7d49b0 100644 --- a/docs/integrations/app-connections/azure-client-secrets.mdx +++ b/docs/integrations/app-connections/azure-client-secrets.mdx @@ -3,7 +3,7 @@ title: "Azure Client Secrets Connection" description: "Learn how to configure an Azure Client Secrets Connection for Infisical." --- -Infisical currently only supports one method for connecting to Azure, which is OAuth. +Infisical currently only supports two methods for connecting to Azure, which are OAuth and Client Secrets. Using the Azure Client Secrets connection on a self-hosted instance of Infisical requires configuring an application in Azure diff --git a/docs/integrations/app-connections/azure-devops.mdx b/docs/integrations/app-connections/azure-devops.mdx index 6a9e71430..4744a35c6 100644 --- a/docs/integrations/app-connections/azure-devops.mdx +++ b/docs/integrations/app-connections/azure-devops.mdx @@ -3,7 +3,7 @@ title: "Azure DevOps Connection" description: "Learn how to configure an Azure DevOps Connection for Infisical." --- -Infisical currently supports two methods for connecting to Azure DevOps, which are OAuth and Azure DevOps Personal Access Token. +Infisical currently supports three methods for connecting to Azure DevOps, which are OAuth, Azure DevOps Personal Access Token and Client Secrets. Using the Azure DevOps OAuth connection on a self-hosted instance of Infisical requires configuring an application in Azure @@ -87,6 +87,32 @@ Infisical currently supports two methods for connecting to Azure DevOps, which a + + To use client secret authentication, ensure your Azure Service Principal has the required permissions and is connected to the Azure DevOps organization and projects you want to use. + + **Prerequisites:** + - Set up Azure and have an existing Azure DevOps organization. + - The service principal must be connected to your target Azure DevOps organization and project(s) + + + + + Configure the required API permissions for your App Registration to interact with Azure DevOps: + + #### Azure DevOps permissions + + Set the API permissions of your Azure service principal to include the following Azure DevOps permissions: + - Azure DevOps + - `user_impersonation` + - `vso.project_write` + - `vso.variablegroups_manage` + - `vso.variablegroups_write` + + ![Azure devops](/images/integrations/azure-devops/app-api-permissions.png) + + + + ## Setup Azure Connection in Infisical @@ -129,6 +155,17 @@ Infisical currently supports two methods for connecting to Azure DevOps, which a + + + + Fill in the **Tenant ID**, **Client ID**, **Client Secret** and **Organization Name** fields with the Directory (Tenant) ID, Application (Client) ID, Client Secret and the organization name you obtained in the previous step. + + You can find the **Organization Name** on https://dev.azure.com/ + + ![Connect via Azure OAUth](/images/app-connections/azure/devops/create-client-secrets-method.png) + + + diff --git a/docs/integrations/app-connections/azure-key-vault.mdx b/docs/integrations/app-connections/azure-key-vault.mdx index 22cdcf637..866a1de82 100644 --- a/docs/integrations/app-connections/azure-key-vault.mdx +++ b/docs/integrations/app-connections/azure-key-vault.mdx @@ -3,7 +3,7 @@ title: "Azure Key Vault Connection" description: "Learn how to configure a Azure Key Vault Connection for Infisical." --- -Infisical currently only supports one method for connecting to Azure, which is OAuth. +Infisical currently only supports two methods for connecting to Azure, which are OAuth and Client Secrets. Using the Azure Key Vault connection on a self-hosted instance of Infisical requires configuring an application in Azure @@ -58,6 +58,27 @@ Infisical currently only supports one method for connecting to Azure, which is O + + To use client secret authentication, ensure your Azure Service Principal has the required permissions and is connected to the Azure Key Vault instances you want to use. + + **Prerequisites:** + - Set up Azure and have an existing Key Vault instance. + - The service principal must be connected to your target Azure Key Vault instance(s) + + + + + Configure the required API permissions for your App Registration to interact with Azure Key Vault: + + #### Azure Key Vault permissions + + Set the API permissions of your Azure service principal to include `user_impersonation` for the Key Vault API. + ![Azure key vault](/images/app-connections/azure/keyvault-azure-permissions.png) + + + + + ## Setup Azure Connection in Infisical @@ -68,21 +89,37 @@ Infisical currently only supports one method for connecting to Azure, which is O Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/key-vault/select-connection.png) - - You can optionally authenticate against a specific tenant by providing the Azure Tenant or Directory ID. + + + + + + You can optionally authenticate against a specific tenant by providing the Azure Tenant or Directory ID. - Now select the **OAuth** method and click **Connect to Azure**. + Now select the **OAuth** method and click **Connect to Azure**. - ![Connect via Azure OAUth](/images/app-connections/azure/key-vault/create-oauth-method.png) + ![Connect via Azure OAUth](/images/app-connections/azure/key-vault/create-oauth-method.png) - + - - - You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, - you will redirect you back to Infisical's App Connections page. ![Azure Key Vault - Authorization](/images/app-connections/azure/grant-access.png) - + + + You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, + you will redirect you back to Infisical's App Connections page. ![Azure Key Vault + Authorization](/images/app-connections/azure/grant-access.png) + + + + + + + Fill in the **Tenant ID**, **Client ID**, **Client Secret** fields with the Directory (Tenant) ID, Application (Client) ID, Client Secret you obtained in the previous step. + ![Connect via Azure OAUth](/images/app-connections/azure/key-vault/create-client-secrets-method.png) + + + + + Your **Azure Key Vault Connection** is now available for use. ![Assume Role AWS Connection](/images/app-connections/azure/key-vault/oauth-connection.png) diff --git a/docs/integrations/app-connections/cloudflare.mdx b/docs/integrations/app-connections/cloudflare.mdx index 6d79d2567..241c737bc 100644 --- a/docs/integrations/app-connections/cloudflare.mdx +++ b/docs/integrations/app-connections/cloudflare.mdx @@ -50,6 +50,17 @@ Infisical supports connecting to Cloudflare using API tokens and Account ID for + + Use the following permissions to grant Infisical access to verify certificates using DNS TXT records with ACME: + + ![Configure Token](/images/app-connections/cloudflare/cloudflare-dns-configure-permissions.png) + + **Required Permissions:** + - **Account** - **Account Settings** - **Read** + - **Zone** - **DNS** - **Edit** + + Add these permissions to your API token and click **Continue to summary**, then **Create Token** to generate your API token. + diff --git a/docs/integrations/app-connections/github.mdx b/docs/integrations/app-connections/github.mdx index 8f4283ae9..9a952f815 100644 --- a/docs/integrations/app-connections/github.mdx +++ b/docs/integrations/app-connections/github.mdx @@ -94,6 +94,11 @@ Infisical supports two methods for connecting to GitHub. Select the **GitHub App** method and click **Connect to GitHub**. + + You may optionally configure GitHub Enterprise options: + - **Gateway:** The gateway connected to your private network + - **Hostname:** The hostname at which to access your GitHub Enterprise instance + ![Connect via GitHub App](/images/app-connections/github/create-github-app-method.png) diff --git a/docs/integrations/app-connections/gitlab.mdx b/docs/integrations/app-connections/gitlab.mdx index b81c0fbcd..60e9236ca 100644 --- a/docs/integrations/app-connections/gitlab.mdx +++ b/docs/integrations/app-connections/gitlab.mdx @@ -26,8 +26,22 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access Create the application. As part of the form, set the **Redirect URI** to `https://your-domain.com/organization/app-connections/gitlab/oauth/callback`. - ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-top.png) - ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-bottom.png) + Depending on your use case, add one or more of the following scopes to your application: + + + + For Secret Syncs, your application will require the `api` scope: + + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-top-secret-sync.png) + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-bottom-secret-sync.png) + + + For Secret Scanning, your application will require the `api` and `read_repository` scopes: + + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-top-secret-scanning.png) + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-bottom-secret-scanning.png) + + The domain you defined in the Redirect URI should be equivalent to the `SITE_URL` configured in your Infisical instance. @@ -96,16 +110,22 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access ![GitLab Personal Access Tokens](/images/app-connections/gitlab/gitlab-add-access-token.png) - - - For Secret Syncs, your token will require the ability to access the API: Fill in the token details: - **Token name**: A descriptive name for the token (e.g., "connection-token") - **Expiration date**: Set an appropriate expiration date - - **Select scopes**: Choose the **api** scope for full API access + - **Select scopes**: Depending on your use case, add one or more of the following scopes: - ![GitLab Personal Token Form](/images/app-connections/gitlab/gitlab-personal-access-token-form.png) + + + For Secret Syncs, your token will require the `api` scope: + + ![GitLab Personal Token Form](/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-sync.png) + + For Secret Scanning, your token will require the `api` and `read_repository` scopes: + + ![GitLab Personal Token Form](/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-scanning.png) + @@ -134,17 +154,22 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access ![GitLab Project Access Tokens](/images/app-connections/gitlab/gitlab-project-access-token-list.png) - - - For Secret Syncs, your token will require the ability to access the API and be at least an **Owner**: - Fill in the token details: - - **Token name**: A descriptive name for the token - - **Expiration date**: Set an appropriate expiration date - - **Select role**: Choose **Owner** or higher role - - **Select scopes**: Choose the **api** scope for API access + Fill in the token details: + - **Token name**: A descriptive name for the token + - **Expiration date**: Set an appropriate expiration date + - **Select role and scopes**: Depending on your use case, add the required role and one or more of the following scopes: - ![GitLab Create Project Token](/images/app-connections/gitlab/gitlab-project-access-token-form.png) - + + + For Secret Syncs, your token will require the `api` scope and at least the **Owner** role: + + ![GitLab Create Project Token](/images/app-connections/gitlab/gitlab-project-access-token-form-secret-sync.png) + + + For Secret Scanning, your token will require the `api` and `read_repository` scopes and the **Maintainer** role: + + ![GitLab Create Project Token](/images/app-connections/gitlab/gitlab-project-access-token-form-secret-scanning.png) + diff --git a/docs/integrations/app-connections/hashicorp-vault.mdx b/docs/integrations/app-connections/hashicorp-vault.mdx index 6e2ff68bf..c49b53ab8 100644 --- a/docs/integrations/app-connections/hashicorp-vault.mdx +++ b/docs/integrations/app-connections/hashicorp-vault.mdx @@ -149,6 +149,7 @@ Infisical supports two methods for connecting to Hashicorp Vault. - **Name**: The name of the connection being created. Must be slug-friendly. - **Description**: An optional description to provide details about this connection. + - **Gateway (optional):** The gateway connected to your private network. All requests made to your Vault instance will be made through the configured gateway. - **Instance URL**: The URL of your Hashicorp Vault instance. - **Namespace (optional)**: The namespace within your vault. Self-hosted and enterprise clusters may not use namespaces. - **Role ID**: The Role ID generated in the steps above. @@ -157,6 +158,7 @@ Infisical supports two methods for connecting to Hashicorp Vault. - **Name**: The name of the connection being created. Must be slug-friendly. - **Description**: An optional description to provide details about this connection. + - **Gateway (optional):** The gateway connected to your private network. All requests made to your Vault instance will be made through the configured gateway. - **Instance URL**: The URL of your Hashicorp Vault instance. - **Namespace (optional)**: The namespace within your vault. Self-hosted and enterprise clusters may not use namespaces. - **Access Token**: The Access Token generated in the steps above. diff --git a/docs/integrations/app-connections/netlify.mdx b/docs/integrations/app-connections/netlify.mdx new file mode 100644 index 000000000..cd4dfb1b5 --- /dev/null +++ b/docs/integrations/app-connections/netlify.mdx @@ -0,0 +1,102 @@ +--- +title: "Netlify Connection" +description: "Learn how to configure a Netlify Connection for Infisical." +--- + +Infisical supports the use of [Personal Access Tokens](https://docs.netlify.com/api/get-started/#get-access-tokens) to connect with Netlify. + + Netlify requires the token to have **full access** to enable secret management for your sites and services. + + +## Create a Netlify Personal Access Token + + + + ![Netlify User Settings](/images/app-connections/netlify/app-connection-profile.png) + + + ![Applications Tab](/images/app-connections/netlify/app-connection-create-api-key.png) + + + Provide a name for your token and generate it. + + ![Token Form](/images/app-connections/netlify/app-connection-create-form.png) + + + Make sure to copy the token nowโ€”you wonโ€™t be able to access it again. + + ![Token Generated](/images/app-connections/netlify/app-connection-key-generated.png) + + + +## Create a Netlify Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Netlify Connection** from the list of integrations. + + ![Select Netlify Connection](/images/app-connections/netlify/app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - The API Token from the previous step + + ![Netlify Connection Modal](/images/app-connections/netlify/app-connection-form.png) + + + After submitting the form, your **Netlify Connection** will be successfully created and ready to use with your Infisical projects. + + ![Netlify Connection Created](/images/app-connections/netlify/app-connection-generated.png) + + + + + + To create a Netlify Connection via API, send a request to the [Create Netlify Connection](/api-reference/endpoints/app-connections/netlify/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/netlify \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-netlify-connection", + "method": "access-token", + "credentials": { + "accessToken": "[ACCESS TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "name": "my-netlify-connection", + "description": null, + "version": 1, + "orgId": "abcdef12-3456-7890-abcd-ef1234567890", + "createdAt": "2025-07-19T10:15:00.000Z", + "updatedAt": "2025-07-19T10:15:00.000Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "d41d8cd98f00b204e9800998ecf8427e", + "app": "netlify", + "method": "access-token", + "credentials": {} + } + } + ``` + + diff --git a/docs/integrations/app-connections/overview.mdx b/docs/integrations/app-connections/overview.mdx index 92e6ab9d2..e698c4302 100644 --- a/docs/integrations/app-connections/overview.mdx +++ b/docs/integrations/app-connections/overview.mdx @@ -79,4 +79,4 @@ in the UI or by passing the associated `connectionId` when generating resources ## Platform Managed Credentials Some App Connections support the ability to have their credentials managed by Infisical. By enabling this option, -Infisical will modify the credentials to prevent external use of the configured access entity. \ No newline at end of file +Infisical will modify the credentials to prevent external use of the configured access entity. diff --git a/docs/integrations/dynamic-secrets.mdx b/docs/integrations/dynamic-secrets.mdx new file mode 100644 index 000000000..84ff43ab5 --- /dev/null +++ b/docs/integrations/dynamic-secrets.mdx @@ -0,0 +1,9 @@ +--- +title: "Dynamic Secrets" +sidebarTitle: "Explore Options" +description: "Browse and search through all available dynamic secrets for Infisical." +--- + +import { DynamicSecretsBrowser } from "/snippets/DynamicSecretsBrowser.jsx"; + + diff --git a/docs/integrations/framework-integrations.mdx b/docs/integrations/framework-integrations.mdx new file mode 100644 index 000000000..dfe78119a --- /dev/null +++ b/docs/integrations/framework-integrations.mdx @@ -0,0 +1,9 @@ +--- +title: "Framework Integrations" +sidebarTitle: "Explore Options" +description: "Browse and search through all available framework integrations for Infisical." +--- + +import { FrameworkIntegrationsBrowser } from "/snippets/FrameworkIntegrationsBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/frameworks/pulumi.mdx b/docs/integrations/frameworks/pulumi.mdx index 11a8e0cb7..c2dbdb103 100644 --- a/docs/integrations/frameworks/pulumi.mdx +++ b/docs/integrations/frameworks/pulumi.mdx @@ -7,7 +7,7 @@ Infisical can be integrated with Pulumi by leveraging Pulumiโ€™s [Terraform Brid which allows Terraform providers to be used seamlessly within Pulumi projects. This enables infrastructure and platform teams to manage Infisical secrets and resources using Pulumiโ€™s familiar programming languages (including TypeScript, Python, Go, and C#), without any change to existing workflows. -The Terraform Bridge wraps the [Infisical Terraform provider](/integrations/frameworks/terraform) and exposes its resources (such as `infisical_secret`, `infisical_project`, and `infisical_service_token`) +The Terraform Bridge wraps the [Infisical Terraform provider](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) and exposes its resources (such as `infisical_secret`, `infisical_project`, and `infisical_service_token`) in a Pulumi-compatible interface. This makes it easy to integrate secret management directly into Pulumi-based IaC pipelines, ensuring secrets stay in sync with the rest of your cloud infrastructure. Authentication is handled through the same methods as Terraform: using environment variables such as `INFISICAL_TOKEN` and `INFISICAL_SITE_URL`. diff --git a/docs/integrations/frameworks/terraform.mdx b/docs/integrations/frameworks/terraform.mdx index 898a77b00..313132d0b 100644 --- a/docs/integrations/frameworks/terraform.mdx +++ b/docs/integrations/frameworks/terraform.mdx @@ -1,8 +1,9 @@ --- title: "Terraform" description: "Learn how to fetch secrets from Infisical with Terraform using both traditional data sources and ephemeral resources" +url: "https://registry.terraform.io/providers/Infisical/infisical/latest/docs" --- - +{/* This guide demonstrates how to use Infisical to manage secrets in your Terraform infrastructure code, supporting both traditional data sources and ephemeral resources for enhanced security. It uses: - Infisical (you can use [Infisical Cloud](https://app.infisical.com) or a [self-hosted instance of Infisical](https://infisical.com/docs/self-hosting/overview)) to store your secrets @@ -234,4 +235,4 @@ For detailed instructions on setting up OIDC authentication with GitHub Actions, See also: - [Machine Identity setup guide](/documentation/platform/identities/machine-identities) - [Terraform Provider Registry](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) -- [GitOps Best Practices](https://www.infisical.com/blog/gitops-best-practices) +- [GitOps Best Practices](https://www.infisical.com/blog/gitops-best-practices) */} diff --git a/docs/integrations/machine-authentication.mdx b/docs/integrations/machine-authentication.mdx new file mode 100644 index 000000000..da4ba1042 --- /dev/null +++ b/docs/integrations/machine-authentication.mdx @@ -0,0 +1,9 @@ +--- +title: "Machine Authentication" +sidebarTitle: "Explore Options" +description: "Browse and search through all available machine authentication methods for Infisical." +--- + +import { MachineAuthenticationBrowser } from "/snippets/MachineAuthenticationBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 5dc06daed..ed7d47b30 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -7,55 +7,55 @@ Integrations allow environment variables to be synced from Infisical into your l Missing an integration? [Throw in a request](https://github.com/Infisical/infisical/issues). -| Integration | Type | Status | -| -------------------------------------------------------------- | ---------------------- | ----------- | -| [Docker](/integrations/platforms/docker) | Platform | Available | -| [Docker-Compose](/integrations/platforms/docker-compose) | Platform | Available | -| [Kubernetes](/integrations/platforms/kubernetes) | Platform | Available | -| [Terraform](/integrations/frameworks/terraform) | Infrastructure as code | Available | -| [PM2](/integrations/platforms/pm2) | Platform | Available | -| [Heroku](/integrations/cloud/heroku) | Cloud | Available | -| [Vercel](/integrations/cloud/vercel) | Cloud | Available | -| [Netlify](/integrations/cloud/netlify) | Cloud | Available | -| [Render](/integrations/cloud/render) | Cloud | Available | -| [Laravel Forge](/integrations/cloud/laravel-forge) | Cloud | Available | -| [Railway](/integrations/cloud/railway) | Cloud | Available | -| [Terraform Cloud](/integrations/cloud/terraform-cloud) | Cloud | Available | -| [TeamCity](/integrations/cloud/teamcity) | Cloud | Available | -| [Fly.io](/integrations/cloud/flyio) | Cloud | Available | -| [Supabase](/integrations/cloud/supabase) | Cloud | Available | -| [Northflank](/integrations/cloud/northflank) | Cloud | Available | -| [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | -| [Cloudflare Workers](/integrations/cloud/cloudflare-workers) | Cloud | Available | -| [Checkly](/integrations/cloud/checkly) | Cloud | Available | -| [Qovery](/integrations/cloud/qovery) | Cloud | Available | -| [HashiCorp Vault](/integrations/cloud/hashicorp-vault) | Cloud | Available | -| [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | -| [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | -| [Azure Key Vault](/integrations/cloud/azure-key-vault) | Cloud | Available | -| [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | -| [Windmill](/integrations/cloud/windmill) | Cloud | Available | -| [Bitbucket](/integrations/cicd/bitbucket) | CI/CD | Available | -| [Codefresh](/integrations/cicd/codefresh) | CI/CD | Available | -| [GitHub Actions](/integrations/cicd/githubactions) | CI/CD | Available | -| [GitLab](/integrations/cicd/gitlab) | CI/CD | Available | -| [CircleCI](/integrations/cicd/circleci) | CI/CD | Available | -| [Travis CI](/integrations/cicd/travisci) | CI/CD | Available | -| [Rundeck](/integrations/cicd/rundeck) | CI/CD | Available | -| [Octopus Deploy](/integrations/cicd/octopus-deploy) | CI/CD | Available | -| [React](/integrations/frameworks/react) | Framework | Available | -| [Vue](/integrations/frameworks/vue) | Framework | Available | -| [Express](/integrations/frameworks/express) | Framework | Available | -| [Next.js](/integrations/frameworks/nextjs) | Framework | Available | -| [NestJS](/integrations/frameworks/nestjs) | Framework | Available | -| [SvelteKit](/integrations/frameworks/sveltekit) | Framework | Available | -| [Nuxt](/integrations/frameworks/nuxt) | Framework | Available | -| [Gatsby](/integrations/frameworks/gatsby) | Framework | Available | -| [Remix](/integrations/frameworks/remix) | Framework | Available | -| [Vite](/integrations/frameworks/vite) | Framework | Available | -| [Fiber](/integrations/frameworks/fiber) | Framework | Available | -| [Django](/integrations/frameworks/django) | Framework | Available | -| [Flask](/integrations/frameworks/flask) | Framework | Available | -| [Laravel](/integrations/frameworks/laravel) | Framework | Available | -| [Ruby on Rails](/integrations/frameworks/rails) | Framework | Available | -| Jenkins | CI/CD | Available | +| Integration | Type | Status | +| ------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------- | +| [Docker](/integrations/platforms/docker) | Platform | Available | +| [Docker-Compose](/integrations/platforms/docker-compose) | Platform | Available | +| [Kubernetes](/integrations/platforms/kubernetes) | Platform | Available | +| [Terraform](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) | Infrastructure as code | Available | +| [PM2](/integrations/platforms/pm2) | Platform | Available | +| [Heroku](/integrations/cloud/heroku) | Cloud | Available | +| [Vercel](/integrations/cloud/vercel) | Cloud | Available | +| [Netlify](/integrations/cloud/netlify) | Cloud | Available | +| [Render](/integrations/cloud/render) | Cloud | Available | +| [Laravel Forge](/integrations/cloud/laravel-forge) | Cloud | Available | +| [Railway](/integrations/cloud/railway) | Cloud | Available | +| [Terraform Cloud](/integrations/cloud/terraform-cloud) | Cloud | Available | +| [TeamCity](/integrations/cloud/teamcity) | Cloud | Available | +| [Fly.io](/integrations/cloud/flyio) | Cloud | Available | +| [Supabase](/integrations/cloud/supabase) | Cloud | Available | +| [Northflank](/integrations/cloud/northflank) | Cloud | Available | +| [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | +| [Cloudflare Workers](/integrations/cloud/cloudflare-workers) | Cloud | Available | +| [Checkly](/integrations/cloud/checkly) | Cloud | Available | +| [Qovery](/integrations/cloud/qovery) | Cloud | Available | +| [HashiCorp Vault](/integrations/cloud/hashicorp-vault) | Cloud | Available | +| [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | +| [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | +| [Azure Key Vault](/integrations/cloud/azure-key-vault) | Cloud | Available | +| [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | +| [Windmill](/integrations/cloud/windmill) | Cloud | Available | +| [Bitbucket](/integrations/cicd/bitbucket) | CI/CD | Available | +| [Codefresh](/integrations/cicd/codefresh) | CI/CD | Available | +| [GitHub Actions](/integrations/cicd/githubactions) | CI/CD | Available | +| [GitLab](/integrations/cicd/gitlab) | CI/CD | Available | +| [CircleCI](/integrations/cicd/circleci) | CI/CD | Available | +| [Travis CI](/integrations/cicd/travisci) | CI/CD | Available | +| [Rundeck](/integrations/cicd/rundeck) | CI/CD | Available | +| [Octopus Deploy](/integrations/cicd/octopus-deploy) | CI/CD | Available | +| [React](/integrations/frameworks/react) | Framework | Available | +| [Vue](/integrations/frameworks/vue) | Framework | Available | +| [Express](/integrations/frameworks/express) | Framework | Available | +| [Next.js](/integrations/frameworks/nextjs) | Framework | Available | +| [NestJS](/integrations/frameworks/nestjs) | Framework | Available | +| [SvelteKit](/integrations/frameworks/sveltekit) | Framework | Available | +| [Nuxt](/integrations/frameworks/nuxt) | Framework | Available | +| [Gatsby](/integrations/frameworks/gatsby) | Framework | Available | +| [Remix](/integrations/frameworks/remix) | Framework | Available | +| [Vite](/integrations/frameworks/vite) | Framework | Available | +| [Fiber](/integrations/frameworks/fiber) | Framework | Available | +| [Django](/integrations/frameworks/django) | Framework | Available | +| [Flask](/integrations/frameworks/flask) | Framework | Available | +| [Laravel](/integrations/frameworks/laravel) | Framework | Available | +| [Ruby on Rails](/integrations/frameworks/rails) | Framework | Available | +| Jenkins | CI/CD | Available | diff --git a/docs/integrations/platforms/ansible.mdx b/docs/integrations/platforms/ansible.mdx index 321dbec6e..85f63079f 100644 --- a/docs/integrations/platforms/ansible.mdx +++ b/docs/integrations/platforms/ansible.mdx @@ -27,22 +27,73 @@ $ 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 You can either call modules by their Fully Qualified Collection Name (FQCN), such as `infisical.vault.read_secrets`, or you can call modules by their short name if you list the `infisical.vault` collection in the playbook's collections keyword: +### Authentication -```bash +The Infisical Ansible Collection supports [Universal Auth](/documentation/platform/identities/universal-auth) and [OIDC](/documentation/platform/identities/oidc-auth/general) for authenticating against Infisical. + + + + + Using Universal Auth for authentication is the most straight-forward way to get started with using the Ansible collection. + + To use Universal Auth, you need to provide the Client ID and Client Secret of your Infisical Machine Identity. + + ```yaml + lookup('infisical.vault.read_secrets', auth_method="universal-auth", universal_auth_client_id='', universal_auth_client_secret='' ...rest) + ``` + + You can also provide the `auth_method`, `universal_auth_client_id`, and `universal_auth_client_secret` parameters through environment variables: + + | Parameter Name | Environment Variable Name | + | ------------------------------ | ---------------------------------------- | + | `auth_method` | `INFISICAL_AUTH_METHOD` | + | `universal_auth_client_id` | `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` | + | `universal_auth_client_secret` | `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` | + + + + To use OIDC Auth, you'll need to provide the ID of your machine identity, and the OIDC JWT to be used for authentication. + + + Please note that in order to use OIDC Auth, you must have `1.0.10` or newer of the `infisicalsdk` package installed. + + + ```yaml + lookup('infisical.vault.read_secrets', auth_method="oidc-auth", identity_id='', jwt='' ...rest) + ``` + You can also provide the `auth_method`, `identity_id`, and `jwt` parameters through environment variables: + + | Parameter Name | Environment Variable Name | + | --------------- | ------------------------- | + | auth_method | `INFISICAL_AUTH_METHOD` | + | identity_id | `INFISICAL_IDENTITY_ID` | + | jwt | `INFISICAL_JWT` | + + + + +### Examples + +```yaml --- 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/docker.mdx b/docs/integrations/platforms/docker.mdx index 8e429461a..7b46e8495 100644 --- a/docs/integrations/platforms/docker.mdx +++ b/docs/integrations/platforms/docker.mdx @@ -6,32 +6,10 @@ description: "Learn how to use Infisical to inject environment variables into a This approach allows you to inject secrets from Infisical directly into your application. This is achieved by installing the Infisical CLI into your docker image and modifying your start command to execute with Infisical. -## Add the Infisical CLI to your Dockerfile +## Install the Infisical CLI to your Dockerfile - - - ```dockerfile - RUN apk add --no-cache bash curl && curl -1sLf \ - 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \ - && apk add infisical - ``` +To install the CLI, follow the instructions for your chosen distribution [here](/cli/overview). - - - ```dockerfile - RUN curl -1sLf \ - 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.rpm.sh' | sh \ - && yum install -y infisical - ``` - - - ```dockerfile - RUN apt-get update && apt-get install -y bash curl && curl -1sLf \ - 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash \ - && apt-get update && apt-get install -y infisical - ``` - - #### We recommend you to set the version of the CLI to a specific version. This will help keep your CLI version consistent across reinstalls. [View versions](https://cloudsmith.io/~infisical/repos/infisical-cli/packages/) diff --git a/docs/integrations/platforms/kubernetes-injector.mdx b/docs/integrations/platforms/kubernetes-injector.mdx index aacdcfbbb..7fea3dc92 100644 --- a/docs/integrations/platforms/kubernetes-injector.mdx +++ b/docs/integrations/platforms/kubernetes-injector.mdx @@ -105,44 +105,93 @@ The templates hold an array of templates that will be rendered and injected into ### Authentication -The Infisical Agent Injector only supports Machine Identity [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) authentication at the moment. +The Infisical Agent Injector supports Machine Identity [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) and [LDAP Auth](/documentation/platform/identities/ldap-auth) authentication. -To configure Kubernetes Auth, you need to set the `auth.type` field to `kubernetes` and set the `auth.config.identity-id` to the ID of the machine identity you wish to use for authentication. -```yaml -auth: - type: "kubernetes" - config: - identity-id: "" -``` + + -### Example ConfigMap -```yaml config-map.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: demo-config-map -data: - config.yaml: | - infisical: - address: "https://app.infisical.com" - auth: - type: "kubernetes" - config: - identity-id: "" - templates: - - destination-path: "/path/to/save/secrets/file.txt" - template-content: | - {{- with secret "" "dev" "/" }} - {{- range . }} - {{ .Key }}={{ .Value }} - {{- end }} - {{- end }} -``` + To configure Kubernetes Auth, you need to set the `auth.type` field to `kubernetes` and set the `auth.config.identity-id` to the ID of the machine identity you wish to use for authentication. + ```yaml + auth: + type: "kubernetes" + config: + identity-id: "" + ``` -```bash -kubectl apply -f config-map.yaml -``` + ### Example ConfigMap + ```yaml config-map.yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: demo-config-map + data: + config.yaml: | + infisical: + address: "https://app.infisical.com" + auth: + type: "kubernetes" + config: + identity-id: "" + templates: + - destination-path: "/path/to/save/secrets/file.txt" + template-content: | + {{- with secret "" "dev" "/" }} + {{- range . }} + {{ .Key }}={{ .Value }} + {{- end }} + {{- end }} + ``` + + ```bash + kubectl apply -f config-map.yaml + ``` + + + + To configure LDAP Auth, you need to set the `auth.type` field to `ldap-auth` and set the `auth.config.identity-id` to the ID of the machine identity you wish to use for authentication. Configure the `auth.config.username` and `auth.config.password` to the username and password of the LDAP user to authenticate with. + + ```yaml + auth: + type: "ldap-auth" + config: + identity-id: "" + username: "" + password: "" + ``` + + ### Example ConfigMap + ```yaml config-map.yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: demo-config-map + data: + config.yaml: | + infisical: + address: "https://app.infisical.com" + auth: + type: "ldap-auth" + config: + identity-id: "" + username: "" + password: "" + templates: + - destination-path: "/path/to/save/secrets/file.txt" + template-content: | + {{- with secret "" "dev" "/" }} + {{- range . }} + {{ .Key }}={{ .Value }} + {{- end }} + {{- end }} + ``` + + ```bash + kubectl apply -f config-map.yaml + ``` + + + To use the config map in your pod, you will need to add the `org.infisical.com/agent-config-map` annotation to your pod's deployment. The value of the annotation is the name of the config map you created above. ```yaml diff --git a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx index 5962e4c10..76dd24cf7 100644 --- a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx @@ -68,6 +68,11 @@ spec: serviceAccountKeyFilePath: gcpIdTokenAuth: identityId: + ldapAuth: + identityId: + credentialsRef: + secretName: # ldap-auth-credentials + secretNamespace: # default kubernetesAuth: identityId: serviceAccountRef: @@ -105,15 +110,15 @@ kind: Secret ### InfisicalDynamicSecret CRD properties - If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to - ` https://your-self-hosted-instace.com/api` + If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to + `https://your-self-hosted-instace.com/api` When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. - If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. + If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. To achieve this, use the following address for the hostAPI field: - + ``` bash http://..svc.cluster.local:4000/api ``` @@ -126,18 +131,19 @@ When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. The `leaseTTL` is a string-formatted duration that defines the time the lease should last for the dynamic secret. - The format of the field is `[duration][unit]` where `duration` is a number and `unit` is a string representing the unit of time. +The format of the field is `[duration][unit]` where `duration` is a number and `unit` is a string representing the unit of time. - The following units are supported: +The following units are supported: - - `s` for seconds (must be at least 5 seconds) - - `m` for minutes - - `h` for hours - - `d` for days +- `s` for seconds (must be at least 5 seconds) +- `m` for minutes +- `h` for hours +- `d` for days - - The lease duration at most be 1 day (24 hours). And the TTL must be less than the max TTL defined on the dynamic secret. - + + The lease duration at most be 1 day (24 hours). And the TTL must be less than the max TTL defined on the dynamic secret. + + @@ -212,7 +218,7 @@ spec: The `dynamicSecret` field is used to specify which dynamic secret to create leases for. The required fields are `secretName`, `projectId`, `secretsPath`, and `environmentSlug`. - + ```yaml spec: dynamicSecret: @@ -300,7 +306,6 @@ The available authentication methods are `universalAuth`, `kubernetesAuth`, `aws - `autoCreateServiceAccountToken`: If set to `true`, the operator will automatically create a short-lived service account token on-demand for the service account. Defaults to `false`. - `serviceAccountTokenAudiences`: Optionally specify audience for the service account token. This field is only relevant if you have set `autoCreateServiceAccountToken` to `true`. No audience is specified by default. - Example: ```yaml @@ -316,7 +321,40 @@ The available authentication methods are `universalAuth`, `kubernetesAuth`, `aws ``` + + The LDAP machine identity authentication method is used to authenticate with a configured LDAP directory. [Read more about LDAP Auth](/documentation/platform/identities/ldap-auth). + Valid fields: + - `identityId`: The identity ID of the machine identity you created. + - `credentialsRef`: The name and namespace of the Kubernetes secret that stores the LDAP credentials. + - `credentialsRef.secretName`: The name of the Kubernetes secret. + - `credentialsRef.secretNamespace`: The namespace of the Kubernetes secret. + + Example: + + ```yaml + # infisical-push-secret.yaml + spec: + ldapAuth: + identityId: + credentialsRef: + secretName: + secretNamespace: + ``` + + ```yaml + # machine-identity-credentials.yaml + apiVersion: v1 + kind: Secret + metadata: + name: ldap-auth-credentials + type: Opaque + stringData: + username: + password: + ``` + + The AWS IAM machine identity authentication method is used to authenticate with Infisical. [Read more about AWS IAM Auth](/documentation/platform/identities/aws-auth). @@ -391,7 +429,7 @@ The available authentication methods are `universalAuth`, `kubernetesAuth`, `aws This block defines the TLS settings to use for connecting to the Infisical instance. - + Fields: This block defines the reference to the CA certificate to use for connecting to the Infisical instance with SSL/TLS. @@ -444,7 +482,7 @@ metadata: name: nginx-deployment labels: app: nginx - annotations: + annotations: secrets.infisical.com/auto-reload: "true" # <- redeployment annotation spec: replicas: 1 @@ -467,7 +505,7 @@ spec: ``` - #### How it works - When the lease changes, the operator will check to see which deployments are using the operator-managed Kubernetes secret that received the update. + #### How it works + When the lease changes, the operator will check to see which deployments are using the operator-managed Kubernetes secret that received the update. Then, for each deployment that has this annotation present, a rolling update will be triggered. A redeployment won't happen if the lease is renewed, only if it's recreated. diff --git a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx index d87648bbf..d5a9c1ef4 100644 --- a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx @@ -5,9 +5,9 @@ description: "Learn how to use the InfisicalPushSecret CRD to push and manage se --- -## Overview +## Overview -The **InfisicalPushSecret** CRD allows you to create secrets in your Kubernetes cluster and push them to Infisical. +The **InfisicalPushSecret** CRD allows you to create secrets in your Kubernetes cluster and push them to Infisical. This CRD offers the following features: @@ -70,6 +70,11 @@ Before applying the InfisicalPushSecret CRD, you need to create a Kubernetes sec serviceAccountRef: name: namespace: + ldapAuth: + identityId: + credentialsRef: + secretName: # ldap-auth-credentials + secretNamespace: # default universalAuth: credentialsRef: secretName: # universal-auth-credentials @@ -104,15 +109,15 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y ## InfisicalPushSecret CRD properties - If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to - ` https://your-self-hosted-instace.com/api` + If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to + `https://your-self-hosted-instace.com/api` When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. - If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. + If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. To achieve this, use the following address for the hostAPI field: - + ``` bash http://..svc.cluster.local:4000/api ``` @@ -187,7 +192,7 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y The `destination` field is used to specify where you want to create the secrets in Infisical. The required fields are `projectId`, `environmentSlug`, and `secretsPath`. - + ```yaml spec: destination: @@ -212,7 +217,7 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y The `push` field is used to define what you want to push to Infisical. Currently the operator only supports pushing Kubernetes secrets to Infisical. An example of the `push` field is shown below. - + @@ -220,7 +225,7 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y - Example usage of the `push.secret` field: + Example usage of the `push.secret` field: ```yaml infisical-push-secret.yaml push: @@ -282,7 +287,7 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y spec: universalAuth: credentialsRef: - secretName: + secretName: secretNamespace: ``` @@ -324,7 +329,39 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y namespace: ``` + + The LDAP machine identity authentication method is used to authenticate with a configured LDAP directory. [Read more about LDAP Auth](/documentation/platform/identities/ldap-auth). + Valid fields: + - `identityId`: The identity ID of the machine identity you created. + - `credentialsRef`: The name and namespace of the Kubernetes secret that stores the LDAP credentials. + - `credentialsRef.secretName`: The name of the Kubernetes secret. + - `credentialsRef.secretNamespace`: The namespace of the Kubernetes secret. + + Example: + + ```yaml + # infisical-push-secret.yaml + spec: + ldapAuth: + identityId: + credentialsRef: + secretName: + secretNamespace: + ``` + + ```yaml + # machine-identity-credentials.yaml + apiVersion: v1 + kind: Secret + metadata: + name: ldap-auth-credentials + type: Opaque + stringData: + username: + password: + ``` + The AWS IAM machine identity authentication method is used to authenticate with Infisical. [Read more about AWS IAM Auth](/documentation/platform/identities/aws-auth). @@ -398,7 +435,7 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y This block defines the TLS settings to use for connecting to the Infisical instance. - + Fields: This block defines the reference to the CA certificate to use for connecting to the Infisical instance with SSL/TLS. @@ -438,7 +475,7 @@ Using Go templates, you can format, combine, and create new key-value pairs of s Use this option when you would like to push **only** a subset of secrets from the Kubernetes secret to Infisical. - Define secret keys and their corresponding templates. + Define secret keys and their corresponding templates. Each data value uses a Golang template with access to all secrets defined in the `push.secret.secretName` Kubernetes secret. Secrets are structured as follows: @@ -483,7 +520,7 @@ A generator is defined as a custom resource (`ClusterGenerator`) within the clus Because of this behavior, you may want to disable automatic syncing for the `InfisicalPushSecret` resource to avoid continuous regeneration of secrets. This can be done by omitting the `resyncInterval` field from the InfisicalPushSecret CRD. -### Example usage +### Example usage ```yaml push: secret: @@ -625,4 +662,4 @@ After applying, you should notice that the secrets have been pushed to Infisical ```bash kubectl apply -f source-push-secret.yaml # The secret that you're referencing in the InfisicalPushSecret CRD push.secret field kubectl apply -f example-infisical-push-secret-crd.yaml # The InfisicalPushSecret CRD itself -``` \ No newline at end of file +``` diff --git a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx index 145737e96..d0e403b79 100644 --- a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx @@ -44,15 +44,15 @@ spec: The following properties help define what instance of Infisical the operator will interact with, the interval it will sync secrets and any CA certificates that may be required to connect. - If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to + If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to ` https://your-self-hosted-instace.com/api` When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. - If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. + If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. To achieve this, use the following address for the hostAPI field: - + ``` bash http://..svc.cluster.local:4000/api ``` @@ -110,7 +110,7 @@ The list of available authentication methods are shown below. Once you have created your machine identity and added it to your project(s), you will need to create a Kubernetes secret containing the identity credentials. To quickly create a Kubernetes secret containing the identity credentials, you can run the command below. - + Make sure you replace `` with the identity client ID and `` with the identity client secret. ``` bash @@ -525,49 +525,6 @@ spec: ... ``` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -747,6 +704,59 @@ spec: + + The LDAP machine identity authentication method is used to authenticate with Infisical using the configured LDAP directory. The username and password needs to be stored in a Kubernetes secret. This block defines the reference to the name and namespace of secret that stores these credentials. + + + + You need to create a machine identity, and give it access to the project(s) you want to interact with. You can [read more about machine identities here](/documentation/platform/identities/universal-auth). + + + Once you have created your machine identity and added it to your project(s), you will need to create a Kubernetes secret containing the identity credentials. + To quickly create a Kubernetes secret containing the identity credentials, you can run the command below. + + Make sure you replace `` with the identity LDAP username and `` with the identity LDAP password. + + ``` bash + kubectl create secret generic ldap-auth-credentials --from-literal=username="" --from-literal=password="" + ``` + + + + Once the secret is created, add the `secretName` and `secretNamespace` of the secret that was just created under `authentication.ldapAuth.credentialsRef` field in the InfisicalSecret resource. + + + + + + Make sure to also populate the `secretsScope` field with the project slug + _`projectSlug`_, environment slug _`envSlug`_, and secrets path + _`secretsPath`_ that you want to fetch secrets from. Please see the example + below. + + +## Example + +```yaml +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample-crd +spec: + authentication: + ldapAuth: + secretsScope: + projectSlug: # <-- project slug + envSlug: # "dev", "staging", "prod", etc.. + secretsPath: "" # Root is "/" + identityId: + credentialsRef: + secretName: ldap-auth-credentials # <-- name of the Kubernetes secret that stores our machine identity credentials + secretNamespace: default # <-- namespace of the Kubernetes secret that stores our machine identity credentials +``` + + + The service token required to authenticate with Infisical needs to be stored in a Kubernetes secret. This block defines the reference to the name and namespace of secret that stores this service token. @@ -826,7 +836,7 @@ managedKubeSecretReferences: The name of the managed Kubernetes secret to be created -The namespace of the managed Kubernetes secret to be created. +The namespace of the managed Kubernetes secret to be created. Override the default Opaque type for managed secrets with this field. Useful for creating kubernetes.io/dockerconfigjson secrets. @@ -855,8 +865,8 @@ Using Go templates, you can format, combine, and create new key-value pairs from - This property controls what secrets are included in your managed secret when using templates. - When set to `true`, all secrets fetched from your Infisical project will be added into your managed Kubernetes secret resource. + This property controls what secrets are included in your managed secret when using templates. + When set to `true`, all secrets fetched from your Infisical project will be added into your managed Kubernetes secret resource. **Use this option when you would like to sync all secrets from Infisical to Kubernetes but want to template a subset of them.** When set to `false`, only secrets defined in the `managedKubeSecretReferences[].template.data` field of the template will be included in the managed secret. @@ -864,7 +874,7 @@ Use this option when you would like to sync **only** a subset of secrets from In -Define secret keys and their corresponding templates. +Define secret keys and their corresponding templates. Each data value uses a Golang template with access to all secrets retrieved from the specified scope. Secrets are structured as follows: @@ -928,7 +938,9 @@ The properties includes defining the name and namespace of the Kubernetes config The Infisical operator will automatically create the Kubernetes config map in the specified name/namespace and ensure it stays up-to-date. If a config map already exists in the specified namespace, the operator will update the existing config map with the new data. - The usage of config maps is only intended for storing non-sensitive data. If you are looking to store sensitive data, please use the [managed secret](#operator-managed-secrets) property instead. + The usage of config maps is only intended for storing non-sensitive data. If + you are looking to store sensitive data, please use the [managed + secret](#operator-managed-secrets) property instead. @@ -937,25 +949,24 @@ The Infisical operator will automatically create the Kubernetes config map in th The name of the managed Kubernetes config map that your Infisical data will be stored in. - The namespace of the managed Kubernetes config map that your Infisical data will be stored in. + The namespace of the managed Kubernetes config map that your Infisical data will be stored in. Creation policies allow you to control whether or not owner references should be added to the managed Kubernetes config map that is generated by the Infisical operator. This is useful for tools such as ArgoCD, where every resource requires an owner reference; otherwise, it will be pruned automatically. - #### Available options +#### Available options - - `Orphan` (default) - - `Owner` +- `Orphan` (default) +- `Owner` - - When creation policy is set to `Owner`, the `InfisicalSecret` CRD must be in - the same namespace as where the managed kubernetes config map. - + + When creation policy is set to `Owner`, the `InfisicalSecret` CRD must be in + the same namespace as where the managed kubernetes config map. + - #### Managed ConfigMap Templating Fetching secrets from Infisical as is via the operator may not be enough. This is where templating functionality may be helpful. @@ -964,67 +975,68 @@ Using Go templates, you can format, combine, and create new key-value pairs from - This property controls what secrets are included in your managed config map when using templates. - When set to `true`, all secrets fetched from your Infisical project will be added into your managed Kubernetes config map resource. + This property controls what secrets are included in your managed config map when using templates. + When set to `true`, all secrets fetched from your Infisical project will be added into your managed Kubernetes config map resource. **Use this option when you would like to sync all secrets from Infisical to Kubernetes but want to template a subset of them.** - When set to `false`, only secrets defined in the `managedKubeConfigMapReferences[].template.data` field of the template will be included in the managed config map. - Use this option when you would like to sync **only** a subset of secrets from Infisical to Kubernetes. +When set to `false`, only secrets defined in the `managedKubeConfigMapReferences[].template.data` field of the template will be included in the managed config map. +Use this option when you would like to sync **only** a subset of secrets from Infisical to Kubernetes. - Define secret keys and their corresponding templates. + Define secret keys and their corresponding templates. Each data value uses a Golang template with access to all secrets retrieved from the specified scope. - Secrets are structured as follows: +Secrets are structured as follows: - ```golang - type TemplateSecret struct { - Value string `json:"value"` - SecretPath string `json:"secretPath"` - } - ``` +```golang +type TemplateSecret struct { + Value string `json:"value"` + SecretPath string `json:"secretPath"` +} +``` - #### Example template configuration: +#### Example template configuration: - ```yaml - managedKubeConfigMapReferences: - - configMapName: managed-configmap - configMapNamespace: default - template: - includeAllSecrets: true - data: - # Create new key that doesn't exist in your Infisical project using values of other secrets - SITE_URL: "{{ .SITE_URL.Value }}" - # Override an existing key in Infisical project with a new value using values of other secrets - API_URL: "https://api.{{.SITE_URL.Value}}.{{.REGION.Value}}.com" - ``` +```yaml +managedKubeConfigMapReferences: + - configMapName: managed-configmap + configMapNamespace: default + template: + includeAllSecrets: true + data: + # Create new key that doesn't exist in your Infisical project using values of other secrets + SITE_URL: "{{ .SITE_URL.Value }}" + # Override an existing key in Infisical project with a new value using values of other secrets + API_URL: "https://api.{{.SITE_URL.Value}}.{{.REGION.Value}}.com" +``` - For this example, let's assume the following secrets exist in your Infisical project: +For this example, let's assume the following secrets exist in your Infisical project: - ``` - SITE_URL = "https://example.com" - REGION = "us-east-1" - API_URL = "old-url" # This will be overridden - ``` +``` +SITE_URL = "https://example.com" +REGION = "us-east-1" +API_URL = "old-url" # This will be overridden +``` - The resulting managed Kubernetes config map will then contain: +The resulting managed Kubernetes config map will then contain: - ``` - # Original config map data (from includeAllSecrets: true) - SITE_URL = "https://example.com" - REGION = "us-east-1" +``` +# Original config map data (from includeAllSecrets: true) +SITE_URL = "https://example.com" +REGION = "us-east-1" - # New and overridden config map data - SITE_URL = "https://example.com" - API_URL = "https://api.example.com.us-east-1.com" # Existing secret overridden by template - ``` +# New and overridden config map data +SITE_URL = "https://example.com" +API_URL = "https://api.example.com.us-east-1.com" # Existing secret overridden by template +``` - To help transform your config map data further, the operator provides a set of built-in functions that you can use in your templates. +To help transform your config map data further, the operator provides a set of built-in functions that you can use in your templates. + +### Available templating functions + +Please refer to the [templating functions documentation](/integrations/platforms/kubernetes/overview#available-helper-functions) for more information. - ### Available templating functions - - Please refer to the [templating functions documentation](/integrations/platforms/kubernetes/overview#available-helper-functions) for more information. ## Applying CRD @@ -1061,8 +1073,6 @@ To verify that the operator has successfully created the managed secret, you can - - ## Using Managed Secret In Your Deployment To make use of the managed secret created by the operator into your deployment can be achieved through several methods. @@ -1071,45 +1081,45 @@ Here, we will highlight three of the most common ways to utilize it. Learn more This will take all the secrets from your managed secret and expose them to your container - ````yaml - envFrom: - - secretRef: - name: managed-secret # managed secret name - ``` +````yaml + envFrom: + - secretRef: + name: managed-secret # managed secret name + ``` - Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: + Example usage in a deployment + ```yaml + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - envFrom: - - secretRef: - name: managed-secret # <- name of managed secret - ports: - - containerPort: 80 - ```` + spec: + containers: + - name: nginx + image: nginx:1.14.2 + envFrom: + - secretRef: + name: managed-secret # <- name of managed secret + ports: + - containerPort: 80 +```` - - This will allow you to select individual secrets by key name from your managed secret and expose them to your container - + + This will allow you to select individual secrets by key name from your managed secret and expose them to your container + ```yaml env: - name: SECRET_NAME # The environment variable's name which is made available in the container @@ -1119,37 +1129,38 @@ Here, we will highlight three of the most common ways to utilize it. Learn more key: SOME_SECRET_KEY # The name of the key which exists in the managed secret ``` - Example usage in a deployment +Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - env: - - name: STRIPE_API_SECRET - valueFrom: - secretKeyRef: - name: managed-secret # <- name of managed secret - key: STRIPE_API_SECRET - ports: - - containerPort: 80 - ``` + spec: + containers: + - name: nginx + image: nginx:1.14.2 + env: + - name: STRIPE_API_SECRET + valueFrom: + secretKeyRef: + name: managed-secret # <- name of managed secret + key: STRIPE_API_SECRET + ports: + - containerPort: 80 +``` + @@ -1161,48 +1172,48 @@ Here, we will highlight three of the most common ways to utilize it. Learn more secretName: managed-secret # managed secret name ```` - You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets +You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets - ```yaml - volumeMounts: - - name: secrets-volume-name - mountPath: /etc/secrets - readOnly: true - ``` +```yaml +volumeMounts: + - name: secrets-volume-name + mountPath: /etc/secrets + readOnly: true +``` - Example usage in a deployment +Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - volumeMounts: - - name: secrets-volume-name - mountPath: /etc/secrets - readOnly: true - ports: - - containerPort: 80 - volumes: - - name: secrets-volume-name - secret: - secretName: managed-secret # <- managed secrets - ``` + spec: + containers: + - name: nginx + image: nginx:1.14.2 + volumeMounts: + - name: secrets-volume-name + mountPath: /etc/secrets + readOnly: true + ports: + - containerPort: 80 + volumes: + - name: secrets-volume-name + secret: + secretName: managed-secret # <- managed secrets +``` @@ -1244,7 +1255,7 @@ secrets.infisical.com/auto-reload: "true" name: nginx-deployment labels: app: nginx - annotations: + annotations: secrets.infisical.com/auto-reload: "true" # <- redeployment annotation spec: replicas: 1 @@ -1339,9 +1350,11 @@ secrets.infisical.com/auto-reload: "true" - #### How it works - When a managed secret is updated, the operator checks for any Deployments, DaemonSets, or StatefulSets that consume the updated secret and have the annotation - `secrets.infisical.com/auto-reload: "true"`. For each matching workload, the operator triggers a rolling restart to ensure it picks up the latest secret values. + #### How it works When a managed secret is updated, the operator checks for + any Deployments, DaemonSets, or StatefulSets that consume the updated secret + and have the annotation `secrets.infisical.com/auto-reload: "true"`. For each + matching workload, the operator triggers a rolling restart to ensure it picks + up the latest secret values. ## Using Managed ConfigMap In Your Deployment @@ -1350,52 +1363,52 @@ To make use of the managed ConfigMap created by the operator into your deploymen Here, we will highlight three of the most common ways to utilize it. Learn more about Kubernetes ConfigMaps [here](https://kubernetes.io/docs/concepts/configuration/configmap/) - Automatic redeployment of deployments using managed ConfigMaps is not yet supported. + Automatic redeployment of deployments using managed ConfigMaps is not yet + supported. - This will take all the secrets from your managed ConfigMap and expose them to your container - ````yaml - envFrom: - - configMapRef: - name: managed-configmap # managed configmap name - ``` +````yaml + envFrom: + - configMapRef: + name: managed-configmap # managed configmap name + ``` - Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: + Example usage in a deployment + ```yaml + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - envFrom: - - configMapRef: - name: managed-configmap # <- name of managed configmap - ports: - - containerPort: 80 - ```` + spec: + containers: + - name: nginx + image: nginx:1.14.2 + envFrom: + - configMapRef: + name: managed-configmap # <- name of managed configmap + ports: + - containerPort: 80 +```` - - This will allow you to select individual secrets by key name from your managed ConfigMap and expose them to your container - + + This will allow you to select individual secrets by key name from your managed ConfigMap and expose them to your container + ```yaml env: - name: CONFIG_NAME # The environment variable's name which is made available in the container @@ -1405,37 +1418,37 @@ Here, we will highlight three of the most common ways to utilize it. Learn more key: SOME_CONFIG_KEY # The name of the key which exists in the managed configmap ``` - Example usage in a deployment +Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - env: - - name: STRIPE_API_SECRET - valueFrom: - configMapKeyRef: - name: managed-configmap # <- name of managed configmap - key: STRIPE_API_SECRET - ports: - - containerPort: 80 - ``` + spec: + containers: + - name: nginx + image: nginx:1.14.2 + env: + - name: STRIPE_API_SECRET + valueFrom: + configMapKeyRef: + name: managed-configmap # <- name of managed configmap + key: STRIPE_API_SECRET + ports: + - containerPort: 80 +``` @@ -1448,48 +1461,49 @@ Here, we will highlight three of the most common ways to utilize it. Learn more name: managed-configmap # managed configmap name ```` - You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets +You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets - ```yaml - volumeMounts: - - name: configmaps-volume-name - mountPath: /etc/config - readOnly: true - ``` +```yaml +volumeMounts: + - name: configmaps-volume-name + mountPath: /etc/config + readOnly: true +``` - Example usage in a deployment +Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - volumeMounts: - - name: configmaps-volume-name - mountPath: /etc/config - readOnly: true - ports: - - containerPort: 80 - volumes: - - name: configmaps-volume-name - configMap: - name: managed-configmap # <- managed configmap - ``` + spec: + containers: + - name: nginx + image: nginx:1.14.2 + volumeMounts: + - name: configmaps-volume-name + mountPath: /etc/config + readOnly: true + ports: + - containerPort: 80 + volumes: + - name: configmaps-volume-name + configMap: + name: managed-configmap # <- managed configmap +``` + The definition file of the Kubernetes secret for the CA certificate can be structured like the following: @@ -1532,20 +1546,21 @@ Thus, if a specific label is required on the resulting secret, it can be applied ... ``` - This would result in the following managed secret to be created: +This would result in the following managed secret to be created: + +```yaml +apiVersion: v1 +data: ... +kind: Secret +metadata: + annotations: + example.com/annotation-to-be-passed-to-managed-secret: sample-value + secrets.infisical.com/version: W/"3f1-ZyOSsrCLGSkAhhCkY2USPu2ivRw" + labels: + label-to-be-passed-to-managed-secret: sample-value + name: managed-token + namespace: default +type: Opaque +``` - ```yaml - apiVersion: v1 - data: ... - kind: Secret - metadata: - annotations: - example.com/annotation-to-be-passed-to-managed-secret: sample-value - secrets.infisical.com/version: W/"3f1-ZyOSsrCLGSkAhhCkY2USPu2ivRw" - labels: - label-to-be-passed-to-managed-secret: sample-value - name: managed-token - namespace: default - type: Opaque - ``` 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-rotations.mdx b/docs/integrations/secret-rotations.mdx new file mode 100644 index 000000000..00d1cbb6a --- /dev/null +++ b/docs/integrations/secret-rotations.mdx @@ -0,0 +1,8 @@ +--- +sidebarTitle: "Explore Options" +description: "Browse and search through all available secret rotations for Infisical." +--- + +import { RotationsBrowser } from "/snippets/RotationsBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/secret-syncs.mdx b/docs/integrations/secret-syncs.mdx new file mode 100644 index 000000000..6d71517f1 --- /dev/null +++ b/docs/integrations/secret-syncs.mdx @@ -0,0 +1,8 @@ +--- +sidebarTitle: "Explore Options" +description: "Browse and search through all available secret syncs for Infisical." +--- + +import { SecretSyncsBrowser } from "/snippets/SecretSyncsBrowser.jsx"; + + \ 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 32fd0c12f..9a4e5f342 100644 --- a/docs/integrations/secret-syncs/aws-parameter-store.mdx +++ b/docs/integrations/secret-syncs/aws-parameter-store.mdx @@ -7,6 +7,11 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical." - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - 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. + diff --git a/docs/integrations/secret-syncs/aws-secrets-manager.mdx b/docs/integrations/secret-syncs/aws-secrets-manager.mdx index 91c606b0a..241c0350a 100644 --- a/docs/integrations/secret-syncs/aws-secrets-manager.mdx +++ b/docs/integrations/secret-syncs/aws-secrets-manager.mdx @@ -7,6 +7,7 @@ description: "Learn how to configure an AWS Secrets Manager Sync for Infisical." - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - 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. diff --git a/docs/integrations/secret-syncs/azure-app-configuration.mdx b/docs/integrations/secret-syncs/azure-app-configuration.mdx index f4aaa7edd..34a8eb9a8 100644 --- a/docs/integrations/secret-syncs/azure-app-configuration.mdx +++ b/docs/integrations/secret-syncs/azure-app-configuration.mdx @@ -7,6 +7,7 @@ description: "Learn how to configure an Azure App Configuration Sync for Infisic - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - Create an [Azure App Configuration Connection](/integrations/app-connections/azure-app-configuration) + - Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply. The Azure App Configuration Secret Sync requires the following permissions to be set on the user / service principal diff --git a/docs/integrations/secret-syncs/azure-devops.mdx b/docs/integrations/secret-syncs/azure-devops.mdx index 2f99fe128..b8c0331f8 100644 --- a/docs/integrations/secret-syncs/azure-devops.mdx +++ b/docs/integrations/secret-syncs/azure-devops.mdx @@ -7,6 +7,7 @@ description: "Learn how to configure a Azure DevOps Sync for Infisical." - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - Create an [Azure DevOps Connection](/integrations/app-connections/azure-devops) +- Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply. diff --git a/docs/integrations/secret-syncs/azure-key-vault.mdx b/docs/integrations/secret-syncs/azure-key-vault.mdx index d19a0162e..e4cb82a04 100644 --- a/docs/integrations/secret-syncs/azure-key-vault.mdx +++ b/docs/integrations/secret-syncs/azure-key-vault.mdx @@ -7,6 +7,7 @@ description: "Learn how to configure a Azure Key Vault Sync for Infisical." - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - Create an [Azure Key Vault Connection](/integrations/app-connections/azure-key-vault) + - Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply. The Azure Key Vault Secret Sync requires the following secrets permissions to be set on the user / service principal diff --git a/docs/integrations/secret-syncs/checkly.mdx b/docs/integrations/secret-syncs/checkly.mdx index 4599fe634..07382fb35 100644 --- a/docs/integrations/secret-syncs/checkly.mdx +++ b/docs/integrations/secret-syncs/checkly.mdx @@ -37,6 +37,7 @@ description: "Learn how to configure a Checkly Sync for Infisical." - **Checkly Connection**: The Checkly Connection to authenticate with. - **Account**: The Checkly account to sync secrets to. + - **Group**: The Checkly check group to sync secrets to (Optional). Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. diff --git a/docs/integrations/secret-syncs/gcp-secret-manager.mdx b/docs/integrations/secret-syncs/gcp-secret-manager.mdx index afce51a0c..1ef31c997 100644 --- a/docs/integrations/secret-syncs/gcp-secret-manager.mdx +++ b/docs/integrations/secret-syncs/gcp-secret-manager.mdx @@ -11,6 +11,7 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical." ![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-resource-manager-api.png) ![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-secret-manager-api.png) ![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-service-usage-api.png) + - Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply. diff --git a/docs/integrations/secret-syncs/github.mdx b/docs/integrations/secret-syncs/github.mdx index 14b2d9a7f..ed76c5edb 100644 --- a/docs/integrations/secret-syncs/github.mdx +++ b/docs/integrations/secret-syncs/github.mdx @@ -7,6 +7,7 @@ description: "Learn how to configure a GitHub Sync for Infisical." - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - Create a [GitHub Connection](/integrations/app-connections/github) + - Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply. diff --git a/docs/integrations/secret-syncs/gitlab.mdx b/docs/integrations/secret-syncs/gitlab.mdx index 5e6cacffa..3f468ac0e 100644 --- a/docs/integrations/secret-syncs/gitlab.mdx +++ b/docs/integrations/secret-syncs/gitlab.mdx @@ -7,6 +7,7 @@ description: "Learn how to configure a GitLab Sync for Infisical." - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - Create a [GitLab Connection](/integrations/app-connections/gitlab) + - Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply. diff --git a/docs/integrations/secret-syncs/netlify.mdx b/docs/integrations/secret-syncs/netlify.mdx new file mode 100644 index 000000000..fd98fac41 --- /dev/null +++ b/docs/integrations/secret-syncs/netlify.mdx @@ -0,0 +1,161 @@ +--- +title: "Netlify Sync" +description: "Learn how to configure a Netlify Sync for Infisical." +--- + +**Prerequisites:** + +- Create a [Netlify Connection](/integrations/app-connections/netlify) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Netlify](/images/secret-syncs/netlify/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/netlify/sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/netlify/sync-destination.png) + + - **Netlify Connection**: The Netlify Connection to authenticate with. + - **Account**: The Netlify Account to be used. + - **Site**: The Netlify site where secrets should be synced. + - **Context**: The Netlify deployment context where secrets should be created in. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/netlify/sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Netlify when keys conflict. + - **Import Secrets (Prioritize Netlify)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Netlify over Infisical when keys conflict. + + Netlify only supports importing non-secret values. + + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Netlify Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/netlify/sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Netlify Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/netlify/sync-review.png) + + + If enabled, your Netlify Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/netlify/sync-created.png) + + + + + + To create a **Netlify Sync**, make an API request to the [Create Netlify Sync](/api-reference/endpoints/secret-syncs/netlify/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/netlify \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-netlify-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "sync to netlify site", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "production", + "secretPath": "/site-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "destinationConfig": { + "siteId": "site-abc123", + "accountId": "account-abc123", + "context": "dev" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-netlify-sync", + "description": "sync to netlify site", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-07-19T12:00:00Z", + "updatedAt": "2025-07-19T12:00:00Z", + "syncStatus": "succeeded", + "lastSyncJobId": "job-1234", + "lastSyncMessage": null, + "lastSyncedAt": "2025-07-19T12:00:00Z", + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "netlify", + "name": "my-netlify-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "production", + "name": "Production", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/site-secrets" + }, + "destination": "netlify", + "destinationConfig": { + "siteId": "site-abc123", + "accountId": "account-abc123", + "context": "dev" + } + } + } + ``` + + diff --git a/docs/integrations/secret-syncs/oci-vault.mdx b/docs/integrations/secret-syncs/oci-vault.mdx index 396b4d13f..105d42395 100644 --- a/docs/integrations/secret-syncs/oci-vault.mdx +++ b/docs/integrations/secret-syncs/oci-vault.mdx @@ -14,6 +14,7 @@ description: "Learn how to configure an Oracle Cloud Infrastructure Vault Sync f - Create an [OCI Connection](/integrations/app-connections/oci) with the required **Secret Sync** permissions - [Create](https://docs.oracle.com/en-us/iaas/Content/Identity/compartments/To_create_a_compartment.htm) or use an existing OCI Compartment (which the OCI Connection is authorized to access) - [Create](https://docs.oracle.com/en-us/iaas/Content/KeyManagement/Tasks/managingvaults_topic-To_create_a_new_vault.htm#createnewvault) or use an existing OCI Vault +- Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply. 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/integrations/user-authentication.mdx b/docs/integrations/user-authentication.mdx new file mode 100644 index 000000000..2b33fd7ba --- /dev/null +++ b/docs/integrations/user-authentication.mdx @@ -0,0 +1,9 @@ +--- +title: "User Authentication" +sidebarTitle: "Explore Options" +description: "Browse and search through all available user authentication methods for Infisical." +--- + +import { UserAuthenticationBrowser } from "/snippets/UserAuthenticationBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/internals/permissions/organization-permissions.mdx b/docs/internals/permissions/organization-permissions.mdx index 80c843851..5f5fc962f 100644 --- a/docs/internals/permissions/organization-permissions.mdx +++ b/docs/internals/permissions/organization-permissions.mdx @@ -217,3 +217,14 @@ Supports conditions and permission inversion | `edit-gateways` | Modify existing gateway settings | | `delete-gateways` | Remove gateways from organization | | `attach-gateways` | Attach gateways to resources | + +#### Subject: `machine-identity-auth-template` + +| Action | Description | +| ------------------ | ---------------------------------------------- | +| `list-templates` | View identity auth templates | +| `create-templates` | Create new identity auth templates | +| `edit-templates` | Modify existing identity auth templates | +| `delete-templates` | Remove identity auth templates | +| `unlink-templates` | Unlink identity auth templates from identities | +| `attach-templates` | Attach identity auth templates to identities | 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/sdks/languages/cpp.mdx b/docs/sdks/languages/cpp.mdx index 2e22def9c..27f1aad3e 100644 --- a/docs/sdks/languages/cpp.mdx +++ b/docs/sdks/languages/cpp.mdx @@ -1,6 +1,315 @@ --- title: "Infisical C++ SDK" sidebarTitle: "C++" -url: "https://github.com/Infisical/infisical-cpp-sdk/?tab=readme-ov-file#infisical-c-sdk" icon: "/images/sdks/languages/cpp.svg" ---- \ No newline at end of file +--- + +If you're working with C++, the official Infisical C++ SDK package is the easiest way to fetch and work with secrets for your application. + +## Compatible with C++ 17 and later +The Infisical C++ SDK is compatible with C++ 17 capable compilers. This implies GCC 8 or newer, and clang 3.8 or newer. Earlier versions of C++ are unsupported. + +## Dependencies +- `cURL`: Used internally for crafting HTTP requests. + +## CMake Installation + +```bash +cmake_minimum_required(VERSION 3.14) +project(InfisicalTest) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_PREFIX_PATH ${CMAKE_BINARY_DIR}) + +find_package(OpenSSL REQUIRED) + + +include(FetchContent) + + +FetchContent_Declare( + infisical + GIT_REPOSITORY https://github.com/Infisical/infisical-cpp-sdk.git + GIT_TAG 1.0.0 # Replace with the desired version +) + +FetchContent_MakeAvailable(infisical) +FetchContent_GetProperties(infisical) + + +# Example usage. This will differ based on your project structure. +add_executable(my_app src/main.cpp) +target_link_libraries(my_app PRIVATE infisical OpenSSL::SSL OpenSSL::Crypto) +target_include_directories(my_app PRIVATE ${infisical_SOURCE_DIR}/include) +``` + +## Manual Installation +If you're unable to use the recommended CMake installation approach, you can choose to manually build the library and use it in your project. + +```bash +mkdir build +cd build +cmake .. +make +``` + +## Quick-Start Example + +Below you'll find an example that uses the Infisical SDK to fetch a secret with the key `API_KEY` using [Machine Identity Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) + +More examples can be found in the [/examples](https://github.com/Infisical/infisical-cpp-sdk/tree/main/examples) folder. + +```cpp +#include +#include + +int main() { + + try { + Infisical::InfisicalClient client( + Infisical::ConfigBuilder() + .withHostUrl("https://app.infisical.com") // Optionally change this to your custom Infisical instance URL. + .withAuthentication( + Infisical::AuthenticationBuilder() + .withUniversalAuth("", "") + .build()) + .build()); + + const auto getSecretOptions = Infisical::Input::GetSecretOptionsBuilder() + .withEnvironment("") // dev, staging, prod, etc + .withProjectId("") + .withSecretKey("API_KEY") + .build(); + + const auto apiKeySecret = client.secrets().getSecret(getSecretOptions); + + printf("Secret retrieved, [key=%s] [value=%s]\n", apiKeySecret.getSecretKey().c_str(), apiKeySecret.getSecretValue().c_str()); + } catch (const Infisical::InfisicalError &e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } + + return 0; +} +``` + +## JSON Serialization +The SDK uses [nlohmann/json](https://github.com/nlohmann/json) internally to serialize/deserialize JSON data. This SDK makes no assumptions about which JSON library you use in your project, and you aren't constrained to `nlohmann/json` in any way. Data returned by the SDK is returned as a class, which exposes Getter methods for getting fields such as the secret value or secret key. + + +## Documentation +The Infisical C++ SDK follows a builder pattern for all types of input. Below is a detailed documentation of our currently support methods. + +Everything related to the Infisical SDK lives inside the `Infisical` namespace. + +### InfisicalClient Class +`InfisicalClient(Config &config)` + +```cpp + Infisical::InfisicalClient client( + Infisical::ConfigBuilder() + .withHostUrl("https://app.infisical.com") + .withAuthentication( + Infisical::AuthenticationBuilder() + .withUniversalAuth(clientId, clientSecret) + .build()) + .build()); +``` + +Config is created through the `ConfigBuilder` class. See below for more details + +### Config Class + +`Config` defines the configuration of the Infisical Client itself, such as authentication. + +```cpp +Infisical::Config config = Infisical::ConfigBuilder() + .withHostUrl("https://app.infisical.com") + .withAuthentication( + Infisical::AuthenticationBuilder() + .withUniversalAuth(clientId, clientSecret) + .build()) + .build(); + +Infisical::InfisicalClient client(config); +``` + +- `withHostUrl(string)` _(optional)_: Specify a custom Infisical host URL, pointing to your Infisical instance. Defaults to `https://app.infisical.com` +- `withAuthentication(Infisical::Authentication)`: Configure the authentication that will be used by the SDK. See [Authentication Class](#authentication-class) for more details. +- `build()`: Returns the `Config` object with the options you configured. + +### Authentication Class +```cpp +Infisical::Authentication auth = Infisical::AuthenticationBuilder() + .withUniversalAuth(clientId, clientSecret) + .build(); + +Infisical::Config config = Infisical::ConfigBuilder() + .withAuthentication(std::move(auth)) // Or use inline declaration + .build(); +``` + +- `withUniversalAuth(string, string)`: Specify the Universal Auth Client ID and Client Secret that will be used for authentication. +- `build()`: Returns the `Authentication` object with the options you specified. + +### TSecret Class +The `TSecret` class is the class that's returned by all secret methods (get/list/delete/update/create). It can come in the form of a `std::vector` or a single instance. + +**Available getter methods:** +- `getId(): std::string`: Returns the ID of the secret. +- `getWorkspace(): std::string`: Returns the project ID of the secret. +- `getEnvironment(): std::string`: Returns the environment slug of the secret. +- `getVersion(): unsigned int`: Gets the version of the secret. By default this will always be the latest version unless specified otherwise with `withVersion()` +- `getType(): std::string`: Returns the type of the secret. Can only be `shared` or `personal`. Shared secrets are available to everyone with access to the secret. Personal secrets are personal overwrites of the secret, mainly intended for local development purposes. +- `getSecretKey(): std::string`: Returns the secret key. +- `getSecretValue(): std::string` Returns the secret value. +- `getRotationId(): std::string`: If the secret is a rotation secret, this will return the rotation ID of the secret. If it's a regular secret, this will return an empty string. +- `getSecretPath(): std::string`: Returns the secret path of the secret. +- `getSkipMultilineEncoding(): bool`: Returns whether or not skip multiline encoding is enabled for the secret or not. +`getIsRotatedSecret(): bool`: Returns wether or not the secret is a rotated secret. If `true`, then `getRotationId()` returns the ID of the rotation. + + + +### Secrets + +#### Create Secret +```cpp +const auto createSecretOptions = Infisical::Input::CreateSecretOptionsBuilder() + .withEnvironment("") + .withProjectId("") + .withSecretKey("SECRET_KEY_TO_CREATE") + .withSecretValue("VALUE_TO_CREATE") + .withSecretComment("Secret comment to attach") // Optional + .withSecretPath("/path/where/to/create/secret") // Optional, defaults to / + .withTagIds({"tag-id-1", "tag-id-2"}) // Optional + .build(); + +const auto secret = client.secrets().createSecret(createSecretOptions); +``` + +**Parameters**: +- `withEnvironment(string)`: Specify the slug of the environment to create the secret in. +- `withProjectId(string)`: Specify the ID of the project to create the secret in. +- `withSecretPath(string)`: Specify the secret path to create the secret in. Defaults to `/` +- `withSecretKey(string)`: The secret key to be created. +- `withSecretValue(string)`: The value of the secret to create. +- `withSecretComment(string)` _(optional)_: Optionally add a comment to the secret. +- `withTagIds(std::vector>)` _(optional)_: A list of ID's of tags to attach to the secret. +- `build()`: Returns the `CreateSecretOptions` class that can be passed into the `createSecret()` method. + +**Returns**: +- Returns the created secret as a `TSecret` class. Read more in the [TSecret Class](#tsecret-class) documentation. + +#### Update Secret + +```cpp + const auto updateSecretOptions = Infisical::Input::UpdateSecretOptionsBuilder() + .withEnvironment("") + .withProjectId("") + .withSecretKey("") + .withNewSecretKey("") // Optional + .withSecretValue("") // Optional + .withSecretComment("Updated comment") // Optional + .withSecretReminderNote("Updated reminder note") // Optional + .withSecretReminderRepeatDays(1) // Optional + .withType("shared") // Optional + .withTagIds({"tag-id-3", "tag-id-4"}) // Optional + .build(); + +const auto updatedSecret = client.secrets().updateSecret(updateSecretOptions); +``` + +**Parameters**: +- `withEnvironment(string)`: Specify the slug of the environment where the secret lives in. +- `withProjectId(string)`: Specify the ID of the project where the secret to update lives in. +- `withSecretPath(string)`: Specify the secret path of the secret to update. Defaults to `/`. +- `withType("shared" | "personal")`: _(optional)_: The type of secret to update. Defaults to `shared`. +- `withSecretKey(string)`: The key of the secret you wish to update. +- `withNewSecretKey(string)` _(optional)_: The new key of the secret you wish to update. +- `withSecretValue(string)` _(optional)_: The new value of the secret. +- `withSecretReminderNote(string)` _(optional)_: Update the secret reminder note attached to the secret. +- `withSecretReminderRepeatDays(unsigned int)` _(optional)_: Update the secret reminder repeat days attached to the secret. +- `withTagIds(std::vector>)` _(optional)_: A list of ID's of tags to attach to the secret. +- `build()`: Returns the `UpdateSecretOptions` class that can be passed into the `updateSecret()` method. + +**Returns**: +- Returns the updated secret as a `TSecret` class. Read more in the [TSecret Class](#tsecret-class) documentation. + +#### Get Secret +```cpp +const auto getSecretOptions = Infisical::Input::GetSecretOptionsBuilder() + .withEnvironment("") + .withProjectId("") + .withSecretKey("") + .withType("shared") + .withVersion(2) + .withExpandSecretReferences(true) + .build(); + +const auto secret = client.secrets().getSecret(getSecretOptions); +``` +**Parameters**: +- `withEnvironment(string)`: Specify the slug of the environment where the secret lives in. +- `withProjectId(string)`: Specify the ID of the project where the secret lives in. +- `withSecretPath(string)`: Specify the secret path of the secret to get. Defaults to `/` +- `withType("shared" | "personal")`: _(optional)_: The type of secret to get. Defaults to `shared`. +- `withSecretKey(string)`: The key of the secret to get. +- `withExpandSecretReferences(bool)` _(optional)_: Whether or not to expand secret references automatically. Defaults to `true`. +- `withVersion(unsigned int)` _(optional)_: Optionally fetch a specific version of the secret. If not defined, the latest version of the secret is returned. +- `build()`: Returns the `GetSecretOptions` class that can be passed into the `getSecret()` method. + +**Returns**: +- Returns the secret as a `TSecret` class. Read more in the [TSecret Class](#tsecret-class) documentation. + +#### Delete Secret + +```cpp +const auto deleteSecretOptions = Infisical::Input::DeleteSecretOptionsBuilder() + .withEnvironment("") + .withProjectId("") + .withSecretKey("") + .withType("shared") + .withSecretPath("") + .build(); + +const auto deletedSecret = client.secrets().deleteSecret(deleteSecretOptions); +``` + +**Parameters**: +- `withEnvironment(string)`: Specify the slug of the environment where the secret to delete lives in. +- `withProjectId(string)`: Specify the ID of the project where the secret to delete lives in. +- `withSecretPath(string)`: Specify the secret path of the secret to delete. Defaults to `/` +- `withType("shared" | "personal")`: _(optional)_: The type of secret to delete. Defaults to `shared`. +- `withSecretKey(string)`: The key of the secret to delete. +- `build()` Returns the `DeleteSecretOptions` class that can be passed into the `deleteSecret()` method. + +**Returns**: +- Returns the deleted secret as a `TSecret` class. Read more in the [TSecret Class](#tsecret-class) documentation. + + +#### List Secrets +```cpp +const auto listSecretsOptions = Infisical::Input::ListSecretOptionsBuilder() + .withProjectId(projectId) + .withEnvironment(environment) + .withSecretPath("/") + .withRecursive(false) + .withAddSecretsToEnvironmentVariables(false) + .build(); + +const auto secrets = client.secrets().listSecrets(listSecretsOptions); + +``` + +**Parameters**: +- `withEnvironment(string)`: Specify the slug of the environment to list secrets from. +- `withProjectId(string)`: Specify the ID of the project to fetch secrets from. +- `withSecretPath(string)`: Specify the secret path to fetch secrets from. Defaults to `/` +- `withExpandSecretReferences(bool)` _(optional)_: Whether or not to expand secret references automatically. Defaults to `true`. +- `withRecursive(bool)` _(optional)_: Wether or not to recursively fetch secrets from sub-folders. If set to true, all secrets from the secret path specified with `withSecretPath()` and downwards will be fetched. +- `withAddSecretsToEnvironmentVariables(bool)` _(optional)_: If set to true, the fetched secrets will be automatically set as environment variables, making them accessible with `std::getenv` or equivalent by secret key. +- `build()`: Returns the `ListSecretsOptions` class that can be passed into the `listSecrets()` method. + +**Returns**: +- Returns the listed secrets as `std::vector`. Read more in the [TSecret Class](#tsecret-class) documentation. diff --git a/docs/sdks/languages/csharp.mdx b/docs/sdks/languages/csharp.mdx deleted file mode 100644 index 524778a4a..000000000 --- a/docs/sdks/languages/csharp.mdx +++ /dev/null @@ -1,594 +0,0 @@ ---- -title: "Infisical .NET SDK" -sidebarTitle: ".NET" -url: "https://github.com/Infisical/infisical-dotnet-sdk?tab=readme-ov-file#infisical-net-sdk" -icon: "/images/sdks/languages/dotnet.svg" ---- -{/* -If you're working with C#, the official [Infisical C# SDK](https://github.com/Infisical/sdk/tree/main/languages/csharp) package is the easiest way to fetch and work with secrets for your application. - -- [Nuget Package](https://www.nuget.org/packages/Infisical.Sdk) -- [Github Repository](https://github.com/Infisical/sdk/tree/main/languages/csharp) - - - **Deprecation Notice** - - All versions prior to **2.3.9** should be considered deprecated and are no longer supported by Infisical. Please update to version **2.3.9** or newer. All changes are fully backwards compatible with older versions. - - -## Basic Usage - -```cs -using Infisical.Sdk; - -namespace Example -{ - class Program - { - static void Main(string[] args) - { - - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - UniversalAuth = new UniversalAuthMethod - { - ClientId = "your-client-id", - ClientSecret = "your-client-secret" - } - } - }; - - - var infisicalClient = new InfisicalClient(settings); - - var getSecretOptions = new GetSecretOptions - { - SecretName = "TEST", - ProjectId = "PROJECT_ID", - Environment = "dev", - }; - var secret = infisicalClient.GetSecret(getSecretOptions); - - - Console.WriteLine($"The value of secret '{secret.SecretKey}', is: {secret.SecretValue}"); - } - } -} -``` - -This example demonstrates how to use the Infisical C# SDK in a C# application. The application retrieves a secret named `TEST` from the `dev` environment of the `PROJECT_ID` project. - - - We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. - - -# Installation - -```console -$ dotnet add package Infisical.Sdk -``` -# Configuration - -Import the SDK and create a client instance with your [Machine Identity](/platform/identities/universal-auth). - -```cs -using Infisical.Sdk; - -namespace Example -{ - class Program - { - static void Main(string[] args) - { - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - UniversalAuth = new UniversalAuthMethod - { - ClientId = "your-client-id", - ClientSecret = "your-client-secret" - } - } - }; - - - var infisicalClient = new InfisicalClient(settings); // <-- Your SDK client is now ready to use - } - } -} -``` - -### ClientSettings methods - - - - - Your machine identity client ID. - - - Your machine identity client secret. - - - - An access token obtained from the machine identity login endpoint. - - - - Time-to-live (in seconds) for refreshing cached secrets. - If manually set to 0, caching will be disabled, this is not recommended. - - - - Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) - - - - Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate. - - - - The authentication object to use for the client. This is required unless you're using environment variables. - - - - - -### Authentication - -The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. - -#### Universal Auth - -**Using environment variables** -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. - -**Using the SDK directly** -```csharp - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - UniversalAuth = new UniversalAuthMethod - { - ClientId = "your-client-id", - ClientSecret = "your-client-secret" - } - } - }; - - var infisicalClient = new InfisicalClient(settings); -``` - -#### GCP ID Token Auth - - Please note that this authentication method will only work if you're running your application on Google Cloud Platform. - Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```csharp - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - GcpIdToken = new GcpIdTokenAuthMethod - { - IdentityId = "your-machine-identity-id", - } - } - }; - - - var infisicalClient = new InfisicalClient(settings); -``` - -#### GCP IAM Auth - -**Using environment variables** -- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. - -**Using the SDK directly** -```csharp - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - GcpIam = new GcpIamAuthMethod - { - IdentityId = "your-machine-identity-id", - ServiceAccountKeyFilePath = "./path/to/your/service-account-key.json" - } - } - }; - - - var infisicalClient = new InfisicalClient(settings); -``` - -#### AWS IAM Auth - - Please note that this authentication method will only work if you're running your application on AWS. - Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```csharp - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - AwsIam = new AwsIamAuthMethod - { - IdentityId = "your-machine-identity-id", - } - } - }; - - - var infisicalClient = new InfisicalClient(settings); -``` - - -#### Azure Auth - - Please note that this authentication method will only work if you're running your application on Azure. - Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```csharp - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - Azure = new AzureAuthMethod - { - IdentityId = "YOUR_IDENTITY_ID", - } - } - }; - - var infisicalClient = new InfisicalClient(settings); -``` - -#### Kubernetes Auth - - Please note that this authentication method will only work if you're running your application on Kubernetes. - Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. - -**Using the SDK directly** -```csharp - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - Kubernetes = new KubernetesAuthMethod - { - ServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token", // Optional - IdentityId = "YOUR_IDENTITY_ID", - } - } - }; - - var infisicalClient = new InfisicalClient(settings); -``` - - - -### Caching - -To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTTL" option when creating the client. - -## Working with Secrets - -### client.ListSecrets(options) - -```cs -var options = new ListSecretsOptions -{ - ProjectId = "PROJECT_ID", - Environment = "dev", - Path = "/foo/bar", - AttachToProcessEnv = false, -}; - -var secrets = infisical.ListSecrets(options); -``` - -Retrieve all secrets within the Infisical project and environment that client is connected to - -#### Parameters - - - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - - The project ID where the secret lives in. - - - - The path from where secrets should be fetched from. - - - - Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `System.getenv("SECRET_NAME")`. - - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - - Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. - - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - - -### client.GetSecret(options) - -```cs -var options = new GetSecretOptions - { - SecretName = "AAAA", - ProjectId = "659c781eb2d4fe3e307b77bd", - Environment = "dev", - }; -var secret = infisical.GetSecret(options); -``` - -Retrieve a secret from Infisical. - -By default, `GetSecret()` fetches and returns a shared secret. - -#### Parameters - - - - - The key of the secret to retrieve. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be fetched from. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - - -### client.CreateSecret(options) - -```cs -var options = new CreateSecretOptions { - Environment = "dev", - ProjectId = "PROJECT_ID", - - SecretName = "NEW_SECRET", - SecretValue = "NEW_SECRET_VALUE", - SecretComment = "This is a new secret", -}; - -var newSecret = infisical.CreateSecret(options); -``` - -Create a new secret in Infisical. - -#### Parameters - - - - - The key of the secret to create. - - - The value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be created. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.UpdateSecret(options) - -```cs -var options = new UpdateSecretOptions { - Environment = "dev", - ProjectId = "PROJECT_ID", - - SecretName = "SECRET_TO_UPDATE", - SecretValue = "NEW VALUE" -}; - -var updatedSecret = infisical.UpdateSecret(options); -``` - -Update an existing secret in Infisical. - -#### Parameters - - - - - The key of the secret to update. - - - The new value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be updated. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.DeleteSecret(options) - -```cs -var options = new DeleteSecretOptions -{ - Environment = "dev", - ProjectId = "PROJECT_ID", - SecretName = "NEW_SECRET", -}; - -var deletedSecret = infisical.DeleteSecret(options); -``` - -Delete a secret in Infisical. - -#### Parameters - - - - - The key of the secret to update. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be deleted. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -## Cryptography - -### Create a symmetric key - -Create a base64-encoded, 256-bit symmetric key to be used for encryption/decryption. - -```cs -var key = infisical.CreateSymmetricKey(); -``` - -#### Returns (string) -`key` (string): A base64-encoded, 256-bit symmetric key, that can be used for encryption/decryption purposes. - -### Encrypt symmetric -```cs -var options = new EncryptSymmetricOptions -{ - Plaintext = "Infisical is awesome!", - Key = key, -}; - -var encryptedData = infisical.EncryptSymmetric(options); -``` - -#### Parameters - - - - - The plaintext you want to encrypt. - - - The symmetric key to use for encryption. - - - - -#### Returns (object) -`Tag` (string): A base64-encoded, 128-bit authentication tag. -`Iv` (string): A base64-encoded, 96-bit initialization vector. -`CipherText` (string): A base64-encoded, encrypted ciphertext. - -### Decrypt symmetric -```cs -var decryptOptions = new DecryptSymmetricOptions -{ - Key = key, - Ciphertext = encryptedData.Ciphertext, - Iv = encryptedData.Iv, - Tag = encryptedData.Tag, -}; - -var decryptedPlaintext = infisical.DecryptSymmetric(decryptOptions); -``` - -#### Parameters - - - - The ciphertext you want to decrypt. - - - The symmetric key to use for encryption. - - - The initialization vector to use for decryption. - - - The authentication tag to use for decryption. - - - - -#### Returns (string) -`Plaintext` (string): The decrypted plaintext. - */} diff --git a/docs/sdks/languages/dotnet.mdx b/docs/sdks/languages/dotnet.mdx new file mode 100644 index 000000000..7b3d12885 --- /dev/null +++ b/docs/sdks/languages/dotnet.mdx @@ -0,0 +1,358 @@ +--- +title: "Infisical .NET SDK" +sidebarTitle: ".NET" +icon: "/images/sdks/languages/dotnet.svg" +--- + +If you're working with .NET, the official Infisical .NET SDK package is the easiest way to fetch and work with secrets for your application. + +## Installation + +```bash +dotnet add package Infisical.Sdk +``` + +## Getting Started (.NET) + +```csharp +namespace Example; + +using Infisical.Sdk; +using Infisical.Sdk.Model; + +public class Program { + public static void Main(string[] args) { + + var settings = new InfisicalSdkSettingsBuilder() + .WithHostUri("http://localhost:8080") // Optional. Will default to https://app.infisical.com + .Build(); + + var infisicalClient = new InfisicalClient(settings); + + var _ = infisicalClient.Auth().UniversalAuth().LoginAsync("", "").Result; + + var options = new ListSecretsOptions + { + SetSecretsAsEnvironmentVariables = true, + EnvironmentSlug = "", + SecretPath = "/", + ProjectId = "", + }; + + var secrets = infisicalClient.Secrets().ListAsync(options).Result; + + if (secrets == null) + { + throw new Exception("Failed to fetch secrets, returned null response"); + } + + foreach (var secret in secrets) + { + Console.WriteLine($"{secret.SecretKey}: {secret.SecretValue}"); + } + } +} +``` + +## Getting Started (Visual Basic) +```vb +Imports Infisical.Sdk +Imports Infisical.Sdk.Model + +Module Program + Sub Main(args As String()) + Dim settings = New InfisicalSdkSettingsBuilder() _ + .WithHostUri("https://app.infisical.com") _ + .Build() + + Dim infisicalClient As New InfisicalClient(settings) + + Dim authResult = infisicalClient.Auth().UniversalAuth() _ + .LoginAsync("", "machine-identity-universal-auth-client-secret").Result + + Dim options As New ListSecretsOptions With { + .SetSecretsAsEnvironmentVariables = True, + .EnvironmentSlug = "", + .SecretPath = "/", + .ProjectId = "" + } + + Dim secrets = infisicalClient.Secrets().ListAsync(options).Result + + For Each secret In secrets + Console.WriteLine(secret.SecretKey) + if Environment.GetEnvironmentVariable(secret.SecretKey) IsNot Nothing Then + Console.WriteLine("{0} found on environment variables", secret.SecretKey) + End If + Next + + End Sub +End Module +``` + +## Core Methods + +The SDK methods are organized into the following high-level categories: + +1. `Auth()`: Handles authentication methods. +2. `Secrets()`: Manages CRUD operations for secrets. +3. `Pki()`: Programmatically interact with the Infisical PKI. + * `Subscribers()`: Manage PKI Subscribers. + +### `Auth()` + +The `Auth()` component provides methods for authentication: + +### Universal Auth + +#### Authenticating +```cs +var _ = await sdk.Auth().UniversalAuth().LoginAsync( + "CLIENT_ID", + "CLIENT_SECRET" +); +``` + + +**Parameters:** +- `clientId` (string): The client ID of your Machine Identity. +- `clientSecret` (string): The client secret of your Machine Identity. + +### `Secrets()` + +The `Secrets()` sub-class handles operations related to the Infisical secrets management product. + +#### List Secrets + +```cs +Task ListAsync(ListSecretsOptions options); + +throws InfisicalException +``` + +```csharp +var options = new ListSecretsOptions + { + SetSecretsAsEnvironmentVariables = true, + EnvironmentSlug = "dev", + SecretPath = "/test", + Recursive = true, + ExpandSecretReferences = true, + ProjectId = projectId, + ViewSecretValue = true, + }; + +Secret[] secrets = await sdk.Secrets().ListAsync(options); +``` + +**ListSecretsOptions:** +- `ProjectId` (string): The ID of your project. +- `EnvironmentSlug` (string): The environment in which to list secrets (e.g., "dev"). +- `SecretPath` (string): The path to the secrets. +- `ExpandSecretReferences` (boolean): Whether to expand secret references. +- `Recursive` (boolean): Whether to list secrets recursively. +- `SetSecretsAsEnvironmentVariables` (boolean): Set the retrieved secrets as environment variables. + +**Returns:** +- `Task`: The response containing the list of secrets. + +#### Create Secret + +```cs +public Task CreateAsync(CreateSecretOptions options); + +throws InfisicalException +``` + +```cs + +var options = new CreateSecretOptions +{ + SecretName = "SECRET_NAME", + SecretValue = "SECRET_VALUE", + EnvironmentSlug = "", + SecretPath = "/", + ProjectId = "", + Metadata = new SecretMetadata[] { + new SecretMetadata { + Key = "metadata-key", + Value = "metadata-value" + } + } +}; + +Task newSecret = await sdk.Secrets().CreateAsync(options); +``` + +**Parameters:** +- `SecretName` (string): The name of the secret to create +- `SecretValue` (string): The value of the secret. +- `ProjectId` (string): The ID of your project. +- `EnvironmentSlug` (string): The environment in which to create the secret. +- `SecretPath` (string, optional): The path to the secret. +- `Metadata` (object, optional): Attach metadata to the secret. +- `SecretComment` (string, optional): Attach a secret comment to the secret. +- `SecretReminderNote` (string, optional): Attach a secret reminder note to the secret. +- `SecretReminderRepeatDays` (int, optional): Set the reminder repeat days on the secret. +- `SkipMultilineEncoding` (bool, optional): Whether or not to skip multiline encoding for the secret's value. Defaults to `false`. + +**Returns:** +- `Task`: The created secret. + +#### Update Secret + +```cs +public Task UpdateAsync(UpdateSecretOptions options); + +throws InfisicalException +``` + + +```cs + +var updateSecretOptions = new UpdateSecretOptions +{ + SecretName = "EXISTING_SECRET_NAME", + EnvironmentSlug = "", + SecretPath = "/", + NewSecretName = "NEW_SECRET_NAME", + NewSecretValue = "new-secret-value", + ProjectId = "", +}; + + +Task updatedSecret = await sdk.Secrets().UpdateAsync(updateSecretOptions); +``` + +**Parameters:** +- `SecretName` (string): The name of the secret to update.` +- `ProjectId` (string): The ID of your project. +- `EnvironmentSlug` (string): The environment in which to update the secret. +- `SecretPath` (string): The path to the secret. +- `NewSecretValue` (string, optional): The new value of the secret. +- `NewSecretName` (string, optional): A new name for the secret. +- `NewMetadata` (object, optional): New metadata to attach to the secret. + +**Returns:** +- `Task`: The updated secret. + +#### Get Secret by Name + +```cs +public Task GetAsync(GetSecretOptions options); + +throws InfisicalException +``` + +```cs + +var getSecretOptions = new GetSecretOptions +{ + SecretName = "SECRET_NAME", + EnvironmentSlug = "", + SecretPath = "/", + ProjectId = "", +}; + +Secret secret = await sdk.Secrets().GetAsync(getSecretOptions); +``` + +**Parameters:** +- `SecretName` (string): The name of the secret to get` +- `ProjectId` (string): The ID of your project. +- `EnvironmentSlug` (string): The environment in which to retrieve the secret. +- `SecretPath` (string): The path to the secret. +- `ExpandSecretReferences` (boolean, optional): Whether to expand secret references. +- `Type` (SecretType, optional): The type of secret to fetch. Defaults to `Shared`. + + +**Returns:** +- `Task`: The fetched secret. + +#### Delete Secret by Name + +```cs +public Secret DeleteAsync(DeleteSecretOptions options); + +throws InfisicalException +``` + +```cs + +var options = new DeleteSecretOptions +{ + SecretName = "SECRET_TO_DELETE", + EnvironmentSlug = "", + SecretPath = "/", + ProjectId = "", +}; + + +Secret deletedSecret = await sdk.Secrets().DeleteAsync(options); +``` + +**Parameters:** +- `SecretName` (string): The name of the secret to delete. +- `ProjectId` (string): The ID of your project. +- `EnvironmentSlug` (string): The environment in which to delete the secret. +- `SecretPath` (string, optional): The path to the secret. + +**Returns:** +- `Task`: The deleted secret. + + +### `Pki().Subscribers()` + +The `Pki().Subscribers()` sub-class is used to programmatically interact with the Infisical PKI product line. Currently only issuing new certificates and retrieving the latest certificate bundle from a subscriber is supported. More widespread support for the PKI product is coming to the .NET SDK in the near future. + + +#### Issue a new certificate + +```cs +public async Task IssueCertificateAsync(IssueCertificateOptions options); + +throws InfisicalException +``` + +```cs + +var options = new IssueCertificateOptions +{ + SubscriberName = "", + ProjectId = "", +}; + +SubscriberIssuedCertificate newCertificate = await sdk.Pki().Subscribers().IssueCertificateAsync(options); +``` + +**Parameters:** +- `SubscriberName` (string): The name of the subscriber to create a certificate for. +- `ProjectId` (string): The ID of PKI project. + +**Returns:** +- `Task`: The newly issued certificate along with it's credentials for the specified subscriber. + +#### Retrieve latest certificate bundle + +```cs +public async Task RetrieveLatestCertificateBundleAsync(RetrieveLatestCertificateBundleOptions options) + +throws InfisicalException +``` + +```cs +var options = new RetrieveLatestCertificateBundleOptions +{ + SubscriberName = "", + ProjectId = "", +}; + +CertificateBundle latestCertificate = await sdk.Pki().Subscribers().RetrieveLatestCertificateBundleAsync(options); +``` + +**Parameters:** +- `SubscriberName` (string): The name of the subscriber to retrieve the latest certificate bundle for +- `ProjectId` (string): The ID of PKI project. + +**Returns:** +- `Task`: The latest certificate bundle for the specified subscriber. \ No newline at end of file diff --git a/docs/sdks/languages/go.mdx b/docs/sdks/languages/go.mdx index 312f7e307..01250c452 100644 --- a/docs/sdks/languages/go.mdx +++ b/docs/sdks/languages/go.mdx @@ -4,7 +4,7 @@ sidebarTitle: "Go" icon: "/images/sdks/languages/go.svg" --- -If you're working with Go Lang, the official [Infisical Go SDK](https://github.com/infisical/go-sdk) package is the easiest way to fetch and work with secrets for your application. +If you're working with Go, the official Infisical Go SDK package is the easiest way to fetch and work with secrets for your application. - [Package](https://pkg.go.dev/github.com/infisical/go-sdk) - [Github Repository](https://github.com/infisical/go-sdk) diff --git a/docs/sdks/languages/java.mdx b/docs/sdks/languages/java.mdx index 359afb987..7ead4d3dd 100644 --- a/docs/sdks/languages/java.mdx +++ b/docs/sdks/languages/java.mdx @@ -1,575 +1,487 @@ --- title: "Infisical Java SDK" sidebarTitle: "Java" -url: "https://github.com/Infisical/java-sdk?tab=readme-ov-file#infisical-java-sdk" icon: "/images/sdks/languages/java.svg" --- -{ -/* -If you're working with Java, the official [Infisical Java SDK](https://github.com/Infisical/sdk/tree/main/languages/java) package is the easiest way to fetch and work with secrets for your application. +If you're working with Java, the official Infisical Java SDK package is the easiest way to fetch and work with secrets for your application. -- [Maven Package](https://github.com/Infisical/sdk/packages/2019741) -- [Github Repository](https://github.com/Infisical/sdk/tree/main/languages/java) +## Installation -## Basic Usage +Replace `{version}` with the version of the SDK you wish to use. This documentation covers version >=3.0.0. + +### Maven + +```xml + + com.infisical + sdk + {version} + +``` + +### Gradle +```gradle + implementation group: 'com.infisical', name: 'sdk', version: '{version}' +``` + + +### Others +For other build tools, please check our [package snippets](https://central.sonatype.com/artifact/com.infisical/sdk), and select the build tool you're using for your project. + + + +## Getting Started ```java -package com.example.app; +package com.example.example; -import com.infisical.sdk.InfisicalClient; -import com.infisical.sdk.schema.*; +import com.infisical.sdk.InfisicalSdk; +import com.infisical.sdk.SdkConfig; public class Example { - public static void main(String[] args) { - - // Create the authentication settings for the client - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - UniversalAuthMethod authMethod = new UniversalAuthMethod(); - authMethod.setClientID("YOUR_IDENTITY_ID"); - authMethod.setClientSecret("YOUR_CLIENT_SECRET"); + public static void main(String[] args) { + var sdk = new InfisicalSdk( + new SdkConfig.Builder() + // Optional, will default to https://app.infisical.com + .withSiteUrl("https://your-infisical-instance.com") + .build() + ); - authOptions.setUniversalAuth(authMethod); - settings.setAuth(authOptions); + sdk.Auth().UniversalAuthLogin( + "CLIENT_ID", + "CLIENT_SECRET" + ); - // Create a new Infisical Client - InfisicalClient client = new InfisicalClient(settings); + var secret = sdk.Secrets().GetSecret( + "", + "", + "", + "", + null, // Expand Secret References (boolean, optional) + null, // Include Imports (boolean, optional) + null // Secret Type (shared/personal, defaults to shared, optional) + ); - // Create the options for fetching the secret - GetSecretOptions options = new GetSecretOptions(); - options.setSecretName("TEST"); - options.setEnvironment("dev"); - options.setProjectID("PROJECT_ID"); - // Fetch the sercret with the provided options - GetSecretResponseSecret secret = client.getSecret(options); - - // Print the value - System.out.println(secret.getSecretValue()); - - // Important to avoid memory leaks! - // If you intend to use the client throughout your entire application, you can omit this line. - client.close(); - } + System.out.println(secret); + } } ``` -This example demonstrates how to use the Infisical Java SDK in a Java application. The application retrieves a secret named `TEST` from the `dev` environment of the `PROJECT_ID` project. +## Core Methods - - We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. - +The SDK methods are organized into the following high-level categories: -# Installation +1. `Auth()`: Handles authentication methods. +2. `Secrets()`: Manages CRUD operations for secrets. -The Infisical Java SDK is hosted on the GitHub Packages Apache Maven registry. Because of this you need to configure your environment properly so it's able to pull dependencies from the GitHub registry. Please check [this guide from GitHub](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-apache-maven-registry) on how to achieve this. +### `Auth` -Our package is [located here](https://github.com/Infisical/sdk/packages/2019741). Please follow the installation guide on the page. +The `Auth` component provides methods for authentication: -# Configuration +### Universal Auth -Import the SDK and create a client instance with your [Machine Identity](/platform/identities/universal-auth). +#### Authenticating ```java -import com.infisical.sdk.InfisicalClient; -import com.infisical.sdk.schema.*; - -public class App { - public static void main(String[] args) { - // Create the authentication settings for the client - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - UniversalAuthMethod authMethod = new UniversalAuthMethod(); - - authMethod.setClientID("YOUR_IDENTITY_ID"); - authMethod.setClientSecret("YOUR_CLIENT_SECRET"); - - authOptions.setUniversalAuth(authMethod); - settings.setAuth(authOptions); - - // Create a new Infisical Client - InfisicalClient client = new InfisicalClient(settings); // Your client! - } -} +public void UniversalAuthLogin( + String clientId, + String clientSecret +) +throws InfisicalException ``` -### ClientSettings methods - - - - - Your machine identity client ID. - - **This field is deprecated and will be removed in future versions.** Please use the `setAuth()` method on the client settings instead. - - - Your machine identity client secret. - - **This field is deprecated and will be removed in future versions.** Please use the `setAuth()` method on the client settings instead. - - - - An access token obtained from the machine identity login endpoint. - - **This field is deprecated and will be removed in future versions.** Please use the `setAuth()` method on the client settings instead. - - - - Time-to-live (in seconds) for refreshing cached secrets. - If manually set to 0, caching will be disabled, this is not recommended. - - - - Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) - - - - Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate. - - - - The authentication object to use for the client. This is required unless you're using environment variables. - - - - - -### Authentication - -The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. - -#### Universal Auth - -**Using environment variables** -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. - -**Using the SDK directly** ```java - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - UniversalAuthMethod authMethod = new UniversalAuthMethod(); - - authMethod.setClientID("YOUR_IDENTITY_ID"); - authMethod.setClientSecret("YOUR_CLIENT_SECRET"); - - authOptions.setUniversalAuth(authMethod); - settings.setAuth(authOptions); - - InfisicalClient client = new InfisicalClient(settings); -``` - -#### GCP ID Token Auth - - Please note that this authentication method will only work if you're running your application on Google Cloud Platform. - Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```java - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - GCPIDTokenAuthMethod authMethod = new GCPIDTokenAuthMethod(); - - authMethod.setIdentityID("YOUR_MACHINE_IDENTITY_ID"); - - authOptions.setGcpIDToken(authMethod); - settings.setAuth(authOptions); - - InfisicalClient client = new InfisicalClient(settings); -``` - -#### GCP IAM Auth - -**Using environment variables** -- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. - -**Using the SDK directly** -```java - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - GCPIamAuthMethod authMethod = new GCPIamAuthMethod(); - - authMethod.setIdentityID("YOUR_MACHINE_IDENTITY_ID"); - authMethod.setServiceAccountKeyFilePath("./path/to/your/service-account-key.json"); - - authOptions.setGcpIam(authMethod); - settings.setAuth(authOptions); - - InfisicalClient client = new InfisicalClient(settings); -``` - -#### AWS IAM Auth - - Please note that this authentication method will only work if you're running your application on AWS. - Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```java - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - AWSIamAuthMethod authMethod = new AWSIamAuthMethod(); - - authMethod.setIdentityID("YOUR_MACHINE_IDENTITY_ID"); - - authOptions.setAwsIam(authMethod); - settings.setAuth(authOptions); - - InfisicalClient client = new InfisicalClient(settings); -``` - -#### Azure Auth - - Please note that this authentication method will only work if you're running your application on Azure. - Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```java - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - AzureAuthMethod authMethod = new AzureAuthMethod(); - - authMethod.setIdentityID("YOUR_IDENTITY_ID"); - - authOptions.setAzure(authMethod); - settings.setAuth(authOptions); - - InfisicalClient client = new InfisicalClient(settings); -``` - -#### Kubernetes Auth - - Please note that this authentication method will only work if you're running your application on Kubernetes. - Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. - -**Using the SDK directly** -```java - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - KubernetesAuthMethod authMethod = new KubernetesAuthMethod(); - - authMethod.setIdentityID("YOUR_IDENTITY_ID"); - authMethod.setServiceAccountTokenPath("/var/run/secrets/kubernetes.io/serviceaccount/token"); // Optional - - authOptions.setKubernetes(authMethod); - settings.setAuth(authOptions); - - InfisicalClient client = new InfisicalClient(settings); +sdk.Auth().UniversalAuthLogin( + "CLIENT_ID", + "CLIENT_SECRET" +); ``` -### Caching +**Parameters:** +- `clientId` (string): The client ID of your Machine Identity. +- `clientSecret` (string): The client secret of your Machine Identity. -To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTTL" option when creating the client. - -## Working with Secrets - -### client.listSecrets(options) +### LDAP Auth ```java -ListSecretsOptions options = new ListSecretsOptions(); -options.setEnvironment("dev"); -options.setProjectID("PROJECT_ID"); -options.setPath("/foo/bar"); -options.setIncludeImports(false); -options.setRecursive(false); -options.setExpandSecretReferences(true); - -SecretElement[] secrets = client.listSecrets(options); +public void LdapAuthLogin( + LdapAuthLoginInput input +) +throws InfisicalException ``` -Retrieve all secrets within the Infisical project and environment that client is connected to - -#### Methods - - - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - - The project ID where the secret lives in. - - - - The path from where secrets should be fetched from. - - - - Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `System.getenv("SECRET_NAME")`. - - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - - Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. - - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - - -### client.getSecret(options) - ```java -GetSecretOptions options = new GetSecretOptions(); -options.setSecretName("TEST"); -options.setEnvironment("dev"); -options.setProjectID("PROJECT_ID"); +var input = LdapAuthLoginInput + .builder() + .identityId("") + .username("") + .password("") + .build(); -GetSecretResponseSecret secret = client.getSecret(options); - -String secretValue = secret.getSecretValue(); +sdk.Auth().LdapAuthLogin(input); ``` -Retrieve a secret from Infisical. +**Parameters:** +- `input` (LdapAuthLoginInput): The input for authenticating with LDAP. + - `identityId` (String): The ID of the machine identity to authenticate with. + - `username` (String): The LDAP username. + - `password` (String): The LDAP password. -By default, `getSecret()` fetches and returns a shared secret. +### Access Token Auth -#### Methods - - - - - The key of the secret to retrieve. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be fetched from. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - -### client.createSecret(options) +#### Authenticating ```java -CreateSecretOptions createOptions = new CreateSecretOptions(); -createOptions.setSecretName("NEW_SECRET"); -createOptions.setEnvironment("dev"); -createOptions.setProjectID("PROJECT_ID"); -createOptions.setSecretValue("SOME SECRET VALUE"); -createOptions.setPath("/"); // Default -createOptions.setType("shared"); // Default - -CreateSecretResponseSecret newSecret = client.createSecret(createOptions); +public void SetAccessToken( + String accessToken +) +throws InfisicalException ``` -Create a new secret in Infisical. - -#### Methods - - - - - The key of the secret to create. - - - The value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be created. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.updateSecret(options) - ```java -UpdateSecretOptions options = new UpdateSecretOptions(); - -options.setSecretName("SECRET_TO_UPDATE"); -options.setSecretValue("NEW SECRET VALUE"); -options.setEnvironment("dev"); -options.setProjectID("PROJECT_ID"); -options.setPath("/"); // Default -options.setType("shared"); // Default - -UpdateSecretResponseSecret updatedSecret = client.updateSecret(options); +sdk.Auth().SetAccessToken("ACCESS_TOKEN"); ``` -Update an existing secret in Infisical. +**Parameters:** +- `accessToken` (string): The access token you want to use for authentication. -#### Methods +### `Secrets` - - - - The key of the secret to update. - - - The new value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be updated. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - +This sub-class handles operations related to secrets: -### client.deleteSecret(options) +#### List Secrets ```java -DeleteSecretOptions options = new DeleteSecretOptions(); +public List ListSecrets( + String projectId, + String environmentSlug, + String secretPath, + Boolean expandSecretReferences, + Boolean recursive, + Boolean includeImports, + Boolean setSecretsOnSystemProperties +) -options.setSecretName("SECRET_TO_DELETE"); -options.setEnvironment("dev"); -options.setProjectID("PROJECT_ID"); -options.setPath("/"); // Default -options.setType("shared"); // Default - -DeleteSecretResponseSecret deletedSecret = client.deleteSecret(options); +throws InfisicalException ``` -Delete a secret in Infisical. - -#### Methods - - - - - The key of the secret to update. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be deleted. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -## Cryptography - -### Create a symmetric key - -Create a base64-encoded, 256-bit symmetric key to be used for encryption/decryption. - ```java -String key = client.createSymmetricKey(); +List secrets = sdk.Secrets().ListSecrets( + "", + "", // dev, prod, staging, etc. + "/secret/path", // `/` is the root folder + false, // Should expand secret references + false, // Should get secrets recursively from sub folders + false, // Should include imports + false // Should set the fetched secrets as key/value pairs on the system properties. Makes the secrets accessible as System.getProperty("") +); ``` -#### Returns (string) -`key` (string): A base64-encoded, 256-bit symmetric key, that can be used for encryption/decryption purposes. +**Parameters:** +- `projectId` (string): The ID of your project. +- `environmentSlug` (string): The environment in which to list secrets (e.g., "dev"). +- `secretPath` (string): The path to the secrets. +- `expandSecretReferences` (boolean): Whether to expand secret references. +- `recursive` (boolean): Whether to list secrets recursively. +- `includeImports` (boolean): Whether to include imported secrets. +- `setSecretsOnSystemProperties` (boolean): Set the retrieved secrets as key/value pairs on the system properties, making them accessible through `System.getProperty("")` + +**Returns:** +- `List`: The response containing the list of secrets. + +#### Create Secret + -### Encrypt symmetric ```java -EncryptSymmetricOptions options = new EncryptSymmetricOptions(); -options.setKey(key); -options.setPlaintext("Infisical is awesome!"); - -EncryptSymmetricResponse encryptedData = client.encryptSymmetric(options); +public Secret CreateSecret( + String secretName, + String secretValue, + String projectId, + String environmentSlug, + String secretPath +) +throws InfisicalException ``` -#### Methods - - - - - The plaintext you want to encrypt. - - - The symmetric key to use for encryption. - - - - -#### Returns (object) -`tag (getTag())` (string): A base64-encoded, 128-bit authentication tag. -`iv (getIv())` (string): A base64-encoded, 96-bit initialization vector. -`ciphertext (getCipherText())` (string): A base64-encoded, encrypted ciphertext. - - -### Decrypt symmetric ```java -DecryptSymmetricOptions decryptOptions = new DecryptSymmetricOptions(); -decryptOptions.setKey(key); -decryptOptions.setCiphertext(encryptedData.getCiphertext()); -decryptOptions.setIv(encryptedData.getIv()); -decryptOptions.setTag(encryptedData.getTag()); - -String decryptedString = client.decryptSymmetric(decryptOptions); +Secret newSecret = sdk.Secrets().CreateSecret( + "NEW_SECRET_NAME", + "secret-value", + "", + "", // dev, prod, staging, etc. + "/secret/path", // `/` is the root folder +); ``` -#### Methods - - - - The ciphertext you want to decrypt. - - - The symmetric key to use for encryption. - - - The initialization vector to use for decryption. - - - The authentication tag to use for decryption. - - - +**Parameters:** +- `secretName` (string): The name of the secret to create +- `secretValue` (string): The value of the secret. +- `projectId` (string): The ID of your project. +- `environmentSlug` (string): The environment in which to create the secret. +- `secretPath` (string, optional): The path to the secret. -#### Returns (string) -`Plaintext` (string): The decrypted plaintext. -*/} \ No newline at end of file +**Returns:** +- `Secret`: The created secret. + +#### Update Secret + +```java +public Secret UpdateSecret( + String secretName, + String projectId, + String environmentSlug, + String secretPath, + String newSecretValue, + String newSecretName + ) + +throws InfisicalException +``` + + +```java +Secret updatedSecret = sdk.Secrets().UpdateSecret( + "SECRET_NAME", + "", + "", // dev, prod, staging, etc. + "/secret/path", // `/` is the root folder + "NEW_SECRET_VALUE", // nullable + "NEW_SECRET_NAME" // nullable +); +``` + +**Parameters:** +- `secretName` (string): The name of the secret to update. +- `projectId` (string): The ID of your project. +- `environmentSlug` (string): The environment in which to update the secret. +- `secretPath` (string): The path to the secret. +- `newSecretValue` (string, nullable): The new value of the secret. +- `newSecretName` (string, nullable): A new name for the secret. + +**Returns:** +- `Secret`: The updated secret. + +#### Get Secret by Name + +```java +public Secret GetSecret( + String secretName, + String projectId, + String environmentSlug, + String secretPath, + Boolean expandSecretReferences, + Boolean includeImports, + String secretType + ) +throws InfisicalException +``` + +```java +Secret secret = sdk.Secrets().GetSecret( + "SECRET_NAME", + "", + "", // dev, prod, staging, etc. + "/secret/path", // `/` is the root folder + false, // Should expand secret references + false, // Should get secrets recursively from sub folders + false, // Should include imports + "shared" // Optional Secret Type (defaults to "shared") +); +``` + +**Parameters:** +- `secretName` (string): The name of the secret to get` +- `projectId` (string): The ID of your project. +- `environmentSlug` (string): The environment in which to retrieve the secret. +- `secretPath` (string): The path to the secret. +- `expandSecretReferences` (boolean, optional): Whether to expand secret references. +- `includeImports` (boolean, optional): Whether to include imported secrets. +- `secretType` (personal | shared, optional): The type of secret to fetch. + + +**Returns:** +- `Secret`: The fetched secret. + +#### Delete Secret by Name + +```java +public Secret DeleteSecret( + String secretName, + String projectId, + String environmentSlug, + String secretPath + ) +throws InfisicalException +``` + +```java +Secret deletedSecret = sdk.Secrets().DeleteSecret( + "SECRET_NAME", + "", + "", // dev, prod, staging, etc. + "/secret/path", // `/` is the root folder +); +``` + +**Parameters:** +- `secretName` (string): The name of the secret to delete. +- `projectId` (string): The ID of your project. +- `environmentSlug` (string): The environment in which to delete the secret. +- `secretPath` (string, optional): The path to the secret. + +**Returns:** +- `Secret`: The deleted secret. + + +### `Folders` + +#### Get Folder By Name + +```java +public Folder Get( + String folderId +); +throws InfisicalException +``` + +```java +Folder folder = sdk.Folders().Get(""); +``` + +**Parameters:** +- `folderId` (String): The ID of the folder to retrieve. + +**Returns:** +- `Folder`: The retrieved folder. + +#### List Folders + +```java +public List List( + ListFoldersInput input +) +throws InfisicalException +``` + +```java +ListFoldersInput input = ListFoldersInput + .builder() + .projectId("") + .environmentSlug("") + .folderPath("/") + .recursive(false) + .build(); + +List folders = sdk.Folders().List(input); +``` + + +**Parameters:** +- `input` (ListFoldersInput): The input for listing folders. + - `projectId` (String): The ID of the project to list folders from. + - `environmentSlug` (String): The slug of the environment to list folders from. + - `folderPath` (String): The path to list folders from. Defaults to `/`. + - `recursive` (Boolean): Whether or not to list sub-folders recursively from the specified folder path and downwards. Defaults to `false`. + +**Returns:** +- `List`: The retrieved folders. + +#### Create Folder + +```java +public Folder Create( + CreateFolderInput input +) +throws InfisicalException +``` + +```java +var input = CreateFolderInput + .builder() + .projectId("") + .environmentSlug("") + .folderName("") + .folderPath("/") + .description("Optional folder description") + .build(); + +Folder createdFolder = sdk.Folders().Create(input); +``` + +**Parameters:** +- `input` (CreateFolderInput): The input for creating a folder. + - `projectId` (String): The ID of the project to create the folder in. + - `environmentSlug` (String): The slug of the environment to create the folder in. + - `folderPath` (String): The path to create the folder in. Defaults to `/`. + - `folderName` (String): The name of the folder to create. + - `description` (String): The description of the folder to create. This is optional. + +**Returns:** +- `Folder`: The created folder. + +#### Update Folder + +```java +public Folder Update( + UpdateFolderInput input +) +throws InfisicalException +``` + +```java +var input = UpdateFolderInput + .builder() + .projectId("") + .environmentSlug("") + .folderId("") + .newFolderName("") + .folderPath("/") + .build(); + +Folder updatedFolder = sdk.Folders().Update(input); +``` + +**Parameters:** +- `input` (UpdateFolderInput): The input for updating a folder. + - `projectId` (String): The ID of the project where the folder exists. + - `environmentSlug` (String): The slug of the environment where the folder exists. + - `folderPath` (String): The path of the folder to update. + - `folderId` (String): The ID of the folder to update. + - `newFolderName` (String): The new folder name. + +**Returns:** +- `Folder`: The updated folder. + +#### Delete Folder + +```java +public Folder Delete( + DeleteFolderInput input +) +throws InfisicalException +``` + +```java +var input = DeleteFolderInput + .builder() + .folderId("") + .environmentSlug("") + .projectId("") + .build(); + +Folder deletedFolder = sdk.Folders().Delete(input); +``` + + +**Parameters:** +- `input` (DeleteFolderInput): The input for deleting a folder. + - `projectId` (String): The ID of the project where the folder exists. + - `environmentSlug` (String): The slug of the environment where the folder exists. + - `folderId` (String): The ID of the folder to delete. + +**Returns:** +- `Folder`: The deleted folder. \ No newline at end of file diff --git a/docs/sdks/languages/node.mdx b/docs/sdks/languages/node.mdx index 69bb36e97..e28e9c9f2 100644 --- a/docs/sdks/languages/node.mdx +++ b/docs/sdks/languages/node.mdx @@ -1,558 +1,559 @@ --- title: "Infisical Node.js SDK" sidebarTitle: "Node.js" -url: "https://github.com/Infisical/node-sdk-v2?tab=readme-ov-file#infisical-nodejs-sdk" icon: "/images/sdks/languages/node.svg" --- -{/* -If you're working with Node.js, the official [Infisical Node SDK](https://github.com/Infisical/sdk/tree/main/languages/node) package is the easiest way to fetch and work with secrets for your application. +If you're working with Node.js, the official Infisical Node.js SDK package is the easiest way to fetch and work with secrets for your application. -- [NPM Package](https://www.npmjs.com/package/@infisical/sdk) -- [Github Repository](https://github.com/Infisical/sdk/tree/main/languages/node) +## Deprecation Notice -## Basic Usage - -```js -import express from "express"; - -import { InfisicalClient } from "@infisical/sdk"; - -const app = express(); - -const PORT = 3000; - -const client = new InfisicalClient({ - siteUrl: "https://app.infisical.com", // Optional, defaults to https://app.infisical.com - auth: { - universalAuth: { - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET" - } - } -}); - -app.get("/", async (req, res) => { - // Access the secret - - const name = await client.getSecret({ - environment: "dev", - projectId: "PROJECT_ID", - path: "/", - type: "shared", - secretName: "NAME" - }); - - res.send(`Hello! My name is: ${name.secretValue}`); -}); - -app.listen(PORT, async () => { - // initialize client - - console.log(`App listening on port ${PORT}`); -}); -``` - -This example demonstrates how to use the Infisical Node SDK with an Express application. The application retrieves a secret named "NAME" and responds to requests with a greeting that includes the secret value. - - - We do not recommend hardcoding your [Machine Identity Tokens](/documentation/platform/identities/overview). Setting it as an environment variable - would be best. - +Please be aware that all versions prior to `4.0.0` are officially fully unsupported. +Please upgrade to version 4.0.0 or newer in order to receive the latest updates. ## Installation -Run `npm` to add `@infisical/sdk` to your project. - -```console -$ npm install @infisical/sdk +```bash +npm install @infisical/sdk ``` -## Configuration +## Getting Started -Import the SDK and create a client instance with your [Machine Identity](/documentation/platform/identities/overview). +```typescript +import { InfisicalSDK } from '@infisical/sdk' - - - ```js - import { InfisicalClient, LogLevel } from "@infisical/sdk"; +const client = new InfisicalSDK({ + siteUrl: "your-infisical-instance.com" // Optional, defaults to https://app.infisical.com +}); - const client = new InfisicalClient({ - auth: { - universalAuth: { - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET" - } - }, - logLevel: LogLevel.Error - }); - ``` +// Authenticate with Infisical +await client.auth().universalAuth.login({ + clientId: "", + clientSecret: "" +}); - - - ```js - const { InfisicalClient } = require("@infisical/sdk"); +const allSecrets = await client.secrets().listSecrets({ + environment: "dev", // stg, dev, prod, or custom environment slugs + projectId: "" +}); - const client = new InfisicalClient({ - auth: { - universalAuth: { - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET" - } - }, - }); - ``` +console.log("Fetched secrets", allSecrets) +``` - - +## Core Methods -### Parameters +The SDK methods are organized into the following high-level categories: - - - - Your machine identity client ID. - - **This field is deprecated and will be removed in future versions.** Please use the `auth.universalAuth.clientId` field instead. - - - Your machine identity client secret. - - **This field is deprecated and will be removed in future versions.** Please use the `auth.universalAuth.clientSecret` field instead. - +1. `auth`: Handles authentication methods. +2. `secrets`: Manages CRUD operations for secrets. +3. `dynamicSecrets`: Manages dynamic secrets and leases. +4. `projects`: Creates and manages projects. +5. `environments`: Creates and manages environments. +6. `folders`: Creates and manages folders. - - An access token obtained from the machine identity login endpoint. - - **This field is deprecated and will be removed in future versions.** Please use the `auth.accessToken` field instead. - +### `auth` - - Time-to-live (in seconds) for refreshing cached secrets. - If manually set to 0, caching will be disabled, this is not recommended. - - - - Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) - - - The level of logs you wish to log The logs are derived from Rust, as we have written our base SDK in Rust. - - - - Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate. - - - - The authentication object to use for the client. This is required unless you're using environment variables. - - - - - - -### Authentication - -The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. +The `Auth` component provides methods for authentication: #### Universal Auth -**Using environment variables** -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. - -**Using the SDK directly** -```js -const client = new InfisicalClient({ - auth: { - universalAuth: { - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET" - } - } +#### Authenticating +```typescript +await client.auth().universalAuth.login({ + clientId: "", + clientSecret: "" }); ``` -#### GCP ID Token Auth - - Please note that this authentication method will only work if you're running your application on Google Cloud Platform. - Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. - +**Parameters:** +- `options` (object): + - `clientId` (string): The client ID of your Machine Identity. + - `clientSecret` (string): The client secret of your Machine Identity. -**Using environment variables** -- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```js -const client = new InfisicalClient({ - auth: { - gcpIdToken: { - identityId: "YOUR_IDENTITY_ID" - } - } -}); +#### Renewing +You can renew the authentication token that is currently set by using the `renew()` method. +```typescript +await client.auth().universalAuth.renew(); ``` -#### GCP IAM Auth +#### Manually set access token +By default, when you run a successful `.login()` method call, the access token returned will be auto set for the client instance. However, if you wish to set the access token manually, you may use this method. -**Using environment variables** -- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. - -**Using the SDK directly** -```js -const client = new InfisicalClient({ - auth: { - gcpIam: { - identityId: "YOUR_IDENTITY_ID", - serviceAccountKeyFilePath: "./path/to/your/service-account-key.json" - } - } -}); +```typescript +client.auth().accessToken("") ``` -#### AWS IAM Auth - - Please note that this authentication method will only work if you're running your application on AWS. - Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```js -const client = new InfisicalClient({ - auth: { - awsIam: { - identityId: "YOUR_IDENTITY_ID" - } - } -}); -``` - -#### Azure Auth - - Please note that this authentication method will only work if you're running your application on Azure. - Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```js -const client = new InfisicalClient({ - auth: { - azure: { - identityId: "YOUR_IDENTITY_ID" - } - } -}); -``` +**Parameters:** +- `accessToken` (string): The access token to be used for authentication. _This should not include "Bearer"._ -#### Kubernetes Auth - - Please note that this authentication method will only work if you're running your application on Kubernetes. - Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. - +#### AWS IAM -**Using environment variables** -- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + AWS IAM auth only works when the SDK is being used from within an AWS service, such as Lambda, EC2, etc. + -**Using the SDK directly** -```js -const client = new InfisicalClient({ - auth: { - kubernetes: { - identityId: "YOUR_IDENTITY_ID", - serviceAccountTokenPathEnvName: "/var/run/secrets/kubernetes.io/serviceaccount/token" // Optional - } - } -}); -``` - -### Caching - -To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTtl" option when creating the client. - -## Working with Secrets - -### client.listSecrets(options) - -```js -const secrets = await client.listSecrets({ - environment: "dev", - projectId: "PROJECT_ID", - path: "/foo/bar/", - includeImports: false -}); -``` - -Retrieve all secrets within the Infisical project and environment that client is connected to - -#### Parameters - - - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The project ID where the secret lives in. - - - - The path from where secrets should be fetched from. - - - - Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `process.env["SECRET_NAME"]`. - - - - Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. - - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - - - -### client.getSecret(options) - -```js -const secret = await client.getSecret({ - environment: "dev", - projectId: "PROJECT_ID", - secretName: "API_KEY", - path: "/", - type: "shared" -}); -``` - -Retrieve a secret from Infisical. - -By default, `getSecret()` fetches and returns a shared secret. - -#### Parameters - - - - - The key of the secret to retrieve. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be fetched from. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - -### client.createSecret(options) - -```js -const newApiKey = await client.createSecret({ - projectId: "PROJECT_ID", - environment: "dev", - secretName: "API_KEY", - secretValue: "SECRET VALUE", - path: "/", - type: "shared" -}); -``` - -Create a new secret in Infisical. - - - - - The key of the secret to create. - - - The value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be created. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.updateSecret(options) - -```js -const updatedApiKey = await client.updateSecret({ - secretName: "API_KEY", - secretValue: "NEW SECRET VALUE", - projectId: "PROJECT_ID", - environment: "dev", - path: "/", - type: "shared" -}); -``` - -Update an existing secret in Infisical. - -#### Parameters - - - - - The key of the secret to update. - - - The new value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be updated. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.deleteSecret(options) - -```js -const deletedSecret = await client.deleteSecret({ - secretName: "API_KEY", - - environment: "dev", - projectId: "PROJECT_ID", - path: "/", - - type: "shared" -}); -``` - -Delete a secret in Infisical. - - - - - The key of the secret to update. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be deleted. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -## Cryptography - -### Create a symmetric key - -Create a base64-encoded, 256-bit symmetric key to be used for encryption/decryption. - -```js -const key = client.createSymmetricKey(); -``` - -#### Returns (string) -`key` (string): A base64-encoded, 256-bit symmetric key, that can be used for encryption/decryption purposes. - -### Encrypt symmetric -```js -const { iv, tag, ciphertext } = await client.encryptSymmetric({ - key: key, - plaintext: "Infisical is awesome!", +#### Authenticating +```typescript +await client.auth().awsIamAuth.login({ + identityId: "" }) ``` -#### Parameters +**Parameters:** +- `options` (object): + - `identityId` (string): The ID of your identity - - - - The plaintext you want to encrypt. - - - The symmetric key to use for encryption. - - - +#### Renewing +You can renew the authentication token that is currently set by using the `renew()` method. +```typescript +await client.auth().awsIamAuth.renew(); +``` -#### Returns (object) -`tag` (string): A base64-encoded, 128-bit authentication tag. -`iv` (string): A base64-encoded, 96-bit initialization vector. -`ciphertext` (string): A base64-encoded, encrypted ciphertext. -### Decrypt symmetric -```js -const decryptedString = await client.decryptSymmetric({ - key: key, - iv: iv, - tag: tag, - ciphertext: ciphertext, +### `secrets` + +This sub-class handles operations related to secrets: + +#### List Secrets + +```typescript +const allSecrets = await client.secrets().listSecrets({ + environment: "dev", + projectId: "", + expandSecretReferences: true, + viewSecretValue: true, + includeImports: false, + recursive: false, + secretPath: "/foo/bar", }); ``` -#### Parameters - - - - The ciphertext you want to decrypt. - - - The symmetric key to use for encryption. - - - The initialization vector to use for decryption. - - - The authentication tag to use for decryption. - - - +**Parameters:** +- `projectId` (string): The ID of your project. +- `environment` (string): The environment in which to list secrets (e.g., "dev"). +- `secretPath` (str): The path to the secrets. +- `expandSecretReferences` (bool, optional): Whether to expand secret references. +- `viewSecretValue` (bool, optional): Whether or not to reveal the secret value of the secrets. If set to `false`, the `secretValue` is masked with ``. Defaults to `true`. +- `recursive` (bool, optional): Whether to list secrets recursively. +- `includeImports` (bool, optional): Whether to include imported secrets. +- `tagFilters` (string[], optional): Tags to filter secrets. -#### Returns (string) -`plaintext` (string): The decrypted plaintext. +**Returns:** +- `ListSecretsResponse`: The response containing the list of secrets. -*/} \ No newline at end of file +#### List secrets with imports + +The `listSecretsWithImports` method makes it easier to get all your secrets at once. The imported secrets will automatically be added to the secrets returned. The secrets in the selected environment will take precedence over the imported secrets. This means if you have secrets with conflicting names, the secret from the environment the import was imported into, will take precedence. + +```typescript +const allSecrets = await client.secrets().listSecretsWithImports({ + environment: "dev", + projectId: "", + expandSecretReferences: true, + viewSecretValue: true, + recursive: false, + secretPath: "/foo/bar" +}); +``` + +**Parameters:** +- `projectId` (string): The ID of your project. +- `environment` (string): The environment in which to list secrets (e.g., "dev"). +- `secretPath` (str): The path to the secrets. +- `expandSecretReferences` (bool, optional): Whether to expand secret references. +- `viewSecretValue` (bool, optional): Whether or not to reveal the secret value of the secrets. If set to `false`, the `secretValue` is masked with ``. Defaults to `true`. +- `recursive` (bool, optional): Whether to list secrets recursively. +- `tagFilters` (string[], optional): Tags to filter secrets. + +**Returns:** +- `Secret[]`: Returns the list of secrets objects, with imports. + + + +#### Create Secret + +```typescript + const newSecret = await client.secrets().createSecret("SECRET_NAME", { + environment: "dev", + projectId: "", + secretValue: "SECRET_VALUE", + secretComment: "This is a new secret", // Optional + secretPath: "/foo/bar", // Optional + secretReminderNote: "This is a reminder note", // Optional + secretReminderRepeatDays: 7, // Optional + skipMultilineEncoding: false, // Optional + tagIds: ["tagId1", "tagId2"], // Optional + type: "personal" // Optional + }); +``` + +**Parameters:** +- `secretName` (string): The name of the secret to create +- `options` (object): + - `projectId` (string): The ID of your project. + - `environment` (str): The environment in which to create the secret. + - `secretValue` (str): The value of the secret. + - `secretPath` (string, optional): The path to the secret. + - `secretComment` (str, optional): A comment associated with the secret. + - `skipMultilineEncoding` (bool, optional): Whether to skip encoding for multiline secrets. + - `secretReminderNote` (string, optional): A note for the secret reminder. + - `secretReminderRepeatDays` (number, optional): Number of days after which to repeat secret reminders. + - `tagIds` (string[], optional): Array of tags to assign to the new secret. + - `type` (personal | shared, optional): Which type of secret to create. + +**Returns:** +- `CreateSecretResponse`: The response after creating the secret. + +#### Update Secret + +```typescript +const updatedSecret = await client.secrets().updateSecret("SECRET_TO_UPDATE", { + environment: "dev", + projectId: "", + secretValue: "UPDATED_SECRET_VALUE", + newSecretName: "NEW_SECRET_NAME2", // Optional + secretComment: "This is an updated secret", // Optional + secretPath: "/foo/bar", // Optional + secretReminderNote: "This is an updated reminder note", // Optional + secretReminderRepeatDays: 14, // Optional + skipMultilineEncoding: false, // Optional + tagIds: ["tagId1", "tagId2"], // Optional + type: "personal", // Optional + metadata: { // Optional + extra: "metadata" + } +}); +``` + +**Parameters:** +- `secretName` (string): The name of the secret to update.` +- `options` (object): + - `environment` (str): The environment in which to update the secret. + - `projectId` (str): The ID of your project. + - `secretValue` (str, optional): The new value of the secret. + - `newSecretName` (str, optional): A new name for the secret. + - `secretComment` (str, optional): An updated comment associated with the secret. + - `secretPath` (str): The path to the secret. + - `secretReminderNote` (str, optional): An updated note for the secret reminder. + - `secretReminderRepeatDays` (number, optional): Updated number of days after which to repeat secret reminders. + - `skipMultilineEncoding` (bool, optional): Whether to skip encoding for multiline secrets. + - `tagIds` (string[], optional): Array of tags to assign to the secret. + - `type` (personal | shared, optional): Which type of secret to create. + - `metadata` (object, optional): Assign additional details to the secret, accessible through the API. + +**Returns:** +- `UpdateSecretResponse`: The response after updating the secret. + +#### Get Secret by Name + +```typescript + const singleSecret = await client.secrets().getSecret({ + environment: "dev", + projectId: "", + secretName: "DATABASE_URL", + expandSecretReferences: true, // Optional + viewSecretValue: true, // Optional + includeImports: true, // Optional + secretPath: "/foo/bar", // Optional + type: "shared", // Optional + version: 1 // Optional + }); +``` + +**Parameters:** +- `environment` (str): The environment in which to retrieve the secret. +- `projectId` (str): The ID of your project. +- `secretName` (str): The name of the secret. +- `secretPath` (str, optional): The path to the secret. +- `expandSecretReferences` (bool, optional): Whether to expand secret references. +- `viewSecretValue` (bool, optional): Whether or not to reveal the secret value of the secret. If set to `false`, the `secretValue` is masked with ``. Defaults to `true`. +- `includeImports` (bool): Whether to include imported secrets. +- `version` (str, optional): The version of the secret to retrieve. Fetches the latest by default. +- `type` (personal | shared, optional): The type of secret to fetch. + + +**Returns:** +- `Secret`: Returns the secret object. + +#### Delete Secret by Name + +```typescript +const deletedSecret = await client.secrets().deleteSecret("SECRET_TO_DELETE", { + environment: "dev", + projectId: "", + secretPath: "/foo/bar", // Optional + type: "personal" // Optional +}); +``` + +**Parameters:** +- `secretName` (string): The name of the secret to delete. +- `options` (object): + - `projectId` (str): The ID of your project. + - `environment` (str): The environment in which to delete the secret. + - `secret_path` (str, optional): The path to the secret. + - `type` (personal | shared, optional): The type of secret to delete. + +**Returns:** +- `DeleteSecretResponse`: The response after deleting the secret. + + + + +### `dynamicSecrets` + + +#### Create a new dynamic secret + +Creating a new dynamic secret can be done by using the `.dynamicSecrets().create({})` function. More details below. + + +The input for creating new dynamic secret varies greatly between secret types. +For a more in-depth description of each input type for each dynamic secret type, please refer to [our API documentation](https://infisical.com/docs/api-reference/endpoints/dynamic-secrets/create) + + +##### Example for creating a new Redis dynamic secret + +```typescript +import { InfisicalSDK, DynamicSecretProviders } from "@infisical/sdk"; + const client = new InfisicalSDK(); + + await client.auth().universalAuth.login({ + // For localhost + clientId: "CLIENT_ID", + clientSecret: "CLIENT_SECRET" + }); +const dynamicSecret = await client.dynamicSecrets().create({ + provider: { + type: DynamicSecretProviders.Redis, + inputs: { + host: "", + port: 6479, + username: "", + password: "", // Only required if your Redis instance uses authentication (recommended) + creationStatement: "ACL SETUSER {{username}} on >{{password}} ~* &* +@all", + revocationStatement: "ACL DELUSER {{username}}" + } + }, + defaultTTL: "1h", + maxTTL: "24h", + name: "dynamic-secret-name", + projectSlug: "project-slug", + environmentSlug: "dev" +}); +console.log(dynamicSecret); +``` + +**Returns:** +- `DynamicSecret`: The created dynamic secret. + + +#### Delete a dynamic secret + +Note: Deleting a dynamic secret will also delete it's associated leases. + +```typescript +const deletedDynamicSecret = await client.dynamicSecrets().delete("dynamic-secret-name", { + environmentSlug: "dev", + projectSlug: "project-slug" +}); +``` + +**Parameters:** +- `secretName` (string): The ID of the dynamic secret to delete +- `options` (object): + - `projectSlug` (str): The ID of your project. + - `environment` (str): The environment in which to delete the secret. + +**Returns:** +- `DynamicSecret`: The deleted dynamic secret. + +### `dynamicSecrets.leases` +In this section you'll learn how to work with dynamic secret leases + + +#### Create a new lease + +```typescript +const lease = await client.dynamicSecrets().leases.create({ + dynamicSecretName: "dynamic-secret-name", + environmentSlug: "dev", + projectSlug: "your-project-slug", + path: "/foo/bar", + ttl: "5m" // Optional +}); + +console.log(lease); +``` + +**Your dynamic secret credentials will be contained user `lease.data` in this example.** + +**Parameters:** +- `dynamicSecretName` (string): The name of the dynamic secret you wish to create a lease for. +- `projectSlug` (string): The slug of the project where the secret is located. +- `environmentSlug` (string): The environment where the dynamic secret is located. +- `path` (string, optional): The path of where the dynamic secret is located. +- `ttl` (string, optional): A [vercel/ms](https://github.com/vercel/ms) encoded string representation of how long the lease credentials should be valid for. This will default to the dynamic secret's default TTL if not specified. + +**Returns:** +- `CreateLeaseResponse`: The dynamic secret lease result. + + +#### Delete a lease +```typescript +const deletedLease = await client.dynamicSecrets().leases.delete(newLease.lease.id, { + environmentSlug: "dev", + projectSlug: "test-zb-3a", + path: "/foo/bar", + isForced: false // Whether or not to forcefully delete the lease. This can't guarantee that the lease will be deleted from the external provider. +}); +``` + +**Parameters:** +- `leaseId` (string): The ID of the lease you want to delete. +- options: + - `projectSlug` (string): The slug of the project where the secret is located. + - `environmentSlug` (string): The environment where the dynamic secret is located. + - `path` (string, optional): The path of where the dynamic secret is located. + - `isForced` (bool, optional): Whether or not to forcefully delete the lease. This can't guarantee that the lease will be deleted from the external provider, and is potentially unsafe for sensitive dynamic secrets. + +**Returns:** +- `DeleteLeaseResponse`: The deleted lease result. + +#### Renew a lease + +Please note that renewals must happen **before** the lease has fully expired. After renewing the lease, you won't be given new credentials. Instead the existing credentials will continue to live for the specified TTL + +```typescript +const renewedLease = await client.dynamicSecrets().leases.renew(newLease.lease.id, { + environmentSlug: "dev", + projectSlug: "project-slug", + path: "/foo/bar", // Optional + ttl: "10m" // Optional + }); +``` + +**Parameters:** +- `leaseId` (string): The ID of the lease you want to delete. +- `options` (object): + - `projectSlug` (string): The slug of the project where the secret is located. + - `environmentSlug` (string): The environment where the dynamic secret is located. + - `path` (string, optional): The path of where the dynamic secret is located. + - `ttl` (string, optional): A [vercel/ms](https://github.com/vercel/ms) encoded string representation of how long the lease credentials should be valid for. This will default to the dynamic secret's default TTL if not specified. + +**Returns:** +- `RenewLeaseResponse`: The renewed lease response _(doesn't contain new credentials)_. + +### `projects` + +#### Create a new project + +```typescript +const project = await client.projects().create({ + projectName: "", + type: "secret-manager", // cert-manager, secret-manager, kms, ssh + projectDescription: "", // Optional + slug: "", // Optional + template: "", // Optional + kmsKeyId: "kms-key-id" // Optional +}); +``` + +**Parameters:** +- `projectName` (string): The name of the project to create. +- `type` (string): The type of project to create. Valid options are `secret-manager`, `cert-manager`, `kms`, `ssh` +- `projectDescription` (string): An optional description of the project to create. +- `slug` (string): An optional slug for the project to create. If not provided, one will be generated automatically. +- `template` (string): Optionally provide a project template name to use for creating this project. +- `kmsKeyId` (string): The ID of the KMS key to use for the project. Will use the Infisical KMS by default. + +**Returns:** +- `Project`: The project that was created. + + +#### Invite members to a project + +When inviting members to projects, you must either specify the `emails` or `usernames`. If neither are specified, the SDK will throw an error. + +```typescript +const memberships = await client.projects().inviteMembers({ + projectId: project.id, + emails: ["test1@example.com", "test2@example.com"], // Optional + usernames: ["example-user3", "example-user4"] // Optional + roleSlugs: ["member"] // Optional +}); +``` + +**Parameters:** +- `projectId`: (string): The ID of the project to invite members to +- `emails`: (string[]): An array of emails of the users to invite to the project. +- `usernames`: (string[]) An array of usernames of the users to invite to the project. +- `roleSlugs`: (string[]): An array of role slugs to assign to the members. If not specified, this will default to `member`. + +**Returns:** +- `Membership[]`: An array of the created project memberships. + +### `environments` + +#### Create a new environment + +```typescript +const environment = await client.environments().create({ + name: "", + projectId: "", + slug: "", + position: 1 // Optional +}); +``` + +**Parameters:** +- `name` (string): The name of the environment to be created. +- `projectId` (string): The ID of the project to create the environment within. +- `slug`: (string): The slug of the environment to be created. +- `position` (number): An optional position of the environment to be created. The position is used in the Infisical UI to display environments in order. Environments with the lowest position come first. + +**Returns:** +- `Environment`: The environment that was created. + +#### Create a new folder + +```typescript +const folder = await client.folders().create({ + name: "", + path: "", + projectId: "", + environment: "", + description: "" // Optional +}); +``` + +**Parameters:** +- `name` (string): The name of the folder to create. +- `path` (string): The path where of where to create the folder. Defaults to `/`, which is the root folder. +- `projectId` (string): The ID of the project to create the folder within. +- `environment` (string): The slug of the environment to create the folder within. +- `description` (string): An optional folder description. + +**Returns:** +- `Folder`: The folder that was created. + +#### List folders + +```typescript +const folders = await client.folders().listFolders({ + environment: "dev", + projectId: "", + path: "/foo/bar", // Optional + recursive: false // Optional +}); +``` + +**Parameters:** +- `environment` (string): The slug of the environment to list folders within. +- `projectId` (string): The ID of the project to list folders within. +- `path` (string): The path to list folders within. Defaults to `/`, which is the root folder. +- `recursive` (boolean): An optional flag to list folders recursively. Defaults to `false`. + +**Returns:** +- `Folder[]`: An array of folders. \ No newline at end of file diff --git a/docs/sdks/languages/php.mdx b/docs/sdks/languages/php.mdx new file mode 100644 index 000000000..e3db5e5cd --- /dev/null +++ b/docs/sdks/languages/php.mdx @@ -0,0 +1,204 @@ +--- +title: "Infisical PHP SDK" +sidebarTitle: "PHP" +icon: "/images/sdks/languages/php.svg" +--- + +If you're working with PHP, the official Infisical PHP SDK package is the easiest way to fetch and work with secrets for your application. + +## Installation + +```bash +composer require infisical/php-sdk +``` + +## Getting Started + +```php +auth()->universalAuth()->login( + "your-machine-identity-client-id", + "your-machine-identity-client-secret" +); + +// List secrets +$params = new \Infisical\SDK\Models\ListSecretsParameters( + environment: "dev", + secretPath: "/", + projectId: "your-project-id" +); + +$secrets = $sdk->secrets()->list($params); +echo "Fetched secrets: " . count($secrets) . "\n"; +``` + +## Core Methods + +The SDK methods are organized into the following high-level categories: + +1. `auth`: Handles authentication methods. +2. `secrets`: Manages CRUD operations for secrets. + +### `Auth` + +The `auth` component provides methods for authentication: + +#### Universal Auth + +**Authenticating** +```php +$response = $sdk->auth()->universal_auth()->login( + "your-machine-identity-client-id", + "your-machine-identity-client-secret" +); +``` + +**Parameters:** +- `clientId` (string): The client ID of your Machine Identity. +- `clientSecret` (string): The client secret of your Machine Identity. + + + We do not recommend hardcoding your [Machine Identity Tokens](/documentation/platform/identities/overview). Setting them as environment variables would be best. + + +### `Secrets` + +This sub-class handles operations related to secrets: + +#### List Secrets + +```php +use Infisical\SDK\Models\ListSecretsParameters; + +$params = new ListSecretsParameters( + environment: "dev", + secretPath: "/", + projectId: "your-project-id", + tagSlugs: ["tag1", "tag2"], // Optional + recursive: true, // Optional + expandSecretReferences: true, // Optional + attachToProcessEnv: false, // Optional + skipUniqueValidation: false // Optional +); + +$secrets = $sdk->secrets()->list($params); +``` + +**Parameters:** +- `environment` (string): The environment in which to list secrets (e.g., "dev"). +- `projectId` (string): The ID of your project. +- `secretPath` (string, optional): The path to the secrets. +- `tagSlugs` (array, optional): Tags to filter secrets. +- `recursive` (bool, optional): Whether to list secrets recursively. +- `expandSecretReferences` (bool, optional): Whether to expand secret references. +- `attachToProcessEnv` (bool, optional): Whether to attach secrets to process environment variables. +- `skipUniqueValidation` (bool, optional): Whether to skip unique validation. + +**Returns:** +- `Secret[]`: An array of secret objects. + +#### Create Secret + +```php +use Infisical\SDK\Models\CreateSecretParameters; + +$params = new CreateSecretParameters( + secretKey: "SECRET_NAME", + secretValue: "SECRET_VALUE", + environment: "dev", + secretPath: "/", + projectId: "your-project-id" +); + +$createdSecret = $sdk->secrets()->create($params); +``` + +**Parameters:** +- `secretKey` (string): The name of the secret to create. +- `secretValue` (string): The value of the secret. +- `environment` (string): The environment in which to create the secret. +- `projectId` (string): The ID of your project. +- `secretPath` (string, optional): The path to the secret. + +**Returns:** +- `Secret`: The created secret object. + +#### Get Secret + +```php +use Infisical\SDK\Models\GetSecretParameters; + +$params = new GetSecretParameters( + secretKey: "SECRET_NAME", + environment: "dev", + secretPath: "/", + projectId: "your-project-id" +); + +$secret = $sdk->secrets()->get($params); +``` + +**Parameters:** +- `secretKey` (string): The name of the secret to retrieve. +- `environment` (string): The environment in which to retrieve the secret. +- `projectId` (string): The ID of your project. +- `secretPath` (string, optional): The path to the secret. + +**Returns:** +- `Secret`: The retrieved secret object. + +#### Update Secret + +```php +use Infisical\SDK\Models\UpdateSecretParameters; + +$params = new UpdateSecretParameters( + secretKey: "SECRET_NAME", + newSecretValue: "UPDATED_SECRET_VALUE", + environment: "dev", + secretPath: "/", + projectId: "your-project-id" +); + +$updatedSecret = $sdk->secrets()->update($params); +``` + +**Parameters:** +- `secretKey` (string): The name of the secret to update. +- `newSecretValue` (string): The new value of the secret. +- `environment` (string): The environment in which to update the secret. +- `projectId` (string): The ID of your project. +- `secretPath` (string, optional): The path to the secret. + +**Returns:** +- `Secret`: The updated secret object. + +#### Delete Secret + +```php +use Infisical\SDK\Models\DeleteSecretParameters; + +$params = new DeleteSecretParameters( + secretKey: "SECRET_NAME", + environment: "dev", + secretPath: "/", + projectId: "your-project-id" +); + +$deletedSecret = $sdk->secrets()->delete($params); +``` + +**Parameters:** +- `secretKey` (string): The name of the secret to delete. +- `environment` (string): The environment in which to delete the secret. +- `projectId` (string): The ID of your project. +- `secretPath` (string, optional): The path to the secret. + +**Returns:** +- `Secret`: The deleted secret object. \ No newline at end of file diff --git a/docs/sdks/languages/python.mdx b/docs/sdks/languages/python.mdx index f888c0819..066ecc060 100644 --- a/docs/sdks/languages/python.mdx +++ b/docs/sdks/languages/python.mdx @@ -1,533 +1,418 @@ --- title: "Infisical Python SDK" sidebarTitle: "Python" -url: "https://github.com/Infisical/python-sdk-official?tab=readme-ov-file#infisical-python-sdk" icon: "/images/sdks/languages/python.svg" --- -{/* If you're working with Python, the official [infisical-python](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) package is the easiest way to fetch and work with secrets for your application. +If you're working with Python, the official Infisical Python SDK package is the easiest way to fetch and work with secrets for your application. -- [PyPi Package](https://pypi.org/project/infisical-python/) -- [Github Repository](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) +### Migrating to version 1.0.3 or above -## Basic Usage +We have recently rolled out our first stable version of the SDK, version `1.0.3` and above. -```py -from flask import Flask -from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions, AuthenticationOptions, UniversalAuthMethod +The 1.0.3 version comes with a few key changes that may change how you're using the SDK. +1. **Removal of `rest`**: The SDK no longer exposes the entire Infisical API. This was nessecary as we have moved away from using an OpenAPI generator approach. We aim to add support for more API resources in the near future. If you have any specific requests, please [open an issue](https://github.com/Infisical/python-sdk-official/issues). -app = Flask(__name__) +2. **New response types**: The 1.0.3 release uses return types that differ from the older versions. The new return types such as `BaseSecret`, are all exported from the Infisical SDK. -client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - universal_auth=UniversalAuthMethod( - client_id="CLIENT_ID", - client_secret="CLIENT_SECRET", - ) - ) -)) +3. **Property renaming**: Some properties on the responses have been slightly renamed. An example of this would be that the `secret_key` property on the `get_secret_by_name()` method, that has been renamed to `secretKey`. -@app.route("/") -def hello_world(): - # access value +With this in mind, you're ready to upgrade your SDK version to `1.0.3` or above. - name = client.getSecret(options=GetSecretOptions( - environment="dev", - project_id="PROJECT_ID", - secret_name="NAME" - )) +You can refer to our [legacy documentation](https://github.com/Infisical/python-sdk-official/tree/9b0403938ee5ae599d42c5f1fdf9158671a15606?tab=readme-ov-file#infisical-python-sdk) if need be. - return f"Hello! My name is: {name.secret_value}" -``` +## Requirements -This example demonstrates how to use the Infisical Python SDK with a Flask application. The application retrieves a secret named "NAME" and responds to requests with a greeting that includes the secret value. - - - We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. - +Python 3.7+ ## Installation -Run `pip` to add `infisical-python` to your project - -```console -$ pip install infisical-python +```bash +pip install infisicalsdk ``` -Note: You need Python 3.7+. +## Getting Started -## Configuration +```python +from infisical_sdk import InfisicalSDKClient -Import the SDK and create a client instance with your [Machine Identity](/api-reference/overview/authentication). +# Initialize the client +client = InfisicalSDKClient(host="https://app.infisical.com") -```py -from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, UniversalAuthMethod +# Authenticate (example using Universal Auth) +client.auth.universal_auth.login( + client_id="", + client_secret="" +) -client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - universal_auth=UniversalAuthMethod( - client_id="CLIENT_ID", - client_secret="CLIENT_SECRET", - ) - ) -)) +# Use the SDK to interact with Infisical +secrets = client.secrets.list_secrets(project_id="", environment_slug="dev", secret_path="/") ``` -#### Parameters +## InfisicalSDKClient Parameters - - - - Your Infisical Client ID. +The `InfisicalSDKClient` takes the following parameters, which are used as a global configuration for the lifetime of the SDK instance. - **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. - - - Your Infisical Client Secret. +- **host** (`str`, _Optional_): The host URL for your Infisical instance. Defaults to `https://app.infisical.com`. +- **token** (`str`, _Optional_): Specify an authentication token to use for all requests. If provided, you will not need to call any of the `auth` methods. Defaults to `None` +- **cache_ttl** (`int`, _Optional_): The SDK has built-in client-side caching for secrets, greatly improving response times. By default, secrets are cached for 1 minute (60 seconds). You can disable caching by setting `cache_ttl` to `None`, or adjust the duration in seconds as needed. - **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. - - - If you want to directly pass an access token obtained from the authentication endpoints, you can do so. +```python +client = InfisicalSDKClient( + host="https://app.infisical.com", # Defaults to https://app.infisical.com + token="", # If not set, use the client.auth() methods. + cache_ttl = 300 # `None` to disable caching +) +``` - **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. - +## Core Methods - - Time-to-live (in seconds) for refreshing cached secrets. - If manually set to 0, caching will be disabled, this is not recommended. - +The SDK methods are organized into the following high-level categories: - - Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) - +1. `auth`: Handles authentication methods. +2. `secrets`: Manages CRUD operations for secrets. +3. `kms`: Perform cryptographic operations with Infisical KMS. - - Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate. - +### `auth` - - The authentication object to use for the client. This is required unless you're using environment variables. - - - - - -### Authentication - -The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. +The `Auth` component provides methods for authentication: #### Universal Auth -**Using environment variables** -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. - -**Using the SDK directly** -```python3 -from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, UniversalAuthMethod - -client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - universal_auth=UniversalAuthMethod( - client_id="CLIENT_ID", - client_secret="CLIENT_SECRET", - ) - ) -)) -``` - -#### GCP ID Token Auth - - Please note that this authentication method will only work if you're running your application on Google Cloud Platform. - Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```py -from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, GCPIDTokenAuthMethod - -client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - gcp_id_token=GCPIDTokenAuthMethod( - identity_id="MACHINE_IDENTITY_ID", - ) - ) -)) -``` - -#### GCP IAM Auth - -**Using environment variables** -- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. - -**Using the SDK directly** -```py -from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, GCPIamAuthMethod - - -client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - gcp_iam=GCPIamAuthMethod( - identity_id="MACHINE_IDENTITY_ID", - service_account_key_file_path="./path/to/service_account_key.json" - ) - ) -)) -``` - -#### AWS IAM Auth - - Please note that this authentication method will only work if you're running your application on AWS. - Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```py -from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, AWSIamAuthMethod - -client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - aws_iam=AWSIamAuthMethod(identity_id="MACHINE_IDENTITY_ID") - ) -)) -``` - -#### Azure Auth - - Please note that this authentication method will only work if you're running your application on Azure. - Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** ```python -from infisical_client import InfisicalClient, ClientSettings, AuthenticationOptions, AzureAuthMethod - -kubernetes_client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - azure=AzureAuthMethod( - identity_id="YOUR_IDENTITY_ID", - ) - ) -)) +response = client.auth.universal_auth.login( + client_id="", + client_secret="" +) ``` +#### AWS Auth -#### Kubernetes Auth - - Please note that this authentication method will only work if you're running your application on Kubernetes. - Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. - -**Using the SDK directly** ```python -from infisical_client import InfisicalClient, ClientSettings, AuthenticationOptions, KubernetesAuthMethod - -kubernetes_client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - kubernetes=KubernetesAuthMethod( - identity_id="YOUR_IDENTITY_ID", - service_account_token_path="/var/run/secrets/kubernetes.io/serviceaccount/token" # Optional - ) - ) -)) +response = client.auth.aws_auth.login(identity_id="") ``` -### Caching +#### OIDC Auth -To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cache_ttl" option when creating the client. - -## Working with Secrets - -### client.listSecrets(options) - -```py -client.listSecrets(options=ListSecretsOptions( - environment="dev", - project_id="PROJECT_ID" -)) -``` - -Retrieve all secrets within the Infisical project and environment that client is connected to - -#### Parameters - - - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The project ID where the secret lives in. - - - - The path from where secrets should be fetched from. - - - - Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `process.env["SECRET_NAME"]`. - - - - Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. - - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - - - -### client.getSecret(options) - -```py -secret = client.getSecret(options=GetSecretOptions( - environment="dev", - project_id="PROJECT_ID", - secret_name="API_KEY" -)) -value = secret.secret_value # get its value -``` - -By default, `getSecret()` fetches and returns a shared secret. If not found, it returns a personal secret. - -#### Parameters - - - - - The key of the secret to retrieve - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The project ID where the secret lives in. - - - The path from where secret should be fetched from. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "personal". - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - -### client.createSecret(options) - -```py -api_key = client.createSecret(options=CreateSecretOptions( - secret_name="API_KEY", - secret_value="Some API Key", - environment="dev", - project_id="PROJECT_ID" -)) -``` - -Create a new secret in Infisical. - -#### Parameters - - - - - The key of the secret to create. - - - The value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be created. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.updateSecret(options) - -```py -client.updateSecret(options=UpdateSecretOptions( - secret_name="API_KEY", - secret_value="NEW_VALUE", - environment="dev", - project_id="PROJECT_ID" -)) -``` - -Update an existing secret in Infisical. - -#### Parameters - - - - - The key of the secret to update. - - - The new value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be updated. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.deleteSecret(options) - -```py -client.deleteSecret(options=DeleteSecretOptions( - environment="dev", - project_id="PROJECT_ID", - secret_name="API_KEY" -)) -``` - -Delete a secret in Infisical. - -#### Parameters - - - - - The key of the secret to update. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be deleted. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -## Cryptography - -### Create a symmetric key - -Create a base64-encoded, 256-bit symmetric key to be used for encryption/decryption. - -```py -key = client.createSymmetricKey() -``` - -#### Returns (string) - -`key` (string): A base64-encoded, 256-bit symmetric key, that can be used for encryption/decryption purposes. - -### Encrypt symmetric - -```py -encryptOptions = EncryptSymmetricOptions( - key=key, - plaintext="Infisical is awesome!" +```python +response = client.auth.oidc_auth.login( + identity_id="", + jwt="" ) - -encryptedData = client.encryptSymmetric(encryptOptions) ``` -#### Parameters +**Parameters:** +- `identity_id` (str): The ID of the OIDC identity configuration in Infisical. +- `jwt` (str): The OIDC JWT token obtained from your identity provider. - - - - The plaintext you want to encrypt. - - - The symmetric key to use for encryption. - - - +This authentication method is useful when integrating with OIDC-compliant identity providers like Okta, Auth0, or any service that issues OIDC tokens. -#### Returns (object) +### `secrets` -`tag` (string): A base64-encoded, 128-bit authentication tag. `iv` (string): A base64-encoded, 96-bit initialization vector. `ciphertext` (string): A base64-encoded, encrypted ciphertext. +This sub-class handles operations related to secrets: -### Decrypt symmetric +#### List Secrets -```py -decryptOptions = DecryptSymmetricOptions( - ciphertext=encryptedData.ciphertext, - iv=encryptedData.iv, - tag=encryptedData.tag, - key=key +```python +secrets = client.secrets.list_secrets( + project_id="", + environment_slug="dev", + secret_path="/", + expand_secret_references=True, # Optional + view_secret_value=True, # Optional + recursive=False, # Optional + include_imports=True, # Optional + tag_filters=[] # Optional ) - -decryptedString = client.decryptSymmetric(decryptOptions) - - ``` -#### Parameters +**Parameters:** +- `project_id` (str): The ID of your project. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to list secrets (e.g., "dev"). +- `secret_path` (str): The path to the secrets. +- `expand_secret_references` (bool): Whether to expand secret references. +- `view_secret_value` (bool): Whether or not to include the secret value in the response. If set to false, the `secretValue` will be masked with ``. Defaults to true. +- `recursive` (bool): Whether to list secrets recursively. +- `include_imports` (bool): Whether to include imported secrets. +- `tag_filters` (List[str]): Tags to filter secrets. - - - - The ciphertext you want to decrypt. - - - The symmetric key to use for encryption. - - - The initialization vector to use for decryption. - - - The authentication tag to use for decryption. - - - +**Note:** Exactly one of `project_id` or `project_slug` is required. If both are provided, `project_id` takes precedence. -#### Returns (string) +**Returns:** +- `ListSecretsResponse`: The response containing the list of secrets. -`plaintext` (string): The decrypted plaintext. */} +#### Create Secret + +```python +new_secret = client.secrets.create_secret_by_name( + secret_name="NEW_SECRET", + project_id="", + secret_path="/", + environment_slug="dev", + secret_value="secret_value", + secret_comment="Optional comment", + skip_multiline_encoding=False, + secret_reminder_repeat_days=30, # Optional + secret_reminder_note="Remember to update this secret", # Optional + secret_metadata=[{"key": "metadata_key", "value": "metadata_value"}], # Optional + tags_ids=["tag_id_1", "tag_id_2"] # Optional +) +``` + +**Parameters:** +- `secret_name` (str): The name of the secret. +- `project_id` (str): The ID of your project. +- `project_slug` (str): The slug of your project. +- `secret_path` (str): The path to the secret. +- `environment_slug` (str): The environment in which to create the secret. +- `secret_value` (str): The value of the secret. +- `secret_comment` (str, optional): A comment associated with the secret. +- `skip_multiline_encoding` (bool, optional): Whether to skip encoding for multiline secrets. +- `secret_reminder_repeat_days` (Union[float, int], optional): Number of days after which to repeat secret reminders. +- `secret_reminder_note` (str, optional): A note for the secret reminder. +- `secret_metadata` (List[Dict[str, Any]], optional): Metadata associated with the secret. +- `tags_ids` (List[str], optional): IDs of tags to associate with the secret. + +**Note:** Exactly one of `project_id` or `project_slug` is required. If both are provided, `project_id` takes precedence. + +**Returns:** +- `BaseSecret`: The response after creating the secret. + +#### Update Secret + +```python +updated_secret = client.secrets.update_secret_by_name( + current_secret_name="EXISTING_SECRET", + project_id="", + project_slug="", + secret_path="/", + environment_slug="dev", + secret_value="new_secret_value", + secret_comment="Updated comment", # Optional + skip_multiline_encoding=False, + secret_reminder_repeat_days=30, # Optional + secret_reminder_note="Updated reminder note", # Optional + new_secret_name="NEW_NAME", # Optional + secret_metadata=[{"key": "metadata_key", "value": "metadata_value"}], # Optional + tags_ids=["tag_id_1", "tag_id_2"] # Optional +) +``` + +**Parameters:** +- `current_secret_name` (str): The current name of the secret. +- `project_id` (str): The ID of your project. +- `project_slug` (str): The slug of your project. +- `secret_path` (str): The path to the secret. +- `environment_slug` (str): The environment in which to update the secret. +- `secret_value` (str, optional): The new value of the secret. +- `secret_comment` (str, optional): An updated comment associated with the secret. +- `skip_multiline_encoding` (bool, optional): Whether to skip encoding for multiline secrets. +- `secret_reminder_repeat_days` (Union[float, int], optional): Updated number of days after which to repeat secret reminders. +- `secret_reminder_note` (str, optional): An updated note for the secret reminder. +- `new_secret_name` (str, optional): A new name for the secret. +- `secret_metadata` (List[Dict[str, Any]], optional): Metadata associated with the secret. +- `tags_ids` (List[str], optional): IDs of tags to associate with the secret. + +**Note:** Exactly one of `project_id` or `project_slug` is required. If both are provided, `project_id` takes precedence. + +**Returns:** +- `BaseSecret`: The response after updating the secret. + +#### Get Secret by Name + +```python +secret = client.secrets.get_secret_by_name( + secret_name="EXISTING_SECRET", + project_id="", + environment_slug="dev", + secret_path="/", + expand_secret_references=True, # Optional + view_secret_value=True, # Optional + include_imports=True, # Optional + version=None # Optional +) +``` + +**Parameters:** +- `secret_name` (str): The name of the secret. +- `project_id` (str): The ID of your project. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to retrieve the secret. +- `secret_path` (str): The path to the secret. +- `expand_secret_references` (bool): Whether to expand secret references. +- `view_secret_value` (bool): Whether or not to include the secret value in the response. If set to false, the `secretValue` will be masked with ``. Defaults to true. +- `include_imports` (bool): Whether to include imported secrets. +- `version` (str, optional): The version of the secret to retrieve. Fetches the latest by default. + +**Note:** Exactly one of `project_id` or `project_slug` is required. If both are provided, `project_id` takes precedence. + +**Returns:** +- `BaseSecret`: The response containing the secret. + +#### Delete Secret by Name + +```python +deleted_secret = client.secrets.delete_secret_by_name( + secret_name="EXISTING_SECRET", + project_id="", + environment_slug="dev", + secret_path="/" +) +``` + +**Parameters:** +- `secret_name` (str): The name of the secret to delete. +- `project_id` (str): The ID of your project. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to delete the secret. +- `secret_path` (str): The path to the secret. + +**Note:** Exactly one of `project_id` or `project_slug` is required. If both are provided, `project_id` takes precedence. + +**Returns:** +- `BaseSecret`: The response after deleting the secret. + +### `kms` + +This sub-class handles KMS related operations: + +#### List KMS Keys + +```python +kms_keys = client.kms.list_keys( + project_id="", + offset=0, # Optional + limit=100, # Optional + order_by=KmsKeysOrderBy.NAME, # Optional + order_direction=OrderDirection.ASC, # Optional + search=None # Optional +) +``` + +**Parameters:** +- `project_id` (str): The ID of your project. +- `offset` (int, optional): The offset to paginate from. +- `limit` (int, optional): The page size for paginating. +- `order_by` (KmsKeysOrderBy, optional): The key property to order the list response by. +- `order_direction` (OrderDirection, optional): The direction to order the list response in. +- `search` (str, optional): The text value to filter key names by. + +**Returns:** +- `ListKmsKeysResponse`: The response containing the list of KMS keys. + +#### Get KMS Key by ID + +```python +kms_key = client.kms.get_key_by_id( + key_id="" +) +``` + +**Parameters:** +- `key_id` (str): The ID of the key to retrieve. + +**Returns:** +- `KmsKey`: The specified key. + +#### Get KMS Key by Name + +```python +kms_key = client.kms.get_key_by_name( + key_name="my-key", + project_id="" +) +``` + +**Parameters:** +- `key_name` (str): The name of the key to retrieve. +- `project_id` (str): The ID of your project. + +**Returns:** +- `KmsKey`: The specified key. + +#### Create KMS Key + +```python +kms_key = client.kms.create_key( + name="my-key", + project_id="", + encryption_algorithm=SymmetricEncryption.AES_GCM_256, + description=None # Optional +) +``` + +**Parameters:** +- `name` (str): The name of the key (must be slug-friendly). +- `project_id` (str): The ID of your project. +- `encryption_algorithm` (SymmetricEncryption): The encryption algorithm this key should use. +- `description` (str, optional): A description of your key. + +**Returns:** +- `KmsKey`: The newly created key. + +#### Update KMS Key + +```python +updated_key = client.kms.update_key( + key_id="", + name="my-updated-key", # Optional + description="Updated description", # Optional + is_disabled=True # Optional +) +``` + +**Parameters:** +- `key_id` (str): The ID of the key to be updated. +- `name` (str, optional): The updated name of the key (must be slug-friendly). +- `description` (str): The updated description of the key. +- `is_disabled` (str): The flag to disable operations with this key. + +**Returns:** +- `KmsKey`: The updated key. + +#### Delete KMS Key + +```python +deleted_key = client.kms.delete_key( + key_id="" +) +``` + +**Parameters:** +- `key_id` (str): The ID of the key to be deleted. + +**Returns:** +- `KmsKey`: The deleted key. + +#### Encrypt Data with KMS Key + +```python +encrypted_data = client.kms.encrypt_data( + key_id="", + base64EncodedPlaintext="TXkgc2VjcmV0IG1lc3NhZ2U=" # must be base64 encoded +) +``` + +**Parameters:** +- `key_id` (str): The ID of the key to encrypt the data with. +- `base64EncodedPlaintext` (str): The plaintext data to encrypt (must be base64 encoded). + +**Returns:** +- `str`: The encrypted ciphertext. + +#### Decrypt Data with KMS Key + +```python +decrypted_data = client.kms.decrypt_data( + key_id="", + ciphertext="Aq96Ry7sMH3k/ogaIB5MiSfH+LblQRBu69lcJe0GfIvI48ZvbWY+9JulyoQYdjAx" +) +``` + +**Parameters:** +- `key_id` (str): The ID of the key to decrypt the data with. +- `ciphertext` (str): The ciphertext returned from the encrypt operation. + +**Returns:** +- `str`: The base64 encoded plaintext. \ No newline at end of file diff --git a/docs/sdks/languages/ruby.mdx b/docs/sdks/languages/ruby.mdx index 5780c48ec..3fb9cb3e1 100644 --- a/docs/sdks/languages/ruby.mdx +++ b/docs/sdks/languages/ruby.mdx @@ -4,9 +4,7 @@ sidebarTitle: "Ruby" icon: "/images/sdks/languages/ruby.svg" --- - - -If you're working with Ruby, the official [Infisical Ruby SDK](https://github.com/infisical/sdk) package is the easiest way to fetch and work with secrets for your application. +If you're working with Ruby, the official Infisical Ruby SDK package is the easiest way to fetch and work with secrets for your application. - [Ruby Package](https://rubygems.org/gems/infisical-sdk) - [Github Repository](https://github.com/infisical/sdk) diff --git a/docs/sdks/languages/rust.mdx b/docs/sdks/languages/rust.mdx index 3d2f16175..40ad78bfb 100644 --- a/docs/sdks/languages/rust.mdx +++ b/docs/sdks/languages/rust.mdx @@ -2,5 +2,494 @@ title: "Infisical Rust SDK" sidebarTitle: "Rust" icon: "/images/sdks/languages/rust.svg" -url: "https://github.com/Infisical/rust-sdk?tab=readme-ov-file#infisical--the-official-infisical-rust-sdk" ---- \ No newline at end of file +--- + +If you're working with Rust, the official Infisical Rust SDK package is the easiest way to fetch and work with secrets for your application. + +### Installation + +```bash +cargo add infisical +``` + +### Getting Started + +The easiest way to get started is to use the builder pattern for both the client and your requests. + +```rust +use infisical::{AuthMethod, Client, InfisicalError, encode_base64, decode_base64}; +use infisical::secrets::GetSecretRequest; + +async fn fetch_secret() -> Result<(), InfisicalError> { + // 1. Build the client. You can chain methods to configure it. + let mut client = Client::builder() + .base_url("https://app.infisical.com") // Optional: defaults to https://app.infisical.com + .build() + .await?; + + // 2. Set up your authentication method and log in. + let auth_method = AuthMethod::new_universal_auth("", ""); + client.login(auth_method).await?; + + // 3. Build a request to get a secret. + // Required parameters (name, project_id, environment) are passed to `builder()`. + let request = GetSecretRequest::builder("API_KEY", "", "dev") + .path("/") // Optional parameters are set with builder methods. + .build(); + + // 4. Make the API call. + let secret = client.secrets().get(request).await?; + + println!("Fetched secret key: {}", secret.secret_key); + // For security, avoid printing the secret value in production code! + // println!("Secret value: {}", secret.secret_value); + + Ok(()) +} +``` + +### Client Configuration + +The `Client::builder()` provides several configuration options: + +```rust +let mut client = Client::builder() + .base_url("https://app.infisical.com") // Optional: set custom Infisical instance URL + .build() + .await?; +``` + +**Parameters** +- `.base_url(url)`: Optional method to set the Infisical instance URL. Defaults to `https://app.infisical.com` for Infisical Cloud. Use `https://eu.infisical.com` for EU and `http://localhost:8080` for local development. + +### Core Methods + +The SDK methods are organized into the following high-level categories: + +- `Client::builder()`: The main entry point for creating a client. +- `client.login()`: Allows client to make authenticated requests to the API. +- `client.secrets()`: Provides access to all CRUD operations for secrets. +- `client.kms()`: Provides access to all KMS (Key Management Service) operations. + +### Helper Functions + +The SDK provides utility functions for common operations: + +```rust +use infisical::{encode_base64, decode_base64}; + +// Base64 encode a string +let encoded = encode_base64("sensitive data"); +println!("Encoded: {}", encoded); + +// Base64 decode a string +let decoded = decode_base64(&encoded)?; +println!("Decoded: {}", decoded); +``` + +**Available Functions** +- `encode_base64(data: &str) -> String`: Encodes a string as base64 +- `decode_base64(data: &str) -> Result`: Decodes a base64 string + +### `secrets` + +All secret operations are accessed via `client.secrets()`. Each operation has a dedicated request builder. + +#### Create Secret + +Create a new secret in your project. + +**Example** + +```rust +use infisical::secrets::CreateSecretRequest; + +let request = CreateSecretRequest::builder( + "API_KEY", + "your-secret-value", + "", + "dev" +) +.path("/") +.secret_comment("A comment for the new secret") +.build(); + +let created_secret = client.secrets().create(request).await?; +``` + +**Parameters** + +- `secret_name`, `secret_value`, `project_id`, `environment`: Required parameters passed to the `builder()` function. +- `.path(path)`: Optional method to set the secret's path (defaults to `/`). +- `.secret_comment(comment)`: Optional method to add a comment. +- `.skip_multiline_encoding(bool)`: Optional method to control multiline encoding (defaults to `false`). +- `.r#type(type)`: Optional method to set the secret type (`shared` or `personal`), defaults to `shared`. + +#### Get Secret + +Retrieve a specific secret by name. + +**Example** + +```rust +use infisical::secrets::GetSecretRequest; + +let request = GetSecretRequest::builder("API_KEY", "", "dev") + .path("/") + .build(); + +let secret = client.secrets().get(request).await?; +``` + +**Parameters** + +- `secret_name`, `project_id`, `environment`: Required parameters passed to the `builder()` function. +- `.path(path)`: Optional method to set the secret's path (defaults to `/`). +- `.expand_secret_references(bool)`: Optional method to control secret reference expansion (defaults to `true`). +- `.r#type(type)`: Optional method to set the secret type (`shared` or `personal`), defaults to `shared`. + +#### List Secrets + +List all secrets in a project and environment. + +**Example** + +```rust +use infisical::secrets::ListSecretsRequest; + +let request = ListSecretsRequest::builder("", "dev") + .path("/") + .recursive(true) + .build(); + +let secrets = client.secrets().list(request).await?; +``` + +**Parameters** + +- `project_id`, `environment`: Required parameters passed to the `builder()` function. +- `.path(path)`: Optional method to set the path from which to list secrets (defaults to `/`). +- `.expand_secret_references(bool)`: Optional method to control secret reference expansion (defaults to `true`). +- `.recursive(bool)`: Optional method to recursively list secrets from sub-folders (defaults to `false`). +- `.attach_to_process_env(bool)`: Optional method to attach fetched secrets to the current process's environment variables (defaults to `false`). + +#### Update Secret + +Update an existing secret. + +**Example** + +```rust +use infisical::secrets::UpdateSecretRequest; + +let request = UpdateSecretRequest::builder("API_KEY", "", "dev") + .secret_value("new-secret-value") // Set the new value + .build(); + +let updated_secret = client.secrets().update(request).await?; +``` + +**Parameters** + +- `secret_name`, `project_id`, `environment`: Required parameters passed to the `builder()` function. +- `.new_secret_name(name)`: Optional method to rename the secret. +- `.secret_value(value)`: Optional method to set a new value for the secret. +- `.path(path)`: Optional method to set the secret's path. +- `.secret_comment(comment)`: Optional method to add or change the comment. +- `.skip_multiline_encoding(bool)`: Optional method to control multiline encoding. +- `.r#type(type)`: Optional method to set the secret type (`shared` or `personal`). + +#### Delete Secret + +Delete a secret from your project. + +**Example** + +```rust +use infisical::secrets::DeleteSecretRequest; + +let request = DeleteSecretRequest::builder("API_KEY", "", "dev") + .path("/") + .build(); + +let deleted_secret = client.secrets().delete(request).await?; +``` + +**Parameters** + +- `secret_name`, `project_id`, `environment`: Required parameters passed to the `builder()` function. +- `.path(path)`: Optional method to set the secret's path (defaults to `/`). +- `.r#type(type)`: Optional method to set the secret type (`shared` or `personal`), defaults to `shared`. + +### `kms` + +All KMS (Key Management Service) operations are accessed via `client.kms()`. Each operation has a dedicated request builder. + +#### List KMS Keys + +List all KMS keys in a project. + +**Example** + +```rust +use infisical::kms::ListKmsKeysRequest; + +let request = ListKmsKeysRequest::builder("").build(); + +let keys = client.kms().list(request).await?; +``` + +**Parameters** + +- `project_id`: Required parameter passed to the `builder()` function. + +#### Get KMS Key + +Retrieve a specific KMS key by ID. + +**Example** + +```rust +use infisical::kms::GetKmsKeyRequest; + +let request = GetKmsKeyRequest::builder("").build(); + +let key = client.kms().get(request).await?; +``` + +**Parameters** + +- `key_id`: Required parameter passed to the `builder()` function. + +#### Get KMS Key by Name + +Retrieve a specific KMS key by name. + +**Example** + +```rust +use infisical::kms::GetKmsKeyByNameRequest; + +let request = GetKmsKeyByNameRequest::builder("").build(); + +let key = client.kms().get_by_name(request).await?; +``` + +**Parameters** + +- `key_name`: Required parameter passed to the `builder()` function. + +#### Create KMS Key + +Create a new KMS key in your project. + +**Example** + +```rust +use infisical::kms::{CreateKmsKeyRequest, EncryptionAlgorithm, KeyUsage}; + +let request = CreateKmsKeyRequest::builder("", "my-key") + .description("A key for encryption operations") + .key_usage(KeyUsage::EncryptDecrypt) + .encryption_algorithm(EncryptionAlgorithm::Aes256Gcm) + .build(); + +let created_key = client.kms().create(request).await?; +``` + +**Parameters** + +- `project_id`, `name`: Required parameters passed to the `builder()` function. +- `.description(description)`: Optional method to set the key description. +- `.key_usage(usage)`: Optional method to set the key usage using the `KeyUsage` enum (defaults to `KeyUsage::EncryptDecrypt`). +- `.encryption_algorithm(algorithm)`: Optional method to set the encryption algorithm using the `EncryptionAlgorithm` enum (defaults to `EncryptionAlgorithm::Aes256Gcm`). + +#### Update KMS Key + +Update an existing KMS key. + +**Example** + +```rust +use infisical::kms::UpdateKmsKeyRequest; + +let request = UpdateKmsKeyRequest::builder("") + .name("updated-key-name") + .description("Updated description") + .is_disabled(false) + .build(); + +let updated_key = client.kms().update(request).await?; +``` + +**Parameters** + +- `key_id`: Required parameter passed to the `builder()` function. +- `.name(name)`: Optional method to rename the key. +- `.description(description)`: Optional method to update the key description. +- `.is_disabled(disabled)`: Optional method to enable or disable the key. + +#### Delete KMS Key + +Delete a KMS key from your project. + +**Example** + +```rust +use infisical::kms::DeleteKmsKeyRequest; + +let request = DeleteKmsKeyRequest::builder("").build(); + +let deleted_key = client.kms().delete(request).await?; +``` + +**Parameters** + +- `key_id`: Required parameter passed to the `builder()` function. + +#### Encrypt Data + +Encrypt data using a KMS key. + +**Example** + +```rust +use infisical::kms::EncryptRequest; + +let request = EncryptRequest::builder("", "sensitive data").build(); + +let ciphertext = client.kms().encrypt(request).await?; +``` + +**Parameters** + +- `key_id`, `plaintext`: Required parameters passed to the `builder()` function. + +#### Decrypt Data + +Decrypt data using a KMS key. + +**Example** + +```rust +use infisical::kms::DecryptRequest; + +let request = DecryptRequest::builder("", "encrypted-data").build(); + +let plaintext = client.kms().decrypt(request).await?; +``` + +**Parameters** + +- `key_id`, `ciphertext`: Required parameters passed to the `builder()` function. + +#### Sign Data + +Sign data using a KMS key. + +**Example** + +```rust +use infisical::kms::{SigningAlgorithm, SignRequest}; + +let request = SignRequest::builder("", "data to sign") + .signing_algorithm(SigningAlgorithm::RsassaPkcs1V15Sha256) + .is_digest(false) + .build(); + +let signature = client.kms().sign(request).await?; +``` + +**Parameters** + +- `key_id`, `data`: Required parameters passed to the `builder()` function. +- `.signing_algorithm(algorithm)`: Optional method to set the signing algorithm using the `SigningAlgorithm` enum (defaults to `SigningAlgorithm::RsassaPkcs1V15Sha256`). +- `.is_digest(is_digest)`: Optional method to indicate if the data is a digest (defaults to `false`). + +#### Verify Signature + +Verify a signature using a KMS key. + +**Example** + +```rust +use infisical::kms::{SigningAlgorithm, VerifyRequest}; + +let request = VerifyRequest::builder("", "data to sign", "signature") + .signing_algorithm(SigningAlgorithm::RsassaPkcs1V15Sha256) + .is_digest(false) + .build(); + +let verification = client.kms().verify(request).await?; +``` + +**Parameters** + +- `key_id`, `data`, `signature`: Required parameters passed to the `builder()` function. +- `.signing_algorithm(algorithm)`: Optional method to set the signing algorithm using the `SigningAlgorithm` enum (defaults to `SigningAlgorithm::RsassaPkcs1V15Sha256`). +- `.is_digest(is_digest)`: Optional method to indicate if the data is a digest (defaults to `false`). + +#### Get Public Key + +Get the public key for a KMS key. + +**Example** + +```rust +let public_key = client.kms().get_public_key("").await?; +``` + +**Parameters** + +- `key_id`: The ID of the key to get the public key for. + +#### Get Signing Algorithms + +Get the available signing algorithms for a KMS key. + +**Example** + +```rust +let algorithms = client.kms().get_signing_algorithms("").await?; +``` + +**Parameters** + +- `key_id`: The ID of the key to get signing algorithms for. + +## Development and Testing + +### Environment Setup + +For development and testing, you'll need to set up environment variables. Create a `.env` file in your project root: + +```env +INFISICAL_CLIENT_ID=your_client_id_here +INFISICAL_CLIENT_SECRET=your_client_secret_here +INFISICAL_BASE_URL=http://localhost:8080 # Optional: for local development + +# Project IDs for different resources +INFISICAL_SECRETS_PROJECT_ID=your_project_id_here +INFISICAL_KMS_PROJECT_ID=your_project_id_here +``` + +### Getting Credentials + +To obtain the required credentials: + +1. **Client ID and Secret**: Create a Universal Auth machine identity in your Infisical project settings +2. **Project ID**: Found in your project settings or URL when viewing a project in the Infisical dashboard + +### Running Tests + +Tests that require authentication are marked with `#[ignore]` and need valid credentials: + +```bash +# Run ignored tests (requires .env file with valid credentials) +cargo test -- --ignored --nocapture + +# Run a specific test +cargo test test_kms_resource -- --ignored --nocapture +``` + +**Note**: Integration tests require a running Infisical instance and valid authentication credentials. \ No newline at end of file diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index 32192805d..f9a8fcac3 100644 --- a/docs/sdks/overview.mdx +++ b/docs/sdks/overview.mdx @@ -12,26 +12,29 @@ From local development to production, Infisical SDKs provide the easiest way for - Fetch secrets on demand - + Manage secrets for your Node application on demand - + Manage secrets for your Python application on demand - + Manage secrets for your Java application on demand - + Manage secrets for your .NET application on demand - + Manage secrets for your C++ application on demand - + Manage secrets for your Rust application on demand Manage secrets for your Go application on demand + + + Manage secrets for your PHP application on demand Manage secrets for your Ruby application on demand diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 1e050ff14..8d351d7f2 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -65,7 +65,14 @@ Example values: default="false" optional > - Determines whether your Infisical instance can automatically read the service account token of the pod it's running on. Used for features such as the IRSA auth method. + Determines whether your Infisical instance can automatically read the service + account token of the pod it's running on. Used for features such as the IRSA + auth method. + + + + Disable storing audit logs in the database. This is useful if you're using + audit log streams and don't want to store them in the database. ## CORS @@ -135,7 +142,7 @@ DB_READ_REPLICAS=[{"DB_CONNECTION_URI":""}] Redis is used for caching and background tasks. You can use either a standalone Redis instance or a Redis Sentinel setup. - + Redis connection string. @@ -166,6 +173,49 @@ Redis is used for caching and background tasks. You can use either a standalone Authentication password for Redis Sentinel + + Authentication username for Redis Node + + + Authentication password for Redis Node + + + + + Comma-separated list of Redis Cluster host:port pairs. ``` + 192.168.65.254:26379,192.168.65.254:26380 ``` + + + Enable Redis TLS encryption on connection. + + + Enable this if you are using AWS encrypt on transit for Elasticache cluster. For more information refer ![here](https://github.com/redis/ioredis?tab=readme-ov-file#special-note-aws-elasticache-clusters-with-tls). + + + Authentication username for Redis Node + + + Authentication password for Redis Node + + + + + Comma-separated list of Redis read replicas host:port pairs. ``` + 192.168.65.254:26379,192.168.65.254:26380 ``` + + + The paramters like username, password, tls, redis type of the primary instance will be inherited. + 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/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx new file mode 100644 index 000000000..72ad40cff --- /dev/null +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -0,0 +1,155 @@ +import React, { useState, useMemo } from 'react'; + +export const AppConnectionsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring', 'Directory Services', 'Identity & Auth', 'Data Analytics', 'Hosting', 'DevOps Tools', 'Security']; + + const connections = [ + {"name": "AWS", "slug": "aws", "path": "/integrations/app-connections/aws", "description": "Learn how to connect your AWS applications to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Azure Key Vault", "slug": "azure-key-vault", "path": "/integrations/app-connections/azure-key-vault", "description": "Learn how to connect your Azure Key Vault to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Azure App Configuration", "slug": "azure-app-configuration", "path": "/integrations/app-connections/azure-app-configuration", "description": "Learn how to connect your Azure App Configuration to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Azure Client Secrets", "slug": "azure-client-secrets", "path": "/integrations/app-connections/azure-client-secrets", "description": "Learn how to connect your Azure Client Secrets to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Azure DevOps", "slug": "azure-devops", "path": "/integrations/app-connections/azure-devops", "description": "Learn how to connect your Azure DevOps to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "Azure ADCS", "slug": "azure-adcs", "path": "/integrations/app-connections/azure-adcs", "description": "Learn how to connect your Azure ADCS to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "GCP", "slug": "gcp", "path": "/integrations/app-connections/gcp", "description": "Learn how to connect your GCP applications to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "HashiCorp Vault", "slug": "hashicorp-vault", "path": "/integrations/app-connections/hashicorp-vault", "description": "Learn how to connect your HashiCorp Vault to pull secrets from Infisical.", "category": "Security"}, + {"name": "1Password", "slug": "1password", "path": "/integrations/app-connections/1password", "description": "Learn how to connect your 1Password to pull secrets from Infisical.", "category": "Security"}, + {"name": "Vercel", "slug": "vercel", "path": "/integrations/app-connections/vercel", "description": "Learn how to connect your Vercel application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Netlify", "slug": "netlify", "path": "/integrations/app-connections/netlify", "description": "Learn how to connect your Netlify application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Railway", "slug": "railway", "path": "/integrations/app-connections/railway", "description": "Learn how to connect your Railway application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Fly.io", "slug": "flyio", "path": "/integrations/app-connections/flyio", "description": "Learn how to connect your Fly.io application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Render", "slug": "render", "path": "/integrations/app-connections/render", "description": "Learn how to connect your Render application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Heroku", "slug": "heroku", "path": "/integrations/app-connections/heroku", "description": "Learn how to connect your Heroku application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "DigitalOcean", "slug": "digital-ocean", "path": "/integrations/app-connections/digital-ocean", "description": "Learn how to connect your DigitalOcean application to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Supabase", "slug": "supabase", "path": "/integrations/app-connections/supabase", "description": "Learn how to connect your Supabase application to pull secrets from Infisical.", "category": "Databases"}, + {"name": "Checkly", "slug": "checkly", "path": "/integrations/app-connections/checkly", "description": "Learn how to connect your Checkly application to pull secrets from Infisical.", "category": "Monitoring"}, + {"name": "GitHub", "slug": "github", "path": "/integrations/app-connections/github", "description": "Learn how to connect your GitHub application to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "GitHub Radar", "slug": "github-radar", "path": "/integrations/app-connections/github-radar", "description": "Learn how to connect your GitHub Radar to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "GitLab", "slug": "gitlab", "path": "/integrations/app-connections/gitlab", "description": "Learn how to connect your GitLab application to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "TeamCity", "slug": "teamcity", "path": "/integrations/app-connections/teamcity", "description": "Learn how to connect your TeamCity to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "Bitbucket", "slug": "bitbucket", "path": "/integrations/app-connections/bitbucket", "description": "Learn how to connect your Bitbucket to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "Terraform Cloud", "slug": "terraform-cloud", "path": "/integrations/app-connections/terraform-cloud", "description": "Learn how to connect your Terraform Cloud to pull secrets from Infisical.", "category": "DevOps Tools"}, + {"name": "Cloudflare", "slug": "cloudflare", "path": "/integrations/app-connections/cloudflare", "description": "Learn how to connect your Cloudflare application to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Databricks", "slug": "databricks", "path": "/integrations/app-connections/databricks", "description": "Learn how to connect your Databricks to pull secrets from Infisical.", "category": "Data Analytics"}, + {"name": "Windmill", "slug": "windmill", "path": "/integrations/app-connections/windmill", "description": "Learn how to connect your Windmill to pull secrets from Infisical.", "category": "DevOps Tools"}, + {"name": "Camunda", "slug": "camunda", "path": "/integrations/app-connections/camunda", "description": "Learn how to connect your Camunda to pull secrets from Infisical.", "category": "DevOps Tools"}, + {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/app-connections/humanitec", "description": "Learn how to connect your Humanitec to pull secrets from Infisical.", "category": "DevOps Tools"}, + {"name": "OCI", "slug": "oci", "path": "/integrations/app-connections/oci", "description": "Learn how to connect your OCI applications to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/app-connections/zabbix", "description": "Learn how to connect your Zabbix to pull secrets from Infisical.", "category": "Monitoring"}, + {"name": "MySQL", "slug": "mysql", "path": "/integrations/app-connections/mysql", "description": "Learn how to connect your MySQL database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "PostgreSQL", "slug": "postgres", "path": "/integrations/app-connections/postgres", "description": "Learn how to connect your PostgreSQL database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "Microsoft SQL Server", "slug": "mssql", "path": "/integrations/app-connections/mssql", "description": "Learn how to connect your SQL Server database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "Oracle Database", "slug": "oracledb", "path": "/integrations/app-connections/oracledb", "description": "Learn how to connect your Oracle database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"}, + {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, + {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredConnections = useMemo(() => { + let filtered = connections; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(connection => connection.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(connection => + connection.name.toLowerCase().includes(searchTerm.toLowerCase()) || + connection.description.toLowerCase().includes(searchTerm.toLowerCase()) || + connection.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredConnections.length} app connection{filteredConnections.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Connections List */} + {filteredConnections.length > 0 ? ( + + ) : ( +
+
+

No app connections found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/DynamicSecretsBrowser.jsx b/docs/snippets/DynamicSecretsBrowser.jsx new file mode 100644 index 000000000..6438deea0 --- /dev/null +++ b/docs/snippets/DynamicSecretsBrowser.jsx @@ -0,0 +1,140 @@ +import React, { useState, useMemo } from 'react'; + +export const DynamicSecretsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Databases', 'Cloud Providers', 'Message Queues', 'Caches', 'Directory Services', 'CI/CD', 'Container Orchestration', 'Authentication']; + + const dynamicSecrets = [ + {"name": "AWS IAM", "slug": "aws-iam", "path": "/documentation/platform/dynamic-secrets/aws-iam", "description": "Learn how to generate dynamic AWS IAM credentials on-demand.", "category": "Cloud Providers"}, + {"name": "AWS ElastiCache", "slug": "aws-elasticache", "path": "/documentation/platform/dynamic-secrets/aws-elasticache", "description": "Learn how to generate dynamic AWS ElastiCache credentials on-demand.", "category": "Caches"}, + {"name": "Azure Entra ID", "slug": "azure-entra-id", "path": "/documentation/platform/dynamic-secrets/azure-entra-id", "description": "Learn how to generate dynamic Azure Entra ID credentials on-demand.", "category": "Cloud Providers"}, + {"name": "GCP IAM", "slug": "gcp-iam", "path": "/documentation/platform/dynamic-secrets/gcp-iam", "description": "Learn how to generate dynamic GCP IAM credentials on-demand.", "category": "Cloud Providers"}, + {"name": "Cassandra", "slug": "cassandra", "path": "/documentation/platform/dynamic-secrets/cassandra", "description": "Learn how to generate dynamic Cassandra database credentials on-demand.", "category": "Databases"}, + {"name": "Couchbase", "slug": "couchbase", "path": "/documentation/platform/dynamic-secrets/couchbase", "description": "Learn how to generate dynamic Couchbase database credentials on-demand.", "category": "Databases"}, + {"name": "MongoDB", "slug": "mongodb", "path": "/documentation/platform/dynamic-secrets/mongo-db", "description": "Learn how to generate dynamic MongoDB database credentials on-demand.", "category": "Databases"}, + {"name": "MongoDB Atlas", "slug": "mongodb-atlas", "path": "/documentation/platform/dynamic-secrets/mongo-atlas", "description": "Learn how to generate dynamic MongoDB Atlas database credentials on-demand.", "category": "Databases"}, + {"name": "MySQL", "slug": "mysql", "path": "/documentation/platform/dynamic-secrets/mysql", "description": "Learn how to generate dynamic MySQL database credentials on-demand.", "category": "Databases"}, + {"name": "PostgreSQL", "slug": "postgresql", "path": "/documentation/platform/dynamic-secrets/postgresql", "description": "Learn how to generate dynamic PostgreSQL database credentials on-demand.", "category": "Databases"}, + {"name": "Microsoft SQL Server", "slug": "mssql", "path": "/documentation/platform/dynamic-secrets/mssql", "description": "Learn how to generate dynamic Microsoft SQL Server credentials on-demand.", "category": "Databases"}, + {"name": "Oracle Database", "slug": "oracle", "path": "/documentation/platform/dynamic-secrets/oracle", "description": "Learn how to generate dynamic Oracle Database credentials on-demand.", "category": "Databases"}, + {"name": "SAP ASE", "slug": "sap-ase", "path": "/documentation/platform/dynamic-secrets/sap-ase", "description": "Learn how to generate dynamic SAP ASE database credentials on-demand.", "category": "Databases"}, + {"name": "SAP HANA", "slug": "sap-hana", "path": "/documentation/platform/dynamic-secrets/sap-hana", "description": "Learn how to generate dynamic SAP HANA database credentials on-demand.", "category": "Databases"}, + {"name": "Snowflake", "slug": "snowflake", "path": "/documentation/platform/dynamic-secrets/snowflake", "description": "Learn how to generate dynamic Snowflake database credentials on-demand.", "category": "Databases"}, + {"name": "Vertica", "slug": "vertica", "path": "/documentation/platform/dynamic-secrets/vertica", "description": "Learn how to generate dynamic Vertica database credentials on-demand.", "category": "Databases"}, + {"name": "Redis", "slug": "redis", "path": "/documentation/platform/dynamic-secrets/redis", "description": "Learn how to generate dynamic Redis credentials on-demand.", "category": "Caches"}, + {"name": "ElasticSearch", "slug": "elasticsearch", "path": "/documentation/platform/dynamic-secrets/elastic-search", "description": "Learn how to generate dynamic ElasticSearch credentials on-demand.", "category": "Databases"}, + {"name": "RabbitMQ", "slug": "rabbitmq", "path": "/documentation/platform/dynamic-secrets/rabbit-mq", "description": "Learn how to generate dynamic RabbitMQ credentials on-demand.", "category": "Message Queues"}, + {"name": "LDAP", "slug": "ldap", "path": "/documentation/platform/dynamic-secrets/ldap", "description": "Learn how to generate dynamic LDAP credentials on-demand.", "category": "Directory Services"}, + {"name": "GitHub", "slug": "github", "path": "/documentation/platform/dynamic-secrets/github", "description": "Learn how to generate dynamic GitHub credentials on-demand.", "category": "CI/CD"}, + {"name": "Kubernetes", "slug": "kubernetes", "path": "/documentation/platform/dynamic-secrets/kubernetes", "description": "Learn how to generate dynamic Kubernetes credentials on-demand.", "category": "Container Orchestration"}, + {"name": "TOTP", "slug": "totp", "path": "/documentation/platform/dynamic-secrets/totp", "description": "Learn how to generate dynamic TOTP codes on-demand.", "category": "Authentication"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredDynamicSecrets = useMemo(() => { + let filtered = dynamicSecrets; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(secret => secret.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(secret => + secret.name.toLowerCase().includes(searchTerm.toLowerCase()) || + secret.description.toLowerCase().includes(searchTerm.toLowerCase()) || + secret.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredDynamicSecrets.length} dynamic secret{filteredDynamicSecrets.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Dynamic Secrets List */} + {filteredDynamicSecrets.length > 0 ? ( +
+ {filteredDynamicSecrets.map((secret, index) => ( + +
+
+

+ {secret.name} +

+ + {secret.category} + +
+

+ {secret.description} +

+
+
+ ))} +
+ ) : ( +
+
+

No dynamic secrets found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/FrameworkIntegrationsBrowser.jsx b/docs/snippets/FrameworkIntegrationsBrowser.jsx new file mode 100644 index 000000000..f85da41ed --- /dev/null +++ b/docs/snippets/FrameworkIntegrationsBrowser.jsx @@ -0,0 +1,101 @@ +import React, { useState, useMemo } from 'react'; + +export const FrameworkIntegrationsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + + const integrations = [ + {"name": "React", "slug": "react", "path": "/integrations/frameworks/react", "description": "Learn how to integrate Infisical with React applications for secure secret management.", "category": "Web Frameworks"}, + {"name": "Next.js", "slug": "nextjs", "path": "/integrations/frameworks/nextjs", "description": "Learn how to integrate Infisical with Next.js applications.", "category": "Web Frameworks"}, + {"name": "Vue", "slug": "vuejs", "path": "/integrations/frameworks/vue", "description": "Learn how to integrate Infisical with Vue.js applications.", "category": "Web Frameworks"}, + {"name": "Nuxt", "slug": "nuxtjs", "path": "/integrations/frameworks/nuxt", "description": "Learn how to integrate Infisical with Nuxt.js applications.", "category": "Web Frameworks"}, + {"name": "SvelteKit", "slug": "sveltekit", "path": "/integrations/frameworks/sveltekit", "description": "Learn how to integrate Infisical with SvelteKit applications.", "category": "Web Frameworks"}, + {"name": "Express, Fastify, Koa", "slug": "express", "path": "/integrations/frameworks/express", "description": "Learn how to integrate Infisical with Express.js backend applications.", "category": "Web Frameworks"}, + {"name": "NestJS", "slug": "nestjs", "path": "/integrations/frameworks/nestjs", "description": "Learn how to integrate Infisical with NestJS applications.", "category": "Web Frameworks"}, + {"name": "Django", "slug": "django", "path": "/integrations/frameworks/django", "description": "Learn how to integrate Infisical with Django applications.", "category": "Web Frameworks"}, + {"name": "Flask", "slug": "flask", "path": "/integrations/frameworks/flask", "description": "Learn how to integrate Infisical with Flask applications.", "category": "Web Frameworks"}, + {"name": "Ruby on Rails", "slug": "rails", "path": "/integrations/frameworks/rails", "description": "Learn how to integrate Infisical with Ruby on Rails applications.", "category": "Web Frameworks"}, + {"name": "Spring Boot", "slug": "spring-boot-maven", "path": "/integrations/frameworks/spring-boot-maven", "description": "Learn how to integrate Infisical with Spring Boot applications.", "category": "Web Frameworks"}, + {"name": "Laravel", "slug": "laravel", "path": "/integrations/frameworks/laravel", "description": "Learn how to integrate Infisical with Laravel applications.", "category": "Web Frameworks"}, + {"name": ".NET", "slug": "dotnet", "path": "/integrations/frameworks/dotnet", "description": "Learn how to integrate Infisical with .NET applications.", "category": "Web Frameworks"}, + {"name": "Fiber", "slug": "fiber", "path": "/integrations/frameworks/fiber", "description": "Learn how to integrate Infisical with Fiber (Go) framework.", "category": "Web Frameworks"}, + {"name": "Gatsby", "slug": "gatsby", "path": "/integrations/frameworks/gatsby", "description": "Learn how to integrate Infisical with Gatsby applications.", "category": "Web Frameworks"}, + {"name": "Remix", "slug": "remix", "path": "/integrations/frameworks/remix", "description": "Learn how to integrate Infisical with Remix applications.", "category": "Web Frameworks"}, + {"name": "Vite", "slug": "vite", "path": "/integrations/frameworks/vite", "description": "Learn how to integrate Infisical with Vite applications.", "category": "Web Frameworks"}, + {"name": "AB Initio", "slug": "ab-initio", "path": "/integrations/frameworks/ab-initio", "description": "Learn how to integrate Infisical with AB Initio applications.", "category": "Web Frameworks"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredIntegrations = useMemo(() => { + if (searchTerm) { + return integrations.filter(integration => + integration.name.toLowerCase().includes(searchTerm.toLowerCase()) || + integration.description.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + return integrations; + }, [searchTerm]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Results Count */} +
+

+ {filteredIntegrations.length} framework integration{filteredIntegrations.length !== 1 ? 's' : ''} found + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Integrations List */} + {filteredIntegrations.length > 0 ? ( +
+ {filteredIntegrations.map((integration, index) => ( + +
+
+

+ {integration.name} +

+
+

+ {integration.description} +

+
+
+ ))} +
+ ) : ( +
+
+

No framework integrations found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/MachineAuthenticationBrowser.jsx b/docs/snippets/MachineAuthenticationBrowser.jsx new file mode 100644 index 000000000..521493846 --- /dev/null +++ b/docs/snippets/MachineAuthenticationBrowser.jsx @@ -0,0 +1,136 @@ +import React, { useState, useMemo } from 'react'; + +export const MachineAuthenticationBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Token-based', 'Cloud Provider', 'Kubernetes', 'Certificate-based', 'Directory-based']; + + const authMethods = [ + {"name": "Universal Auth", "slug": "universal-auth", "path": "/documentation/platform/identities/universal-auth", "description": "Learn how to authenticate machines using Universal Auth tokens with client ID and secret.", "category": "Token-based"}, + {"name": "Token Auth", "slug": "token-auth", "path": "/documentation/platform/identities/token-auth", "description": "Learn how to authenticate machines using long-lived access tokens.", "category": "Token-based"}, + {"name": "JWT Auth", "slug": "jwt-auth", "path": "/documentation/platform/identities/jwt-auth", "description": "Learn how to authenticate machines using JSON Web Tokens (JWT).", "category": "Token-based"}, + {"name": "AWS Auth", "slug": "aws-iam-auth", "path": "/documentation/platform/identities/aws-auth", "description": "Learn how to authenticate AWS services and resources using IAM roles.", "category": "Cloud Provider"}, + {"name": "Azure Auth", "slug": "azure-auth", "path": "/documentation/platform/identities/azure-auth", "description": "Learn how to authenticate Azure services using managed identities.", "category": "Cloud Provider"}, + {"name": "GCP Auth", "slug": "gcp-auth", "path": "/documentation/platform/identities/gcp-auth", "description": "Learn how to authenticate GCP services using service accounts.", "category": "Cloud Provider"}, + {"name": "Alibaba Cloud Auth", "slug": "alicloud-auth", "path": "/documentation/platform/identities/alicloud-auth", "description": "Learn how to authenticate Alibaba Cloud services using RAM roles.", "category": "Cloud Provider"}, + {"name": "OCI Auth", "slug": "oci-auth", "path": "/documentation/platform/identities/oci-auth", "description": "Learn how to authenticate Oracle Cloud Infrastructure services.", "category": "Cloud Provider"}, + {"name": "Kubernetes Auth", "slug": "kubernetes-auth", "path": "/documentation/platform/identities/kubernetes-auth", "description": "Learn how to authenticate Kubernetes workloads using service account tokens.", "category": "Kubernetes"}, + {"name": "OIDC Auth", "slug": "oidc-auth-general", "path": "/documentation/platform/identities/oidc-auth/general", "description": "Learn how to authenticate machines using OpenID Connect (OIDC) providers.", "category": "Token-based"}, + {"name": "OIDC Auth for GitHub Actions", "slug": "oidc-auth-github", "path": "/documentation/platform/identities/oidc-auth/github", "description": "Learn how to authenticate GitHub Actions using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for GitLab CI/CD", "slug": "oidc-auth-gitlab", "path": "/documentation/platform/identities/oidc-auth/gitlab", "description": "Learn how to authenticate GitLab CI/CD using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for Azure", "slug": "oidc-auth-azure", "path": "/documentation/platform/identities/oidc-auth/azure", "description": "Learn how to authenticate Azure services using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for CircleCI", "slug": "oidc-auth-circleci", "path": "/documentation/platform/identities/oidc-auth/circleci", "description": "Learn how to authenticate CircleCI workflows using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for Terraform Cloud", "slug": "oidc-auth-terraform", "path": "/documentation/platform/identities/oidc-auth/terraform-cloud", "description": "Learn how to authenticate Terraform Cloud using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for SPIRE", "slug": "oidc-auth-spire", "path": "/documentation/platform/identities/oidc-auth/spire", "description": "Learn how to authenticate workloads using SPIFFE/SPIRE OIDC.", "category": "Token-based"}, + {"name": "TLS Certificate Auth", "slug": "tls-cert-auth", "path": "/documentation/platform/identities/tls-cert-auth", "description": "Learn how to authenticate machines using TLS client certificates.", "category": "Certificate-based"}, + {"name": "LDAP Auth", "slug": "ldap-auth-general", "path": "/documentation/platform/identities/ldap-auth/general", "description": "Learn how to authenticate machines using LDAP credentials.", "category": "Directory-based"}, + {"name": "LDAP Auth for JumpCloud", "slug": "ldap-auth-jumpcloud", "path": "/documentation/platform/identities/ldap-auth/jumpcloud", "description": "Learn how to authenticate machines using JumpCloud LDAP.", "category": "Directory-based"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredAuthMethods = useMemo(() => { + let filtered = authMethods; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(method => method.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(method => + method.name.toLowerCase().includes(searchTerm.toLowerCase()) || + method.description.toLowerCase().includes(searchTerm.toLowerCase()) || + method.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredAuthMethods.length} authentication method{filteredAuthMethods.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Authentication Methods List */} + {filteredAuthMethods.length > 0 ? ( +
+ {filteredAuthMethods.map((method, index) => ( + +
+
+

+ {method.name} +

+ + {method.category} + +
+

+ {method.description} +

+
+
+ ))} +
+ ) : ( +
+
+

No authentication methods found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/RotationsBrowser.jsx b/docs/snippets/RotationsBrowser.jsx new file mode 100644 index 000000000..3d50656b6 --- /dev/null +++ b/docs/snippets/RotationsBrowser.jsx @@ -0,0 +1,126 @@ +import React, { useState, useMemo } from 'react'; + +export const RotationsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Databases', 'Identity & Auth', 'Cloud Providers']; + + const rotations = [ + {"name": "AWS IAM User", "slug": "aws-iam-user", "path": "/documentation/platform/secret-rotation/aws-iam-user-secret", "description": "Learn how to automatically rotate AWS IAM user access keys.", "category": "Cloud Providers"}, + {"name": "Azure Client Secret", "slug": "azure-client-secret", "path": "/documentation/platform/secret-rotation/azure-client-secret", "description": "Learn how to automatically rotate Azure client secrets.", "category": "Cloud Providers"}, + {"name": "Auth0 Client Secret", "slug": "auth0-client-secret", "path": "/documentation/platform/secret-rotation/auth0-client-secret", "description": "Learn how to automatically rotate Auth0 client secrets.", "category": "Identity & Auth"}, + {"name": "Okta Client Secret", "slug": "okta-client-secret", "path": "/documentation/platform/secret-rotation/okta-client-secret", "description": "Learn how to automatically rotate Okta client secrets.", "category": "Identity & Auth"}, + {"name": "LDAP Password", "slug": "ldap-password", "path": "/documentation/platform/secret-rotation/ldap-password", "description": "Learn how to automatically rotate LDAP user passwords.", "category": "Identity & Auth"}, + {"name": "MySQL", "slug": "mysql-credentials", "path": "/documentation/platform/secret-rotation/mysql-credentials", "description": "Learn how to automatically rotate MySQL database credentials.", "category": "Databases"}, + {"name": "PostgreSQL", "slug": "postgres-credentials", "path": "/documentation/platform/secret-rotation/postgres-credentials", "description": "Learn how to automatically rotate PostgreSQL database credentials.", "category": "Databases"}, + {"name": "Microsoft SQL Server", "slug": "mssql-credentials", "path": "/documentation/platform/secret-rotation/mssql-credentials", "description": "Learn how to automatically rotate Microsoft SQL Server credentials.", "category": "Databases"}, + {"name": "Oracle Database", "slug": "oracledb-credentials", "path": "/documentation/platform/secret-rotation/oracledb-credentials", "description": "Learn how to automatically rotate Oracle Database credentials.", "category": "Databases"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredRotations = useMemo(() => { + let filtered = rotations; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(rotation => rotation.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(rotation => + rotation.name.toLowerCase().includes(searchTerm.toLowerCase()) || + rotation.description.toLowerCase().includes(searchTerm.toLowerCase()) || + rotation.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredRotations.length} secret rotation{filteredRotations.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Rotations List */} + {filteredRotations.length > 0 ? ( + + ) : ( +
+
+

No secret rotations found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/SecretSyncsBrowser.jsx b/docs/snippets/SecretSyncsBrowser.jsx new file mode 100644 index 000000000..d32f5ac04 --- /dev/null +++ b/docs/snippets/SecretSyncsBrowser.jsx @@ -0,0 +1,147 @@ +import React, { useState, useMemo } from 'react'; + +export const SecretSyncsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring', 'Data Analytics', 'Hosting', 'DevOps Tools', 'Security']; + + const syncs = [ + {"name": "AWS Parameter Store", "slug": "aws-parameter-store", "path": "/integrations/secret-syncs/aws-parameter-store", "description": "Learn how to sync secrets from Infisical to AWS Parameter Store.", "category": "Cloud Providers"}, + {"name": "AWS Secrets Manager", "slug": "aws-secrets-manager", "path": "/integrations/secret-syncs/aws-secrets-manager", "description": "Learn how to sync secrets from Infisical to AWS Secrets Manager.", "category": "Cloud Providers"}, + {"name": "Azure Key Vault", "slug": "azure-key-vault", "path": "/integrations/secret-syncs/azure-key-vault", "description": "Learn how to sync secrets from Infisical to Azure Key Vault.", "category": "Cloud Providers"}, + {"name": "Azure App Configuration", "slug": "azure-app-configuration", "path": "/integrations/secret-syncs/azure-app-configuration", "description": "Learn how to sync secrets from Infisical to Azure App Configuration.", "category": "Cloud Providers"}, + {"name": "Azure DevOps", "slug": "azure-devops", "path": "/integrations/secret-syncs/azure-devops", "description": "Learn how to sync secrets from Infisical to Azure DevOps.", "category": "CI/CD"}, + {"name": "GCP Secret Manager", "slug": "gcp-secret-manager", "path": "/integrations/secret-syncs/gcp-secret-manager", "description": "Learn how to sync secrets from Infisical to GCP Secret Manager.", "category": "Cloud Providers"}, + {"name": "HashiCorp Vault", "slug": "hashicorp-vault", "path": "/integrations/secret-syncs/hashicorp-vault", "description": "Learn how to sync secrets from Infisical to HashiCorp Vault.", "category": "Security"}, + {"name": "1Password", "slug": "1password", "path": "/integrations/secret-syncs/1password", "description": "Learn how to sync secrets from Infisical to 1Password.", "category": "Security"}, + {"name": "Vercel", "slug": "vercel", "path": "/integrations/secret-syncs/vercel", "description": "Learn how to sync secrets from Infisical to Vercel.", "category": "Hosting"}, + {"name": "Netlify", "slug": "netlify", "path": "/integrations/secret-syncs/netlify", "description": "Learn how to sync secrets from Infisical to Netlify.", "category": "Hosting"}, + {"name": "Railway", "slug": "railway", "path": "/integrations/secret-syncs/railway", "description": "Learn how to sync secrets from Infisical to Railway.", "category": "Hosting"}, + {"name": "Fly.io", "slug": "flyio", "path": "/integrations/secret-syncs/flyio", "description": "Learn how to sync secrets from Infisical to Fly.io.", "category": "Hosting"}, + {"name": "Render", "slug": "render", "path": "/integrations/secret-syncs/render", "description": "Learn how to sync secrets from Infisical to Render.", "category": "Hosting"}, + {"name": "Heroku", "slug": "heroku", "path": "/integrations/secret-syncs/heroku", "description": "Learn how to sync secrets from Infisical to Heroku.", "category": "Hosting"}, + {"name": "DigitalOcean App Platform", "slug": "digital-ocean-app-platform", "path": "/integrations/secret-syncs/digital-ocean-app-platform", "description": "Learn how to sync secrets from Infisical to DigitalOcean App Platform.", "category": "Hosting"}, + {"name": "Supabase", "slug": "supabase", "path": "/integrations/secret-syncs/supabase", "description": "Learn how to sync secrets from Infisical to Supabase.", "category": "Databases"}, + {"name": "Checkly", "slug": "checkly", "path": "/integrations/secret-syncs/checkly", "description": "Learn how to sync secrets from Infisical to Checkly.", "category": "Monitoring"}, + {"name": "GitHub", "slug": "github", "path": "/integrations/secret-syncs/github", "description": "Learn how to sync secrets from Infisical to GitHub.", "category": "CI/CD"}, + {"name": "GitLab", "slug": "gitlab", "path": "/integrations/secret-syncs/gitlab", "description": "Learn how to sync secrets from Infisical to GitLab.", "category": "CI/CD"}, + {"name": "TeamCity", "slug": "teamcity", "path": "/integrations/secret-syncs/teamcity", "description": "Learn how to sync secrets from Infisical to TeamCity.", "category": "CI/CD"}, + {"name": "Bitbucket", "slug": "bitbucket", "path": "/integrations/secret-syncs/bitbucket", "description": "Learn how to sync secrets from Infisical to Bitbucket.", "category": "CI/CD"}, + {"name": "Terraform Cloud", "slug": "terraform-cloud", "path": "/integrations/secret-syncs/terraform-cloud", "description": "Learn how to sync secrets from Infisical to Terraform Cloud.", "category": "DevOps Tools"}, + {"name": "Cloudflare Pages", "slug": "cloudflare-pages", "path": "/integrations/secret-syncs/cloudflare-pages", "description": "Learn how to sync secrets from Infisical to Cloudflare Pages.", "category": "Hosting"}, + {"name": "Cloudflare Workers", "slug": "cloudflare-workers", "path": "/integrations/secret-syncs/cloudflare-workers", "description": "Learn how to sync secrets from Infisical to Cloudflare Workers.", "category": "Cloud Providers"}, + {"name": "Databricks", "slug": "databricks", "path": "/integrations/secret-syncs/databricks", "description": "Learn how to sync secrets from Infisical to Databricks.", "category": "Data Analytics"}, + {"name": "Windmill", "slug": "windmill", "path": "/integrations/secret-syncs/windmill", "description": "Learn how to sync secrets from Infisical to Windmill.", "category": "DevOps Tools"}, + {"name": "Camunda", "slug": "camunda", "path": "/integrations/secret-syncs/camunda", "description": "Learn how to sync secrets from Infisical to Camunda.", "category": "DevOps Tools"}, + {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/secret-syncs/humanitec", "description": "Learn how to sync secrets from Infisical to Humanitec.", "category": "DevOps Tools"}, + {"name": "OCI Vault", "slug": "oci-vault", "path": "/integrations/secret-syncs/oci-vault", "description": "Learn how to sync secrets from Infisical to OCI Vault.", "category": "Cloud Providers"}, + {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredSyncs = useMemo(() => { + let filtered = syncs; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(sync => sync.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(sync => + sync.name.toLowerCase().includes(searchTerm.toLowerCase()) || + sync.description.toLowerCase().includes(searchTerm.toLowerCase()) || + sync.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredSyncs.length} secret sync{filteredSyncs.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Secret Syncs List */} + {filteredSyncs.length > 0 ? ( + + ) : ( +
+
+

No secret syncs found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/UserAuthenticationBrowser.jsx b/docs/snippets/UserAuthenticationBrowser.jsx new file mode 100644 index 000000000..76b77ad93 --- /dev/null +++ b/docs/snippets/UserAuthenticationBrowser.jsx @@ -0,0 +1,135 @@ +import React, { useState, useMemo } from 'react'; + +export const UserAuthenticationBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'SSO', 'LDAP', 'SCIM', 'General']; + + const authMethods = [ + {"name": "Auth0 OIDC", "slug": "auth0-oidc-sso", "path": "/documentation/platform/sso/auth0-oidc", "description": "Learn how to configure Auth0 OIDC SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Auth0 SAML", "slug": "auth0-saml-sso", "path": "/documentation/platform/sso/auth0-saml", "description": "Learn how to configure Auth0 SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Entra ID / Azure AD SAML", "slug": "azure-ad-sso", "path": "/documentation/platform/sso/azure", "description": "Learn how to configure Azure Active Directory (Entra ID) SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Google", "slug": "google-sso", "path": "/documentation/platform/sso/google", "description": "Learn how to configure Google SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Google SAML", "slug": "google-saml-sso", "path": "/documentation/platform/sso/google-saml", "description": "Learn how to configure Google SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "GitHub", "slug": "github-sso", "path": "/documentation/platform/sso/github", "description": "Learn how to configure GitHub SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "GitLab", "slug": "gitlab-sso", "path": "/documentation/platform/sso/gitlab", "description": "Learn how to configure GitLab SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "JumpCloud", "slug": "jumpcloud-sso", "path": "/documentation/platform/sso/jumpcloud", "description": "Learn how to configure JumpCloud SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Keycloak OIDC", "slug": "keycloak-oidc-sso", "path": "/documentation/platform/sso/keycloak-oidc/overview", "description": "Learn how to configure Keycloak OIDC SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Keycloak SAML", "slug": "keycloak-saml-sso", "path": "/documentation/platform/sso/keycloak-saml", "description": "Learn how to configure Keycloak SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Okta", "slug": "okta-oidc-sso", "path": "/documentation/platform/sso/okta", "description": "Learn how to configure Okta OIDC SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Okta SAML", "slug": "okta-saml-sso", "path": "/documentation/platform/sso/okta-saml", "description": "Learn how to configure Okta SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "OneLogin SAML", "slug": "onelogin-saml-sso", "path": "/documentation/platform/sso/onelogin-saml", "description": "Learn how to configure OneLogin SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "General OIDC", "slug": "general-oidc-sso", "path": "/documentation/platform/sso/general-oidc", "description": "Learn how to configure generic OIDC providers for SSO in Infisical.", "category": "SSO"}, + {"name": "General SAML 2.0", "slug": "general-saml-sso", "path": "/documentation/platform/sso/general-saml", "description": "Learn how to configure generic SAML 2.0 providers for SSO in Infisical.", "category": "SSO"}, + {"name": "LDAP", "slug": "ldap", "path": "/documentation/platform/ldap/overview", "description": "Learn how to configure LDAP authentication for user login in Infisical.", "category": "LDAP"}, + {"name": "SCIM", "slug": "scim", "path": "/documentation/platform/scim/overview", "description": "Learn how to configure SCIM provisioning for automated user management in Infisical.", "category": "SCIM"}, + {"name": "Email/Password", "slug": "email-password", "path": "/documentation/getting-started/introduction", "description": "Learn how to use standard email and password authentication in Infisical.", "category": "General"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredAuthMethods = useMemo(() => { + let filtered = authMethods; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(method => method.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(method => + method.name.toLowerCase().includes(searchTerm.toLowerCase()) || + method.description.toLowerCase().includes(searchTerm.toLowerCase()) || + method.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredAuthMethods.length} user authentication method{filteredAuthMethods.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Authentication Methods List */} + {filteredAuthMethods.length > 0 ? ( +
+ {filteredAuthMethods.map((method, index) => ( + +
+
+

+ {method.name} +

+ + {method.category} + +
+

+ {method.description} +

+
+
+ ))} +
+ ) : ( +
+
+

No user authentication methods found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/public/images/integrations/Datadog.png b/frontend/public/images/integrations/Datadog.png new file mode 100644 index 000000000..7c1b33de0 Binary files /dev/null and b/frontend/public/images/integrations/Datadog.png differ diff --git a/frontend/public/images/integrations/EnvKey.png b/frontend/public/images/integrations/EnvKey.png new file mode 100644 index 000000000..bf1ce03ed Binary files /dev/null and b/frontend/public/images/integrations/EnvKey.png differ diff --git a/frontend/public/images/integrations/Splunk.png b/frontend/public/images/integrations/Splunk.png new file mode 100644 index 000000000..54f13cd25 Binary files /dev/null and b/frontend/public/images/integrations/Splunk.png differ diff --git a/frontend/public/infisical.ico b/frontend/public/infisical.ico index 45d31d20e..a76dd0339 100644 Binary files a/frontend/public/infisical.ico and b/frontend/public/infisical.ico differ 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/auth/UserInfoStep.tsx b/frontend/src/components/auth/UserInfoStep.tsx index 2a1f8dca1..2eb6c65f6 100644 --- a/frontend/src/components/auth/UserInfoStep.tsx +++ b/frontend/src/components/auth/UserInfoStep.tsx @@ -1,12 +1,8 @@ -import crypto from "crypto"; - import { useState } from "react"; import { useTranslation } from "react-i18next"; import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import jsrp from "jsrp"; -import { useServerConfig } from "@app/context"; import { initProjectHelper } from "@app/helpers/project"; import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; @@ -14,15 +10,9 @@ import { onRequestError } from "@app/hooks/api/reactQuery"; import InputField from "../basic/InputField"; import checkPassword from "../utilities/checks/password/checkPassword"; -import Aes256Gcm from "../utilities/cryptography/aes-256-gcm"; -import { deriveArgonKey, generateKeyPair } from "../utilities/cryptography/crypto"; -import { saveTokenToLocalStorage } from "../utilities/saveTokenToLocalStorage"; import SecurityClient from "../utilities/SecurityClient"; import { Button, Input } from "../v2"; -// eslint-disable-next-line new-cap -const client = new jsrp.client(); - interface UserInfoStepProps { incrementStep: () => void; email: string; @@ -76,7 +66,6 @@ export default function UserInfoStep({ }: UserInfoStepProps): JSX.Element { const [nameError, setNameError] = useState(false); const [organizationNameError, setOrganizationNameError] = useState(false); - const { config } = useServerConfig(); const [errors, setErrors] = useState({}); @@ -108,109 +97,41 @@ export default function UserInfoStep({ }); if (!errorCheck) { - // Generate a random pair of a public and a private key - const pair = await generateKeyPair(config.fipsEnabled); + try { + const response = await completeAccountSignup({ + email, + password, + firstName: name.split(" ")[0], + lastName: name.split(" ").slice(1).join(" "), + providerAuthToken, + organizationName, + attributionSource + }); - localStorage.setItem("PRIVATE_KEY", pair.privateKey); + // unset signup JWT token and set JWT token + SecurityClient.setSignupToken(""); + SecurityClient.setToken(response.token); + SecurityClient.setProviderAuthToken(""); - client.init( - { - username: email, - password - }, - async () => { - client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => { - try { - // TODO: moduralize into KeyService - const derivedKey = await deriveArgonKey({ - password, - salt: result.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: pair.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) - }); - - const response = await completeAccountSignup({ - email, - password, - firstName: name.split(" ")[0], - lastName: name.split(" ").slice(1).join(" "), - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey: pair.publicKey, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - providerAuthToken, - salt: result.salt, - verifier: result.verifier, - organizationName, - attributionSource - }); - - // unset signup JWT token and set JWT token - SecurityClient.setSignupToken(""); - SecurityClient.setToken(response.token); - SecurityClient.setProviderAuthToken(""); - - if (response.organizationId) { - await selectOrganization({ organizationId: response.organizationId }); - } - - saveTokenToLocalStorage({ - publicKey: pair.publicKey, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - privateKey: pair.privateKey - }); - - const userOrgs = await fetchOrganizations(); - - const orgId = userOrgs[0]?.id; - await initProjectHelper({ - projectName: "Example Project" - }); - - localStorage.setItem("orgData.id", orgId); - - incrementStep(); - } catch (error) { - onRequestError(error); - setIsLoading(false); - console.error(error); - } - }); + if (response.organizationId) { + await selectOrganization({ organizationId: response.organizationId }); } - ); + + const userOrgs = await fetchOrganizations(); + + const orgId = userOrgs[0]?.id; + await initProjectHelper({ + projectName: "Example Project" + }); + + localStorage.setItem("orgData.id", orgId); + + incrementStep(); + } catch (error) { + onRequestError(error); + setIsLoading(false); + console.error(error); + } } else { setIsLoading(false); } 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/organization/LastLoginSection/LastLoginSection.tsx b/frontend/src/components/organization/LastLoginSection/LastLoginSection.tsx new file mode 100644 index 000000000..f03ff077d --- /dev/null +++ b/frontend/src/components/organization/LastLoginSection/LastLoginSection.tsx @@ -0,0 +1,34 @@ +import { faClock, faShield } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +type Props = { + lastLoginAuthMethod: string; + lastLoginTime: string; +}; + +export const LastLoginSection = ({ lastLoginTime, lastLoginAuthMethod }: Props) => ( +
+
+
Last Login
+
+
+
+ +
+
+
Authentication Method
+
{lastLoginAuthMethod}
+
+
+
+
+ +
+
+
Time
+
{format(lastLoginTime, "PPpp")}
+
+
+
+); diff --git a/frontend/src/components/organization/LastLoginSection/index.tsx b/frontend/src/components/organization/LastLoginSection/index.tsx new file mode 100644 index 000000000..607358130 --- /dev/null +++ b/frontend/src/components/organization/LastLoginSection/index.tsx @@ -0,0 +1 @@ +export { LastLoginSection } from "./LastLoginSection"; diff --git a/frontend/src/components/permissions/OrgPermissionCan.tsx b/frontend/src/components/permissions/OrgPermissionCan.tsx index d2e698e88..8e0bf08ad 100644 --- a/frontend/src/components/permissions/OrgPermissionCan.tsx +++ b/frontend/src/components/permissions/OrgPermissionCan.tsx @@ -1,6 +1,7 @@ import { FunctionComponent, ReactNode } from "react"; import { BoundCanProps, Can } from "@casl/react"; +import { TooltipProps } from "@app/components/v2/Tooltip/Tooltip"; import { TOrgPermission, useOrgPermission } from "@app/context/OrgPermissionContext"; import { AccessRestrictedBanner, Tooltip } from "../v2"; @@ -20,6 +21,7 @@ type Props = { renderTooltip?: boolean; allowedLabel?: string; renderGuardBanner?: boolean; + tooltipProps?: Omit; } & BoundCanProps; export const OrgPermissionCan: FunctionComponent = ({ @@ -29,6 +31,7 @@ export const OrgPermissionCan: FunctionComponent = ({ renderTooltip, allowedLabel, renderGuardBanner, + tooltipProps, ...props }) => { const { permission } = useOrgPermission(); @@ -43,11 +46,19 @@ export const OrgPermissionCan: FunctionComponent = ({ : children; if (!isAllowed && passThrough) { - return {finalChild}; + return ( + + {finalChild} + + ); } if (isAllowed && renderTooltip && allowedLabel) { - return {finalChild}; + return ( + + {finalChild} + + ); } if (!isAllowed && renderGuardBanner) { diff --git a/frontend/src/components/projects/NewProjectModal.tsx b/frontend/src/components/projects/NewProjectModal.tsx index fa3ae2c70..1bbe9f6f1 100644 --- a/frontend/src/components/projects/NewProjectModal.tsx +++ b/frontend/src/components/projects/NewProjectModal.tsx @@ -153,7 +153,7 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { reset(); onOpenChange(false); navigate({ - to: getProjectHomePage(project.type), + to: getProjectHomePage(project.type, project.environments), params: { projectId: project.id } }); } catch (err) { 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-scanning/forms/SecretScanningDataSourceConfigFields/GitLabDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitLabDataSourceConfigFields.tsx new file mode 100644 index 000000000..d36f0553d --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitLabDataSourceConfigFields.tsx @@ -0,0 +1,272 @@ +import { useEffect } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { MultiValue, SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { FilterableSelect, FormControl, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { + TGitLabGroup, + TGitLabProject, + useGitLabConnectionListGroups, + useGitLabConnectionListProjects +} from "@app/hooks/api/appConnections/gitlab"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; +import { GitLabDataSourceScope } from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { SecretScanningDataSourceConnectionField } from "../SecretScanningDataSourceConnectionField"; + +enum ScanMethod { + AllProjects = "all-projects", + SelectProjects = "select-projects" +} + +export const GitLabDataSourceConfigFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretScanningDataSourceForm & { + type: SecretScanningDataSource.GitLab; + } + >(); + + const connectionId = useWatch({ control, name: "connection.id" }); + const isUpdate = Boolean(watch("id")); + + const scope = watch("config.scope"); + const groupName = watch("config.groupName"); + const includeProjects = watch("config.includeProjects"); + + const { data: projects, isPending: isProjectsPending } = useGitLabConnectionListProjects( + connectionId, + { enabled: Boolean(connectionId) } + ); + + const { data: groups, isPending: isGroupsPending } = useGitLabConnectionListGroups(connectionId, { + enabled: Boolean(connectionId) && scope === GitLabDataSourceScope.Group + }); + + const scanMethod = + !includeProjects || includeProjects.includes("*") + ? ScanMethod.AllProjects + : ScanMethod.SelectProjects; + + useEffect(() => { + if (!includeProjects) { + setValue("config.includeProjects", ["*"]); + } + }, [includeProjects]); + + const clearAllFields = () => { + setValue("config.includeProjects", []); + setValue("config.projectName", ""); + setValue("config.groupName", ""); + // @ts-expect-error rhf doesn't like this but we need to reset + setValue("config.projectId", undefined); + // @ts-expect-error rhf doesn't like this but we need to reset + setValue("config.groupId", undefined); + }; + + return ( + <> + + ( + +

Specify the GitLab scope scanning should be performed at:

+
    +
  • +

    + Project: Scan an + individual GitLab project. +

    +
  • +
  • +

    + Group: Scan one or more + projects belonging to a GitLab group. +

    +
  • +
+ + } + > + +
+ )} + /> + {scope === GitLabDataSourceScope.Project ? ( + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + value === Number.parseInt(project.id, 10))} + onChange={(newValue) => { + const project = newValue as SingleValue; + + onChange(project ? Number.parseInt(project.id, 10) : null); + setValue("config.projectName", project?.name ?? ""); + }} + options={projects} + placeholder="Select project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
+ )} + /> + ) : ( + <> + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the group you're looking for?{" "} + +
+ + } + > + value === Number.parseInt(group.id, 10))} + onChange={(newValue) => { + const group = newValue as SingleValue; + + onChange(group ? Number.parseInt(group.id, 10) : null); + setValue("config.groupName", group?.name ?? ""); + setValue("config.includeProjects", ["*"]); + }} + options={groups} + placeholder="Select group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
+ )} + /> + + + + {scanMethod === ScanMethod.SelectProjects && ( + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + value.includes(project.name))} + onChange={(newValue) => { + onChange( + newValue + ? (newValue as MultiValue).map((p) => p.name) + : null + ); + }} + options={projects?.filter((project) => project.name.startsWith(groupName))} + placeholder="Select projects..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
+ )} + /> + )} + + )} + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx index cbdc0f0b8..c4334c29a 100644 --- a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx @@ -7,10 +7,12 @@ import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; import { TSecretScanningDataSourceForm } from "../schemas"; import { BitbucketDataSourceConfigFields } from "./BitbucketDataSourceConfigFields"; import { GitHubDataSourceConfigFields } from "./GitHubDataSourceConfigFields"; +import { GitLabDataSourceConfigFields } from "./GitLabDataSourceConfigFields"; const COMPONENT_MAP: Record = { [SecretScanningDataSource.GitHub]: GitHubDataSourceConfigFields, - [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceConfigFields + [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceConfigFields, + [SecretScanningDataSource.GitLab]: GitLabDataSourceConfigFields }; export const SecretScanningDataSourceConfigFields = () => { diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/GitLabDataSourceReviewFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/GitLabDataSourceReviewFields.tsx new file mode 100644 index 000000000..ad421e57e --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/GitLabDataSourceReviewFields.tsx @@ -0,0 +1,49 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; +import { GitLabDataSourceScope } from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { SecretScanningDataSourceConfigReviewSection } from "./shared"; + +export const GitLabDataSourceReviewFields = () => { + const { watch } = useFormContext< + TSecretScanningDataSourceForm & { + type: SecretScanningDataSource.GitLab; + } + >(); + + const [config, connection] = watch(["config", "connection"]); + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectName, projectId } = config; + return ( + + {connection && {connection.name}} + + {config.scope} + + {projectName || projectId} + + ); + } + + // group-scope + + const { includeProjects, groupName, groupId } = config; + const shouldScanAll = includeProjects.includes("*"); + + return ( + + {connection && {connection.name}} + + {config.scope} + + {groupName || groupId} + + {shouldScanAll ? "All" : includeProjects.join(", ")} + + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx index e29dc9ebd..69d630e8a 100644 --- a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx @@ -6,10 +6,12 @@ import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; import { TSecretScanningDataSourceForm } from "../schemas"; import { BitbucketDataSourceReviewFields } from "./BitbucketDataSourceReviewFields"; import { GitHubDataSourceReviewFields } from "./GitHubDataSourceReviewFields"; +import { GitLabDataSourceReviewFields } from "./GitLabDataSourceReviewFields"; const COMPONENT_MAP: Record = { [SecretScanningDataSource.GitHub]: GitHubDataSourceReviewFields, - [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceReviewFields + [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceReviewFields, + [SecretScanningDataSource.GitLab]: GitLabDataSourceReviewFields }; export const SecretScanningDataSourceReviewFields = () => { diff --git a/frontend/src/components/secret-scanning/forms/schemas/gitlab-data-source-schema.ts b/frontend/src/components/secret-scanning/forms/schemas/gitlab-data-source-schema.ts new file mode 100644 index 000000000..ef577a7d2 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/schemas/gitlab-data-source-schema.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; +import { GitLabDataSourceScope } from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; + +import { BaseSecretScanningDataSourceSchema } from "./base-secret-scanning-data-source-schema"; + +export const GitLabDataSourceSchema = z + .object({ + type: z.literal(SecretScanningDataSource.GitLab), + config: z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(GitLabDataSourceScope.Group), + groupId: z.number(), + groupName: z.string(), + includeProjects: z + .array(z.string().min(1).max(256)) + .min(1, "One or more projects required") + .max(100, "Cannot configure more than 100 projects") + .default(["*"]) + }), + z.object({ + scope: z.literal(GitLabDataSourceScope.Project), + projectId: z.number(), + projectName: z.string() + }) + ]) + }) + .merge(BaseSecretScanningDataSourceSchema({ isConnectionRequired: true })); diff --git a/frontend/src/components/secret-scanning/forms/schemas/index.ts b/frontend/src/components/secret-scanning/forms/schemas/index.ts index 23e6d86e4..82a25e1ae 100644 --- a/frontend/src/components/secret-scanning/forms/schemas/index.ts +++ b/frontend/src/components/secret-scanning/forms/schemas/index.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { BitbucketDataSourceSchema } from "./bitbucket-data-source-schema"; import { GitHubDataSourceSchema } from "./github-data-source-schema"; +import { GitLabDataSourceSchema } from "./gitlab-data-source-schema"; export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [ GitHubDataSourceSchema, - BitbucketDataSourceSchema + BitbucketDataSourceSchema, + GitLabDataSourceSchema ]); export type TSecretScanningDataSourceForm = z.infer; diff --git a/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx b/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx index 53b53d5c0..e0d541138 100644 --- a/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx +++ b/frontend/src/components/secret-syncs/SecretSyncStatusBadge.tsx @@ -1,6 +1,7 @@ import { faCheck, faExclamationTriangle, + faHourglass, faRotate, IconDefinition } from "@fortawesome/free-solid-svg-icons"; @@ -29,7 +30,11 @@ export const SecretSyncStatusBadge = ({ status }: Props) => { text = "Synced"; icon = faCheck; break; - case SecretSyncStatus.Pending: // no need to differentiate from user perspective + case SecretSyncStatus.Pending: + variant = "primary"; + text = "Queued"; + icon = faHourglass; + break; case SecretSyncStatus.Running: default: variant = "primary"; @@ -42,11 +47,7 @@ export const SecretSyncStatusBadge = ({ status }: Props) => { {text} diff --git a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx index 25d992735..dc2ab385a 100644 --- a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx @@ -31,7 +31,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => environment: secretSync.environment ?? undefined, secretPath: secretSync.folder?.path, description: secretSync.description ?? "" - }, + } as Partial, reValidateMode: "onChange" }); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx index 50c645ee2..c55313898 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx @@ -5,14 +5,15 @@ import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/Se import { FilterableSelect, FormControl } from "@app/components/v2"; import { TChecklyAccount, - useChecklyConnectionListAccounts + useChecklyConnectionListAccounts, + useChecklyConnectionListGroups } from "@app/hooks/api/appConnections/checkly"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TSecretSyncForm } from "../schemas"; export const ChecklySyncFields = () => { - const { control, setValue } = useFormContext< + const { control, setValue, watch } = useFormContext< TSecretSyncForm & { destination: SecretSync.Checkly } >(); @@ -25,12 +26,24 @@ export const ChecklySyncFields = () => { } ); + const accountId = watch("destinationConfig.accountId"); + + const { data: groups = [], isPending: isGroupsLoading } = useChecklyConnectionListGroups( + connectionId, + accountId, + { + enabled: Boolean(connectionId && accountId) + } + ); + return ( <> { setValue("destinationConfig.accountId", ""); setValue("destinationConfig.accountName", ""); + setValue("destinationConfig.groupId", undefined); + setValue("destinationConfig.groupName", undefined); }} /> { )} /> + + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.groupName", v?.name ?? undefined); + }} + options={groups} + placeholder="Select a group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitLabSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitLabSyncFields.tsx index 42387e6df..fe73ea5d5 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitLabSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitLabSyncFields.tsx @@ -16,8 +16,8 @@ import { import { TGitLabGroup, TGitLabProject, - useGitlabConnectionListGroups, - useGitlabConnectionListProjects + useGitLabConnectionListGroups, + useGitLabConnectionListProjects } from "@app/hooks/api/appConnections/gitlab"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { GitLabSyncScope } from "@app/hooks/api/secretSyncs/types/gitlab-sync"; @@ -70,11 +70,11 @@ export const GitLabSyncFields = () => { const scope = useWatch({ name: "destinationConfig.scope", control }); const shouldMaskSecrets = useWatch({ name: "destinationConfig.shouldMaskSecrets", control }); - const { data: groups, isLoading: isGroupsLoading } = useGitlabConnectionListGroups(connectionId, { + const { data: groups, isLoading: isGroupsLoading } = useGitLabConnectionListGroups(connectionId, { enabled: Boolean(connectionId) && scope === GitLabSyncScope.Group }); - const { data: projects, isLoading: isProjectsLoading } = useGitlabConnectionListProjects( + const { data: projects, isLoading: isProjectsLoading } = useGitLabConnectionListProjects( connectionId, { enabled: Boolean(connectionId) diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NetlifySyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NetlifySyncFields.tsx new file mode 100644 index 000000000..fd977ec2a --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NetlifySyncFields.tsx @@ -0,0 +1,144 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { + TNetlifyAccount, + TNetlifySite, + useNetlifyConnectionListAccounts, + useNetlifyConnectionListSites +} from "@app/hooks/api/appConnections/netlify"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; +import { NetlifySyncContext } from "../schemas/netlify-sync-destination-schema"; + +export const NetlifySyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Netlify } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const accountId = useWatch({ name: "destinationConfig.accountId", control }); + + const { data: accounts = [], isPending: isAccountsLoading } = useNetlifyConnectionListAccounts( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const { data: sites = [], isPending: isSitesLoading } = useNetlifyConnectionListSites( + connectionId, + accountId, + { + enabled: Boolean(accountId) + } + ); + + const contexts = Object.entries(NetlifySyncContext).map(([key, value]) => ({ + label: key, + value + })); + + return ( + <> + { + setValue("destinationConfig.accountId", ""); + setValue("destinationConfig.accountName", ""); + }} + /> + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.accountName", v?.name ?? ""); + }} + options={accounts} + placeholder="Select an account..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + if (v?.id === value) { + onChange(undefined); + setValue("destinationConfig.siteName", undefined); + } else { + onChange(v?.id); + setValue("destinationConfig.siteName", v?.name); + } + }} + options={sites} + placeholder="Select a site..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + ( + + p.value === value) ?? undefined} + onChange={(option) => { + const v = option as SingleValue<{ label: string; value: NetlifySyncContext }>; + if (v) onChange(v.value); + }} + options={contexts} + placeholder="Select a context..." + getOptionLabel={(option) => option.label} + getOptionValue={(option) => option.value} + /> + + )} + /> + + ); +}; 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/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 478447541..b2186fed1 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -23,6 +23,7 @@ import { GitLabSyncFields } from "./GitLabSyncFields"; import { HCVaultSyncFields } from "./HCVaultSyncFields"; import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; +import { NetlifySyncFields } from "./NetlifySyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; import { RailwaySyncFields } from "./RailwaySyncFields"; import { RenderSyncFields } from "./RenderSyncFields"; @@ -95,6 +96,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.DigitalOceanAppPlatform: return ; + case SecretSync.Netlify: + return ; case SecretSync.Bitbucket: return ; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index cb7a40559..93e71cfc6 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -67,6 +67,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Checkly: case SecretSync.Supabase: case SecretSync.DigitalOceanAppPlatform: + case SecretSync.Netlify: case SecretSync.Bitbucket: AdditionalSyncOptionsFieldsComponent = null; break; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx index d520699ce..0f86a0c06 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx @@ -6,7 +6,16 @@ import { SecretSync } from "@app/hooks/api/secretSyncs"; export const ChecklySyncReviewFields = () => { const { watch } = useFormContext(); - const accountName = watch("destinationConfig.accountName"); + const config = watch("destinationConfig"); - return {accountName}; + return ( + <> + + {config.accountName ?? config.accountId} + + {config.groupId && ( + {config.groupName ?? config.groupId} + )} + + ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NetlifySyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NetlifySyncReviewFields.tsx new file mode 100644 index 000000000..5898de7c3 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NetlifySyncReviewFields.tsx @@ -0,0 +1,22 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const NetlifySyncReviewFields = () => { + const { watch } = useFormContext(); + const accountName = watch("destinationConfig.accountName"); + const accountId = watch("destinationConfig.accountId"); + const siteName = watch("destinationConfig.siteName"); + const siteId = watch("destinationConfig.siteId"); + const context = watch("destinationConfig.context"); + + return ( + <> + {accountName || accountId} + {siteName || siteId || "None"} + {context || "None"} + + ); +}; 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/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 5ee53f2e3..eaeab2e66 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -32,6 +32,7 @@ import { GitLabSyncReviewFields } from "./GitLabSyncReviewFields"; import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { NetlifySyncReviewFields } from "./NetlifySyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; import { RailwaySyncReviewFields } from "./RailwaySyncReviewFields"; @@ -150,6 +151,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.DigitalOceanAppPlatform: DestinationFieldsComponent = ; break; + case SecretSync.Netlify: + DestinationFieldsComponent = ; + break; case SecretSync.Bitbucket: DestinationFieldsComponent = ; break; diff --git a/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts index 9ada6e276..7684df981 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts @@ -8,7 +8,15 @@ export const ChecklySyncDestinationSchema = BaseSecretSyncSchema().merge( destination: z.literal(SecretSync.Checkly), destinationConfig: z.object({ accountId: z.string(), - accountName: z.string() + accountName: z.string(), + groupId: z + .string() + .nullish() + .transform((val) => val || undefined), + groupName: z + .string() + .nullish() + .transform((val) => val || undefined) }) }) ); diff --git a/frontend/src/components/secret-syncs/forms/schemas/netlify-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/netlify-sync-destination-schema.ts new file mode 100644 index 000000000..b0412bda1 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/netlify-sync-destination-schema.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export enum NetlifySyncContext { + // All = "all", - Netlify doesn't allow "all" as the context when using is_secret: true + DeployPreview = "deploy-preview", + Production = "production", + BranchDeploy = "branch-deploy", + Dev = "dev", + Branch = "branch" +} + +export const NetlifySyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Netlify), + destinationConfig: z.object({ + accountId: z.string(), + accountName: z.string(), + siteId: z.string().optional(), + siteName: z.string().optional(), + context: z.nativeEnum(NetlifySyncContext).optional() + }) + }) +); 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/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 5ffd5940a..f4616c711 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -20,6 +20,7 @@ import { GitlabSyncDestinationSchema } from "./gitlab-sync-destination-schema"; import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema"; import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; +import { NetlifySyncDestinationSchema } from "./netlify-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; import { RailwaySyncDestinationSchema } from "./railway-sync-destination-schema"; import { RenderSyncDestinationSchema } from "./render-sync-destination-schema"; @@ -59,6 +60,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ RailwaySyncDestinationSchema, ChecklySyncDestinationSchema, DigitalOceanAppPlatformSyncDestinationSchema, + NetlifySyncDestinationSchema, BitbucketSyncDestinationSchema ]); diff --git a/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx b/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx index b8724f5dc..a9542c90c 100644 --- a/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx +++ b/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx @@ -125,7 +125,7 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop if (isPending) { return (
- +
); } diff --git a/frontend/src/components/utilities/attemptChangePassword.ts b/frontend/src/components/utilities/attemptChangePassword.ts deleted file mode 100644 index e51168525..000000000 --- a/frontend/src/components/utilities/attemptChangePassword.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* eslint-disable new-cap */ -import crypto from "crypto"; - -import jsrp from "jsrp"; - -import { changePassword, srp1 } from "@app/hooks/api/auth/queries"; - -import Aes256Gcm from "./cryptography/aes-256-gcm"; -import { deriveArgonKey } from "./cryptography/crypto"; -import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage"; - -const clientOldPassword = new jsrp.client(); -const clientNewPassword = new jsrp.client(); - -type Params = { - email: string; - currentPassword: string; - newPassword: string; -}; - -const attemptChangePassword = ({ email, currentPassword, newPassword }: Params): Promise => { - return new Promise((resolve, reject) => { - clientOldPassword.init({ username: email, password: currentPassword }, async () => { - let serverPublicKey; - let salt; - - try { - const clientPublicKey = clientOldPassword.getPublicKey(); - - const res = await srp1({ clientPublicKey }); - - serverPublicKey = res.serverPublicKey; - salt = res.salt; - - clientOldPassword.setSalt(salt); - clientOldPassword.setServerPublicKey(serverPublicKey); - - const clientProof = clientOldPassword.getProof(); - - clientNewPassword.init({ username: email, password: newPassword }, async () => { - clientNewPassword.createVerifier(async (_err, result) => { - try { - const derivedKey = await deriveArgonKey({ - password: newPassword, - salt: result.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); - - const { - ciphertext: encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag - } = Aes256Gcm.encrypt({ - text: localStorage.getItem("PRIVATE_KEY") as string, - secret: key - }); - - const { - ciphertext: protectedKey, - iv: protectedKeyIV, - tag: protectedKeyTag - } = Aes256Gcm.encrypt({ - text: key.toString("hex"), - secret: Buffer.from(derivedKey.hash) - }); - - await changePassword({ - password: newPassword, - clientProof, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt: result.salt, - verifier: result.verifier - }); - - saveTokenToLocalStorage({ - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag - }); - - resolve(); - } catch (err2) { - console.error(err2); - reject(err2); - } - }); - }); - } catch (err) { - console.error(err); - reject(err); - } - }); - }); -}; - -export default attemptChangePassword; diff --git a/frontend/src/components/utilities/attemptCliLogin.ts b/frontend/src/components/utilities/attemptCliLogin.ts index d897ebeaa..f8339aecf 100644 --- a/frontend/src/components/utilities/attemptCliLogin.ts +++ b/frontend/src/components/utilities/attemptCliLogin.ts @@ -1,18 +1,18 @@ /* eslint-disable prefer-destructuring */ +import axios from "axios"; import jsrp from "jsrp"; -import { decryptPrivateKeyHelper } from "@app/helpers/key"; -import { login1, login2 } from "@app/hooks/api/auth/queries"; +import { login1, login2, loginV3 } from "@app/hooks/api/auth/queries"; +import { createNotification } from "../notifications"; import Telemetry from "./telemetry/Telemetry"; -import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage"; +import { LoginMode } from "./attemptLogin"; import SecurityClient from "./SecurityClient"; // eslint-disable-next-line new-cap const client = new jsrp.client(); export interface IsCliLoginSuccessful { - mfaEnabled: boolean; loginResponse?: { email: string; privateKey: string; @@ -31,14 +31,62 @@ const attemptLogin = async ({ email, password, providerAuthToken, - captchaToken + captchaToken, + loginMode = LoginMode.ServerSide }: { email: string; password: string; providerAuthToken?: string; captchaToken?: string; + loginMode?: LoginMode; }): Promise => { const telemetry = new Telemetry().getInstance(); + + if (loginMode === LoginMode.ServerSide) { + console.log("Attempting login with server side..."); + const data = await loginV3({ + email, + password, + providerAuthToken, + captchaToken + }).catch((err) => { + if (axios.isAxiosError(err) && err.response?.status === 400) { + if (err.response?.data?.error === "LegacyEncryptionScheme") { + createNotification({ + text: "Failed to login without SRP, attempting to authenticate with legacy SRP authentication.", + type: "info" + }); + + return null; + } + } + + throw err; + }); + + if (data === null) { + return attemptLogin({ + email, + password, + providerAuthToken, + captchaToken, + loginMode: LoginMode.LegacySrp + }); + } + + SecurityClient.setProviderAuthToken(""); + SecurityClient.setToken(data.accessToken); + + return { + success: true, + loginResponse: { + email, + privateKey: "", + JTWToken: data.accessToken + } + }; + } + return new Promise((resolve, reject) => { client.init( { @@ -58,79 +106,26 @@ const attemptLogin = async ({ client.setServerPublicKey(serverPublicKey); const clientProof = client.getProof(); // called M1 - const { - mfaEnabled, - encryptionVersion, - protectedKey, - protectedKeyIV, - protectedKeyTag, - token, - publicKey, - encryptedPrivateKey, - iv, - tag - } = await login2({ + const { encryptionVersion, token, encryptedPrivateKey, iv, tag } = await login2({ email, password, clientProof, providerAuthToken, captchaToken }); - if (mfaEnabled) { - // case: MFA is enabled - - // set temporary (MFA) JWT token - SecurityClient.setMfaToken(token); - - resolve({ - mfaEnabled, - success: true - }); - } else if ( - !mfaEnabled && - encryptionVersion && - encryptedPrivateKey && - iv && - tag && - token - ) { - // case: MFA is not enabled - - // unset provider auth token in case it was used + if (encryptionVersion && encryptedPrivateKey && iv && tag && token) { SecurityClient.setProviderAuthToken(""); - // set JWT token SecurityClient.setToken(token); - const privateKey = await decryptPrivateKeyHelper({ - encryptionVersion, - encryptedPrivateKey, - iv, - tag, - password, - salt, - protectedKey, - protectedKeyIV, - protectedKeyTag - }); - - saveTokenToLocalStorage({ - publicKey, - encryptedPrivateKey, - iv, - tag, - privateKey - }); - if (email) { telemetry.identify(email, email); telemetry.capture("User Logged In"); } resolve({ - mfaEnabled: false, loginResponse: { email, - privateKey, + privateKey: "", JTWToken: token }, success: true diff --git a/frontend/src/components/utilities/attemptCliLoginMfa.ts b/frontend/src/components/utilities/attemptCliLoginMfa.ts deleted file mode 100644 index 14a61d817..000000000 --- a/frontend/src/components/utilities/attemptCliLoginMfa.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* eslint-disable prefer-destructuring */ -import jsrp from "jsrp"; - -import { decryptPrivateKeyHelper } from "@app/helpers/key"; -import { login1, verifyMfaToken } from "@app/hooks/api/auth/queries"; - -import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage"; -import SecurityClient from "./SecurityClient"; - -// eslint-disable-next-line new-cap -const client = new jsrp.client(); - -interface IsMfaLoginSuccessful { - success: boolean; - loginResponse: { - privateKey: string; - JTWToken: string; - }; -} - -/** - * Return whether or not MFA-login is successful for user with email [email] - * and MFA token [mfaToken] - * @param {Object} obj - * @param {String} obj.email - email of user - * @param {String} obj.mfaToken - MFA code/token - */ -const attemptLoginMfa = async ({ - email, - password, - providerAuthToken, - mfaToken -}: { - email: string; - password: string; - providerAuthToken?: string; - mfaToken: string; -}): Promise => { - return new Promise((resolve, reject) => { - client.init( - { - username: email, - password - }, - async () => { - try { - const clientPublicKey = client.getPublicKey(); - const { salt } = await login1({ - email, - clientPublicKey, - providerAuthToken - }); - - const { - encryptionVersion, - protectedKey, - protectedKeyIV, - protectedKeyTag, - token, - publicKey, - encryptedPrivateKey, - iv, - tag - } = await verifyMfaToken({ - email, - mfaCode: mfaToken - }); - - // unset temporary (MFA) JWT token and set JWT token - SecurityClient.setMfaToken(""); - SecurityClient.setToken(token); - SecurityClient.setProviderAuthToken(""); - - const privateKey = await decryptPrivateKeyHelper({ - encryptionVersion, - encryptedPrivateKey, - iv, - tag, - password, - salt, - protectedKey, - protectedKeyIV, - protectedKeyTag - }); - - saveTokenToLocalStorage({ - publicKey, - encryptedPrivateKey, - iv, - tag, - privateKey - }); - - resolve({ - success: true, - loginResponse: { - privateKey, - JTWToken: token - } - }); - } catch (err) { - reject(err); - } - } - ); - }); -}; - -export default attemptLoginMfa; diff --git a/frontend/src/components/utilities/attemptLogin.ts b/frontend/src/components/utilities/attemptLogin.ts index c371d9c91..bdaf79042 100644 --- a/frontend/src/components/utilities/attemptLogin.ts +++ b/frontend/src/components/utilities/attemptLogin.ts @@ -1,15 +1,19 @@ /* eslint-disable prefer-destructuring */ +import axios from "axios"; import jsrp from "jsrp"; -import { decryptPrivateKeyHelper } from "@app/helpers/key"; -import { login1, login2 } from "@app/hooks/api/auth/queries"; +import { login1, login2, loginV3 } from "@app/hooks/api/auth/queries"; +import { createNotification } from "../notifications"; import Telemetry from "./telemetry/Telemetry"; -import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage"; import SecurityClient from "./SecurityClient"; +export enum LoginMode { + LegacySrp = "legacy-srp", + ServerSide = "server-side" +} + interface IsLoginSuccessful { - mfaEnabled: boolean; success: boolean; } @@ -23,14 +27,62 @@ const attemptLogin = async ({ email, password, providerAuthToken, - captchaToken + captchaToken, + loginMode = LoginMode.ServerSide }: { email: string; password: string; providerAuthToken?: string; captchaToken?: string; + loginMode?: LoginMode; }): Promise => { const telemetry = new Telemetry().getInstance(); + + if (loginMode === LoginMode.ServerSide) { + console.log("Attempting login with server side..."); + const data = await loginV3({ + email, + password, + providerAuthToken, + captchaToken + }).catch((err) => { + if (axios.isAxiosError(err) && err.response?.status === 400) { + if (err.response?.data?.error === "LegacyEncryptionScheme") { + createNotification({ + text: "Failed to login without SRP, attempting to authenticate with legacy SRP authentication.", + type: "info" + }); + + return null; + } + } + + throw err; + }); + + if (data === null) { + return attemptLogin({ + email, + password, + providerAuthToken, + captchaToken, + loginMode: LoginMode.LegacySrp + }); + } + + SecurityClient.setProviderAuthToken(""); + SecurityClient.setToken(data.accessToken); + + if (email) { + telemetry.identify(email, email); + telemetry.capture("User Logged In"); + } + + return { + success: true + }; + } + // eslint-disable-next-line new-cap const client = new jsrp.client(); await new Promise((resolve) => { @@ -48,18 +100,7 @@ const attemptLogin = async ({ client.setServerPublicKey(serverPublicKey); const clientProof = client.getProof(); // called M1 - const { - mfaEnabled, - encryptionVersion, - protectedKey, - protectedKeyIV, - protectedKeyTag, - token, - publicKey, - encryptedPrivateKey, - iv, - tag - } = await login2({ + const { encryptionVersion, token, encryptedPrivateKey, iv, tag } = await login2({ captchaToken, email, password, @@ -67,56 +108,22 @@ const attemptLogin = async ({ providerAuthToken }); - if (mfaEnabled) { - // case: MFA is enabled - - // set temporary (MFA) JWT token - SecurityClient.setMfaToken(token); - - return { - mfaEnabled, - success: true - }; - } - if (!mfaEnabled && encryptionVersion && encryptedPrivateKey && iv && tag && token) { - // case: MFA is not enabled - + if (encryptionVersion && encryptedPrivateKey && iv && tag && token) { // unset provider auth token in case it was used SecurityClient.setProviderAuthToken(""); // set JWT token SecurityClient.setToken(token); - const privateKey = await decryptPrivateKeyHelper({ - encryptionVersion, - encryptedPrivateKey, - iv, - tag, - password, - salt, - protectedKey, - protectedKeyIV, - protectedKeyTag - }); - - saveTokenToLocalStorage({ - publicKey, - encryptedPrivateKey, - iv, - tag, - privateKey - }); - if (email) { telemetry.identify(email, email); telemetry.capture("User Logged In"); } return { - mfaEnabled: false, success: true }; } - return { success: false, mfaEnabled: false }; + return { success: false }; }; export default attemptLogin; diff --git a/frontend/src/components/utilities/attemptLoginMfa.ts b/frontend/src/components/utilities/attemptLoginMfa.ts deleted file mode 100644 index 10f36ec39..000000000 --- a/frontend/src/components/utilities/attemptLoginMfa.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* eslint-disable prefer-destructuring */ -import jsrp from "jsrp"; - -import { decryptPrivateKeyHelper } from "@app/helpers/key"; -import { login1, verifyMfaToken } from "@app/hooks/api/auth/queries"; - -import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage"; -import SecurityClient from "./SecurityClient"; - -// eslint-disable-next-line new-cap -const client = new jsrp.client(); - -/** - * Return whether or not MFA-login is successful for user with email [email] - * and MFA token [mfaToken] - * @param {Object} obj - * @param {String} obj.email - email of user - * @param {String} obj.mfaToken - MFA code/token - */ -const attemptLoginMfa = async ({ - email, - password, - providerAuthToken, - mfaToken -}: { - email: string; - password: string; - providerAuthToken?: string; - mfaToken: string; -}): Promise => { - return new Promise((resolve, reject) => { - client.init( - { - username: email, - password - }, - async () => { - try { - const clientPublicKey = client.getPublicKey(); - const { salt } = await login1({ - email, - clientPublicKey, - providerAuthToken - }); - - const { - encryptionVersion, - protectedKey, - protectedKeyIV, - protectedKeyTag, - token, - publicKey, - encryptedPrivateKey, - iv, - tag - } = await verifyMfaToken({ - email, - mfaCode: mfaToken - }); - - // unset temporary (MFA) JWT token and set JWT token - SecurityClient.setMfaToken(""); - SecurityClient.setToken(token); - SecurityClient.setProviderAuthToken(""); - - const privateKey = await decryptPrivateKeyHelper({ - encryptionVersion, - encryptedPrivateKey, - iv, - tag, - password, - salt, - protectedKey, - protectedKeyIV, - protectedKeyTag - }); - - saveTokenToLocalStorage({ - publicKey, - encryptedPrivateKey, - iv, - tag, - privateKey - }); - - resolve(true); - } catch (err) { - reject(err); - } - } - ); - }); -}; - -export default attemptLoginMfa; diff --git a/frontend/src/components/utilities/cryptography/issueBackupKey.ts b/frontend/src/components/utilities/cryptography/issueBackupKey.ts deleted file mode 100644 index 52a502e4e..000000000 --- a/frontend/src/components/utilities/cryptography/issueBackupKey.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* eslint-disable new-cap */ -import crypto from "crypto"; - -import jsrp from "jsrp"; - -import { issueBackupPrivateKey, srp1 } from "@app/hooks/api/auth/queries"; - -import generateBackupPDF from "../generateBackupPDF"; -import Aes256Gcm from "./aes-256-gcm"; - -const clientPassword = new jsrp.client(); -const clientKey = new jsrp.client(); - -interface BackupKeyProps { - email: string; - password: string; - personalName: string; - setBackupKeyError: (value: boolean) => void; - setBackupKeyIssued: (value: boolean) => void; -} - -/** - * This function issue a backup key for a user - * @param {obkect} obj - * @param {string} obj.email - email of a user issuing a backup key - * @param {string} obj.password - password of a user issuing a backup key - * @param {string} obj.personalName - name of a user issuing a backup key - * @param {function} obj.setBackupKeyError - state function that turns true if there is an erorr with a backup key - * @param {function} obj.setBackupKeyIssued - state function that turns true if a backup key was issued correctly - * @returns - */ -const issueBackupKey = async ({ - email, - password, - personalName, - setBackupKeyError, - setBackupKeyIssued -}: BackupKeyProps) => { - try { - setBackupKeyError(false); - setBackupKeyIssued(false); - clientPassword.init( - { - username: email, - password - }, - async () => { - const clientPublicKey = clientPassword.getPublicKey(); - - let serverPublicKey; - let salt; - try { - const res = await srp1({ - clientPublicKey - }); - serverPublicKey = res.serverPublicKey; - salt = res.salt; - } catch (err) { - setBackupKeyError(true); - console.log("Wrong current password", err, 1); - } - - clientPassword.setSalt(salt as string); - clientPassword.setServerPublicKey(serverPublicKey as string); - const clientProof = clientPassword.getProof(); // called M1 - - const generatedKey = crypto.randomBytes(16).toString("hex"); - - clientKey.init( - { - username: email, - password: generatedKey - }, - async () => { - clientKey.createVerifier( - async (_err: any, result: { salt: string; verifier: string }) => { - const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ - text: String(localStorage.getItem("PRIVATE_KEY")), - secret: generatedKey - }); - - try { - await issueBackupPrivateKey({ - encryptedPrivateKey: ciphertext, - iv, - tag, - salt: result.salt, - verifier: result.verifier, - clientProof - }); - - generateBackupPDF({ - personalName, - personalEmail: email, - generatedKey - }); - setBackupKeyIssued(true); - } catch { - setBackupKeyError(true); - } - } - ); - } - ); - } - ); - } catch { - setBackupKeyError(true); - console.log("Failed to issue a backup key"); - } - return true; -}; - -export default issueBackupKey; 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/components/utilities/saveTokenToLocalStorage.ts b/frontend/src/components/utilities/saveTokenToLocalStorage.ts deleted file mode 100644 index 1539438f4..000000000 --- a/frontend/src/components/utilities/saveTokenToLocalStorage.ts +++ /dev/null @@ -1,67 +0,0 @@ -interface Props { - protectedKey?: string; - protectedKeyIV?: string; - protectedKeyTag?: string; - publicKey?: string; - encryptedPrivateKey?: string; - iv?: string; - tag?: string; - privateKey?: string; -} - -export const saveTokenToLocalStorage = ({ - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey, - encryptedPrivateKey, - iv, - tag, - privateKey -}: Props) => { - try { - if (protectedKey) { - localStorage.removeItem("protectedKey"); - localStorage.setItem("protectedKey", protectedKey); - } - - if (protectedKeyIV) { - localStorage.removeItem("protectedKeyIV"); - localStorage.setItem("protectedKeyIV", protectedKeyIV); - } - - if (protectedKeyTag) { - localStorage.removeItem("protectedKeyTag"); - localStorage.setItem("protectedKeyTag", protectedKeyTag); - } - - if (publicKey) { - localStorage.removeItem("publicKey"); - localStorage.setItem("publicKey", publicKey); - } - - if (encryptedPrivateKey) { - localStorage.removeItem("encryptedPrivateKey"); - localStorage.setItem("encryptedPrivateKey", encryptedPrivateKey); - } - - if (iv) { - localStorage.removeItem("iv"); - localStorage.setItem("iv", iv); - } - - if (tag) { - localStorage.removeItem("tag"); - localStorage.setItem("tag", tag); - } - - if (privateKey) { - localStorage.removeItem("PRIVATE_KEY"); - localStorage.setItem("PRIVATE_KEY", privateKey); - } - } catch (err) { - if (err instanceof Error) { - throw new Error(`Unable to send the tokens in local storage:${err.message}`); - } - } -}; diff --git a/frontend/src/components/v2/Badge/Badge.tsx b/frontend/src/components/v2/Badge/Badge.tsx index 4355b0251..0e6addd77 100644 --- a/frontend/src/components/v2/Badge/Badge.tsx +++ b/frontend/src/components/v2/Badge/Badge.tsx @@ -1,3 +1,4 @@ +import { forwardRef } from "react"; import { cva, VariantProps } from "cva"; import { twMerge } from "tailwind-merge"; @@ -24,13 +25,16 @@ const badgeVariants = cva( export type BadgeProps = VariantProps & IProps; -export const Badge = ({ children, className, variant, ...props }: BadgeProps) => { - return ( -
- {children} -
- ); -}; +export const Badge = forwardRef( + ({ children, className, variant, ...props }, ref) => { + return ( +
+ {children} +
+ ); + } +); diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx index 2daa76930..179a55714 100644 --- a/frontend/src/components/v2/Button/Button.tsx +++ b/frontend/src/components/v2/Button/Button.tsx @@ -204,7 +204,7 @@ export const Button = forwardRef( {leftIcon && (
{ +export const ContentLoader = ({ text, frequency = 2000, className, lottieClassName }: Props) => { const [pos, setPos] = useState(0); const isTextArray = Array.isArray(text); useEffect(() => { @@ -33,7 +34,11 @@ export const ContentLoader = ({ text, frequency = 2000, className }: Props) => { className )} > - + {text && isTextArray && ( ({ isSelected && "text-mineshaft-200", "px-3 py-2 text-xs hover:cursor-pointer" ), - noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md" + noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md", + loadingMessage: () => "text-mineshaft-400 p-2 rounded-md" }} {...props} /> diff --git a/frontend/src/components/v2/DatePicker/DatePicker.tsx b/frontend/src/components/v2/DatePicker/DatePicker.tsx index 115b0ad3e..9611e957f 100644 --- a/frontend/src/components/v2/DatePicker/DatePicker.tsx +++ b/frontend/src/components/v2/DatePicker/DatePicker.tsx @@ -98,7 +98,7 @@ export const DatePicker = ({ > {value ? formatDateTime({ timestamp: value, timezone, dateFormat }) - : "Pick a date and time"} + : `Select Date${hideTime ? "" : " and Time"}`}
diff --git a/frontend/src/components/v2/Drawer/Drawer.tsx b/frontend/src/components/v2/Drawer/Drawer.tsx index e74995333..7ebbe1350 100644 --- a/frontend/src/components/v2/Drawer/Drawer.tsx +++ b/frontend/src/components/v2/Drawer/Drawer.tsx @@ -13,6 +13,7 @@ export type DrawerContentProps = DialogPrimitive.DialogContentProps & { subTitle?: ReactNode; footerContent?: ReactNode; onClose?: () => void; + cardBodyClassName?: string; } & VariantProps; const drawerContentVariation = cva( @@ -32,7 +33,17 @@ const drawerContentVariation = cva( export const DrawerContent = forwardRef( ( - { children, title, subTitle, className, footerContent, direction = "right", onClose, ...props }, + { + children, + title, + subTitle, + className, + footerContent, + direction = "right", + onClose, + cardBodyClassName, + ...props + }, forwardedRef ) => ( @@ -47,11 +58,16 @@ export const DrawerContent = forwardRef( > {title && ( - + {title} )} - + {children} {footerContent && {footerContent}}{" "} diff --git a/frontend/src/components/v2/EmptyState/EmptyState.tsx b/frontend/src/components/v2/EmptyState/EmptyState.tsx index 9816a3fe2..f6b926953 100644 --- a/frontend/src/components/v2/EmptyState/EmptyState.tsx +++ b/frontend/src/components/v2/EmptyState/EmptyState.tsx @@ -10,6 +10,7 @@ type Props = { children?: ReactNode; icon?: IconDefinition; iconSize?: SizeProp; + titleClassName?: string; }; export const EmptyState = ({ @@ -17,7 +18,8 @@ export const EmptyState = ({ className, children, icon = faCubesStacked, - iconSize = "2x" + iconSize = "2x", + titleClassName }: Props) => (
-
{title}
+
{title}
{children}
diff --git a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx index 47a5acd56..8d0849a04 100644 --- a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx +++ b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx @@ -67,7 +67,7 @@ export const FilterableSelect = ({ }), menuPortal: (provided) => ({ ...provided, - zIndex: 9999 + zIndex: 99999 }) }} tabSelectsValue={tabSelectsValue} @@ -91,7 +91,7 @@ export const FilterableSelect = ({ ), placeholder: () => `${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`, - input: () => "pl-1", + input: () => `pl-1 ${isMulti ? "py-[0.22rem]" : ""}`, valueContainer: () => `px-1 max-h-[8.2rem] ${ isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]" @@ -114,7 +114,8 @@ export const FilterableSelect = ({ isSelected && "text-mineshaft-200", "rounded px-3 py-2 text-xs hover:cursor-pointer" ), - noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md" + noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md", + loadingMessage: () => "text-mineshaft-400 p-2 rounded-md" }} {...props} /> diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index a42ee02a5..ee2f196b1 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -1,5 +1,5 @@ import { forwardRef, TextareaHTMLAttributes, useCallback, useMemo, useRef, useState } from "react"; -import { faCircle, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; +import { faFolder, faKey, faLayerGroup } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as Popover from "@radix-ui/react-popover"; @@ -79,7 +79,7 @@ export const InfisicalSecretInput = forwardRef( const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; - const [debouncedValue] = useDebounce(value, 500); + const [debouncedValue] = useDebounce(value, 100); const [highlightedIndex, setHighlightedIndex] = useState(-1); @@ -142,7 +142,7 @@ export const InfisicalSecretInput = forwardRef( const suggestions = useMemo(() => { if (!isPopupOpen) return []; // reset highlight whenever recomputation happens - setHighlightedIndex(-1); + setHighlightedIndex(0); const suggestionsArr: ReferenceItem[] = []; const predicate = suggestionSource.predicate.toLowerCase(); @@ -298,17 +298,21 @@ export const InfisicalSecretInput = forwardRef( }} >
{suggestions.map((item, i) => { let entryIcon; + let subText; if (item.type === ReferenceType.SECRET) { - entryIcon = faKey; + entryIcon = ; + subText = "Secret"; } else if (item.type === ReferenceType.ENVIRONMENT) { - entryIcon = faCircle; + entryIcon = ; + subText = "Environment"; } else { - entryIcon = faFolder; + entryIcon = ; + subText = "Folder"; } return ( @@ -327,22 +331,22 @@ export const InfisicalSecretInput = forwardRef( }} onMouseEnter={() => setHighlightedIndex(i)} style={{ pointerEvents: "auto" }} - className="flex items-center justify-between border-mineshaft-600 text-left" + className="flex w-full items-center justify-between border-mineshaft-600 text-left" key={`secret-reference-secret-${i + 1}`} >
-
-
- +
+
{entryIcon}
+
+ {item.label} +
+ {subText} +
-
{item.label}
diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index 6f3f4283a..747af73d5 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -6,20 +6,12 @@ import { useToggle } from "@app/hooks"; import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem"; const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g; -const replaceContentWithDot = (str: string) => { - let finalStr = ""; - for (let i = 0; i < str.length; i += 1) { - const char = str.at(i); - finalStr += char === "\n" ? "\n" : "*"; - } - return finalStr; -}; const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport?: boolean) => { if (isImport && !content) return "IMPORTED"; if (content === "") return "EMPTY"; if (!content) return "EMPTY"; - if (!isVisible) return replaceContentWithDot(content); + if (!isVisible) return HIDDEN_SECRET_VALUE; let skipNext = false; const formattedContent = content.split(REGEX).flatMap((el, i) => { diff --git a/frontend/src/components/v2/Table/Table.tsx b/frontend/src/components/v2/Table/Table.tsx index cc180ffb6..b8ca27dc1 100644 --- a/frontend/src/components/v2/Table/Table.tsx +++ b/frontend/src/components/v2/Table/Table.tsx @@ -1,4 +1,4 @@ -import { DetailedHTMLProps, HTMLAttributes, ReactNode, TdHTMLAttributes } from "react"; +import { DetailedHTMLProps, forwardRef, HTMLAttributes, ReactNode, TdHTMLAttributes } from "react"; import { twMerge } from "tailwind-merge"; import { Skeleton } from "../Skeleton"; @@ -9,22 +9,20 @@ export type TableContainerProps = { className?: string; } & DetailedHTMLProps, HTMLDivElement>; -export const TableContainer = ({ - children, - className, - isRounded = true, - ...props -}: TableContainerProps): JSX.Element => ( -
- {children} -
+export const TableContainer = forwardRef( + ({ children, className, isRounded = true, ...props }, ref): JSX.Element => ( +
+ {children} +
+ ) ); // main parent table diff --git a/frontend/src/components/v2/Tooltip/Tooltip.tsx b/frontend/src/components/v2/Tooltip/Tooltip.tsx index 12d4397d9..acd0f67f8 100644 --- a/frontend/src/components/v2/Tooltip/Tooltip.tsx +++ b/frontend/src/components/v2/Tooltip/Tooltip.tsx @@ -16,6 +16,7 @@ export type TooltipProps = Omit // just render children if tooltip content is empty content ? ( & SecretEventSubjectFields) + ) + ]; export type TProjectPermission = MongoAbility; diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 833956d77..bfb504664 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -2,6 +2,7 @@ export { useOrganization } from "./OrganizationContext"; export type { TOrgPermission } from "./OrgPermissionContext"; export { OrgPermissionActions, + OrgPermissionAuditLogsActions, OrgPermissionBillingActions, OrgPermissionGroupActions, OrgPermissionIdentityActions, @@ -11,6 +12,7 @@ export { export type { TProjectPermission } from "./ProjectPermissionContext"; export { ProjectPermissionActions, + ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 8c8475767..88d9bb0d1 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -13,6 +13,7 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { Auth0ConnectionMethod, AwsConnectionMethod, + AzureADCSConnectionMethod, AzureAppConfigurationConnectionMethod, AzureClientSecretsConnectionMethod, AzureDevOpsConnectionMethod, @@ -45,6 +46,7 @@ import { BitbucketConnectionMethod } from "@app/hooks/api/appConnections/types/b import { ChecklyConnectionMethod } from "@app/hooks/api/appConnections/types/checkly-connection"; import { DigitalOceanConnectionMethod } from "@app/hooks/api/appConnections/types/digital-ocean"; import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; +import { NetlifyConnectionMethod } from "@app/hooks/api/appConnections/types/netlify-connection"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection"; import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection"; @@ -75,6 +77,7 @@ export const APP_CONNECTION_MAP: Record< image: "Microsoft Azure.png" }, [AppConnection.AzureDevOps]: { name: "Azure DevOps", image: "Microsoft Azure.png" }, + [AppConnection.AzureADCS]: { name: "Azure ADCS", image: "Microsoft Azure.png" }, [AppConnection.Databricks]: { name: "Databricks", image: "Databricks.png" }, [AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" }, [AppConnection.TerraformCloud]: { name: "Terraform Cloud", image: "Terraform Cloud.png" }, @@ -94,7 +97,7 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Heroku]: { name: "Heroku", image: "Heroku.png" }, [AppConnection.Render]: { name: "Render", image: "Render.png" }, [AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" }, - [AppConnection.Gitlab]: { name: "GitLab", image: "GitLab.png" }, + [AppConnection.GitLab]: { name: "GitLab", image: "GitLab.png" }, [AppConnection.Cloudflare]: { name: "Cloudflare", image: "Cloudflare.png" }, [AppConnection.Zabbix]: { name: "Zabbix", image: "Zabbix.png" }, [AppConnection.Railway]: { name: "Railway", image: "Railway.png" }, @@ -105,6 +108,10 @@ export const APP_CONNECTION_MAP: Record< name: "Digital Ocean", image: "Digital Ocean.png" }, + [AppConnection.Netlify]: { + name: "Netlify", + image: "Netlify.png" + }, [AppConnection.Okta]: { name: "Okta", image: "Okta.png" } }; @@ -146,12 +153,14 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case MsSqlConnectionMethod.UsernameAndPassword: case MySqlConnectionMethod.UsernameAndPassword: case OracleDBConnectionMethod.UsernameAndPassword: + case AzureADCSConnectionMethod.UsernamePassword: return { name: "Username & Password", icon: faLock }; case HCVaultConnectionMethod.AccessToken: case TeamCityConnectionMethod.AccessToken: case AzureDevOpsConnectionMethod.AccessToken: case WindmillConnectionMethod.AccessToken: case FlyioConnectionMethod.AccessToken: + case NetlifyConnectionMethod.AccessToken: return { name: "Access Token", icon: faKey }; case Auth0ConnectionMethod.ClientCredentials: return { name: "Client Credentials", icon: faServer }; @@ -172,6 +181,9 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case ChecklyConnectionMethod.ApiKey: return { name: "API Key", icon: faKey }; case AzureClientSecretsConnectionMethod.ClientSecret: + case AzureAppConfigurationConnectionMethod.ClientSecret: + case AzureKeyVaultConnectionMethod.ClientSecret: + case AzureDevOpsConnectionMethod.ClientSecret: return { name: "Client Secret", icon: faKey }; default: throw new Error(`Unhandled App Connection Method: ${method}`); diff --git a/frontend/src/helpers/auditLogStreams.ts b/frontend/src/helpers/auditLogStreams.ts new file mode 100644 index 000000000..00d2b9e51 --- /dev/null +++ b/frontend/src/helpers/auditLogStreams.ts @@ -0,0 +1,32 @@ +import { faCode, IconDefinition } from "@fortawesome/free-solid-svg-icons"; + +import { LogProvider } from "@app/hooks/api/auditLogStreams/enums"; +import { TAuditLogStream } from "@app/hooks/api/types"; +import { DiscriminativePick } from "@app/types"; + +export const AUDIT_LOG_STREAM_PROVIDER_MAP: Record< + LogProvider, + { name: string; image?: string; icon?: IconDefinition; size?: number } +> = { + [LogProvider.Custom]: { name: "Custom", icon: faCode }, + [LogProvider.Datadog]: { name: "Datadog", image: "Datadog.png" }, + [LogProvider.Splunk]: { name: "Splunk", image: "Splunk.png", size: 65 } +}; + +// Strictly for showing to the client in the front-end +export function getProviderUrl( + logStream: DiscriminativePick +) { + switch (logStream.provider) { + case LogProvider.Custom: + return logStream.credentials.url; + case LogProvider.Datadog: + return logStream.credentials.url; + case LogProvider.Splunk: + return `https://${logStream.credentials.hostname}:8088/services/collector/event`; + default: + throw new Error( + `Unhandled provider in getProviderUrl: ${(logStream as TAuditLogStream).provider}` + ); + } +} diff --git a/frontend/src/helpers/datetime.ts b/frontend/src/helpers/datetime.ts index 6e0fb8e48..d8a5e90c6 100644 --- a/frontend/src/helpers/datetime.ts +++ b/frontend/src/helpers/datetime.ts @@ -22,3 +22,62 @@ export const formatDateTime = ({ } return format(date, dateFormat); }; + +// Helper function to convert seconds to value and unit +export const getObjectFromSeconds = ( + totalSeconds: number, + activeUnits?: Array<"s" | "m" | "h" | "d" | "w" | "y"> +): { value: number; unit: "s" | "m" | "h" | "d" | "w" | "y" } => { + const SECONDS_IN_MINUTE = 60; + const SECONDS_IN_HOUR = SECONDS_IN_MINUTE * 60; + const SECONDS_IN_DAY = SECONDS_IN_HOUR * 24; + const SECONDS_IN_WEEK = SECONDS_IN_DAY * 7; + const SECONDS_IN_YEAR = SECONDS_IN_DAY * 365; + + const activeUnitsSet = activeUnits ? new Set(activeUnits) : null; + + const isUnitActive = (unit: "s" | "m" | "h" | "d" | "w" | "y"): boolean => { + return activeUnitsSet ? activeUnitsSet.has(unit) : true; + }; + + if ( + isUnitActive("y") && + totalSeconds >= SECONDS_IN_YEAR && + totalSeconds % SECONDS_IN_YEAR === 0 + ) { + return { value: totalSeconds / SECONDS_IN_YEAR, unit: "y" }; + } + + if ( + isUnitActive("w") && + totalSeconds >= SECONDS_IN_WEEK && + totalSeconds % SECONDS_IN_WEEK === 0 + ) { + return { value: totalSeconds / SECONDS_IN_WEEK, unit: "w" }; + } + + if (isUnitActive("d") && totalSeconds >= SECONDS_IN_DAY && totalSeconds % SECONDS_IN_DAY === 0) { + return { value: totalSeconds / SECONDS_IN_DAY, unit: "d" }; + } + + if ( + isUnitActive("h") && + totalSeconds >= SECONDS_IN_HOUR && + totalSeconds % SECONDS_IN_HOUR === 0 + ) { + return { value: totalSeconds / SECONDS_IN_HOUR, unit: "h" }; + } + + if ( + isUnitActive("m") && + totalSeconds >= SECONDS_IN_MINUTE && + totalSeconds % SECONDS_IN_MINUTE === 0 + ) { + return { value: totalSeconds / SECONDS_IN_MINUTE, unit: "m" }; + } + + return { + value: totalSeconds, + unit: "s" + }; +}; diff --git a/frontend/src/helpers/download.ts b/frontend/src/helpers/download.ts index 50adf4fe2..d1659a956 100644 --- a/frontend/src/helpers/download.ts +++ b/frontend/src/helpers/download.ts @@ -4,3 +4,15 @@ export const downloadTxtFile = (filename: string, content: string) => { const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); FileSaver.saveAs(blob, filename); }; + +export const downloadFile = (content: string, filename: string, mimeType: string = "text/csv") => { + const blob = new Blob([content], { type: mimeType }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); +}; 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/project.ts b/frontend/src/helpers/project.ts index 3b04b263d..7c9185556 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -1,6 +1,6 @@ import { apiRequest } from "@app/config/request"; import { createWorkspace } from "@app/hooks/api/workspace/queries"; -import { ProjectType } from "@app/hooks/api/workspace/types"; +import { ProjectType, WorkspaceEnv } from "@app/hooks/api/workspace/types"; const secretsToBeAdded = [ { @@ -72,12 +72,14 @@ export const getProjectBaseURL = (type: ProjectType) => { } }; -export const getProjectHomePage = (type: ProjectType) => { +// @ts-expect-error akhilmhdh: will remove this later +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const getProjectHomePage = (type: ProjectType, environments: WorkspaceEnv[]) => { switch (type) { case ProjectType.SecretManager: - return "/projects/secret-management/$projectId/overview"; + return "/projects/secret-management/$projectId/overview" as const; case ProjectType.CertificateManager: - return "/projects/cert-management/$projectId/subscribers"; + return "/projects/cert-management/$projectId/subscribers" as const; case ProjectType.SecretScanning: return `/projects/${type}/$projectId/data-sources` as const; default: diff --git a/frontend/src/helpers/secretScanningV2.ts b/frontend/src/helpers/secretScanningV2.ts index 8021142b5..4e88b8482 100644 --- a/frontend/src/helpers/secretScanningV2.ts +++ b/frontend/src/helpers/secretScanningV2.ts @@ -24,6 +24,11 @@ export const SECRET_SCANNING_DATA_SOURCE_MAP: Record< name: "Bitbucket", image: "Bitbucket.png", size: 45 + }, + [SecretScanningDataSource.GitLab]: { + name: "GitLab", + image: "GitLab.png", + size: 45 } }; @@ -32,7 +37,8 @@ export const SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP: Record< AppConnection > = { [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar, - [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket + [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket, + [SecretScanningDataSource.GitLab]: AppConnection.GitLab }; export const RESOURCE_DESCRIPTION_HELPER: Record< @@ -58,6 +64,13 @@ export const RESOURCE_DESCRIPTION_HELPER: Record< singularNoun: "repository", pluralTitle: "Repositories", singularTitle: "Repository" + }, + [SecretScanningDataSource.GitLab]: { + verb: "push", + pluralNoun: "projects", + singularNoun: "project", + pluralTitle: "Projects", + singularTitle: "Project" } }; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index b4c0df352..6ce82cfb2 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -106,6 +106,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Heroku]: AppConnection.Heroku, [SecretSync.Render]: AppConnection.Render, [SecretSync.Flyio]: AppConnection.Flyio, - [SecretSync.GitLab]: AppConnection.Gitlab, + [SecretSync.GitLab]: AppConnection.GitLab, [SecretSync.CloudflarePages]: AppConnection.Cloudflare, [SecretSync.CloudflareWorkers]: AppConnection.Cloudflare, [SecretSync.Supabase]: AppConnection.Supabase, @@ -141,6 +145,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Railway]: AppConnection.Railway, [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, + [SecretSync.Netlify]: AppConnection.Netlify, [SecretSync.Bitbucket]: AppConnection.Bitbucket }; @@ -207,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/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index 916067f00..918a0ecd5 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { Organization } from "@app/hooks/api/organization/types"; import { organizationKeys } from "../organization/queries"; import { User } from "../users/types"; @@ -8,9 +9,12 @@ import { adminQueryKeys, adminStandaloneKeys } from "./queries"; import { RootKeyEncryptionStrategy, TCreateAdminUserDTO, + TCreateOrganizationDTO, TInvalidateCacheDTO, + TResendOrgInviteDTO, TServerConfig, - TUpdateServerConfigDTO + TUpdateServerConfigDTO, + TUsageReportResponse } from "./types"; export const useCreateAdminUser = () => { @@ -68,6 +72,25 @@ export const useAdminDeleteUser = () => { }); }; +export const useAdminBulkDeleteUsers = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (userIds: string[]) => { + await apiRequest.delete("/api/v1/admin/user-management/users", { + data: { userIds } + }); + + return {}; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [adminStandaloneKeys.getUsers] + }); + queryClient.invalidateQueries({ queryKey: adminStandaloneKeys.getOrganizations }); + } + }); +}; + export const useAdminDeleteOrganizationMembership = () => { const queryClient = useQueryClient(); return useMutation({ @@ -174,3 +197,58 @@ export const useInvalidateCache = () => { } }); }; + +export const useServerAdminCreateOrganization = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (opt: TCreateOrganizationDTO) => { + const { data } = await apiRequest.post<{ organization: Organization }>( + "/api/v1/admin/organization-management/organizations", + opt + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: adminQueryKeys.getOrganizations() }); + } + }); +}; + +export const useServerAdminResendOrgInvite = () => { + return useMutation({ + mutationFn: async ({ organizationId, membershipId }: TResendOrgInviteDTO) => { + await apiRequest.post( + `/api/v1/admin/organization-management/organizations/${organizationId}/memberships/${membershipId}/resend-invite` + ); + } + }); +}; + +export const useServerAdminAccessOrg = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (orgId: string) => { + const { data } = await apiRequest.post( + `/api/v1/admin/organization-management/organizations/${orgId}/access` + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: organizationKeys.getUserOrganizations }); + queryClient.invalidateQueries({ queryKey: adminQueryKeys.getOrganizations() }); + } + }); +}; + +export const useGenerateUsageReport = () => { + return useMutation({ + mutationFn: async () => { + const { data } = await apiRequest.post( + "/api/v1/admin/usage-report/generate" + ); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index 871c7288c..4b10ba3ce 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -1,4 +1,11 @@ -import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { + DefaultError, + InfiniteData, + UndefinedInitialDataInfiniteOptions, + useInfiniteQuery, + useQuery, + UseQueryOptions +} from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { Identity } from "@app/hooks/api/identities/types"; @@ -25,8 +32,8 @@ export const adminStandaloneKeys = { export const adminQueryKeys = { serverConfig: () => ["server-config"] as const, getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const, - getOrganizations: (filters: AdminGetOrganizationsFilters) => - [adminStandaloneKeys.getOrganizations, { filters }] as const, + getOrganizations: (filters?: AdminGetOrganizationsFilters) => + [adminStandaloneKeys.getOrganizations, ...(filters ? [{ filters }] : [])] as const, getIdentities: (filters: AdminGetIdentitiesFilters) => [adminStandaloneKeys.getIdentities, { filters }] as const, getAdminSlackConfig: () => ["admin-slack-config"] as const, @@ -83,7 +90,18 @@ export const useGetServerConfig = ({ enabled: options?.enabled ?? true }); -export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { +export const useAdminGetUsers = ( + filters: AdminGetUsersFilters, + options?: Partial< + UndefinedInitialDataInfiniteOptions< + User[], + DefaultError, + InfiniteData, + ReturnType, + number + > + > +) => { return useInfiniteQuery({ initialPageParam: 0, queryKey: adminQueryKeys.getUsers(filters), @@ -101,7 +119,8 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { return data.users; }, getNextPageParam: (lastPage, pages) => - lastPage.length !== 0 ? pages.length * filters.limit : undefined + lastPage.length !== 0 ? pages.length * filters.limit : undefined, + ...options }); }; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 51ff5a908..f8336fcac 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -1,3 +1,5 @@ +import { OrgMembershipStatus } from "@app/hooks/api/organization/types"; + import { Organization } from "../types"; export enum LoginMethod { @@ -20,6 +22,7 @@ export type OrganizationWithProjects = Organization & { lastName: string | null; }; membershipId: string; + status: OrgMembershipStatus; role: string; roleId: string | null; }[]; @@ -35,6 +38,7 @@ export type TServerConfig = { initialized: boolean; allowSignUp: boolean; allowedSignUpDomain?: string | null; + disableAuditLogStorage: boolean; isMigrationModeOn?: boolean; trustSamlEmails: boolean; trustLdapEmails: boolean; @@ -52,6 +56,7 @@ export type TServerConfig = { fipsEnabled: boolean; envOverrides?: Record; paramsFolderSecretDetectionEnabled: boolean; + isOfflineUsageReportsEnabled: boolean; }; export type TUpdateServerConfigDTO = { @@ -73,15 +78,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 = { @@ -150,3 +146,19 @@ export interface TGetEnvOverrides { fields: { key: string; value: string; hasEnvEntry: boolean; description?: string }[]; }; } + +export type TUsageReportResponse = { + filename: string; + csvContent: string; + signature: string; +}; + +export type TCreateOrganizationDTO = { + name: string; + inviteAdminEmails: string[]; +}; + +export type TResendOrgInviteDTO = { + organizationId: string; + membershipId: string; +}; diff --git a/frontend/src/hooks/api/appConnections/checkly/queries.tsx b/frontend/src/hooks/api/appConnections/checkly/queries.tsx index 452c21245..95156aaa4 100644 --- a/frontend/src/hooks/api/appConnections/checkly/queries.tsx +++ b/frontend/src/hooks/api/appConnections/checkly/queries.tsx @@ -8,7 +8,9 @@ import { TChecklyAccount } from "./types"; const checklyConnectionKeys = { all: [...appConnectionKeys.all, "checkly"] as const, listAccounts: (connectionId: string) => - [...checklyConnectionKeys.all, "workspace-scopes", connectionId] as const + [...checklyConnectionKeys.all, "workspace-scopes", connectionId] as const, + listGroups: (connectionId: string, accountId: string) => + [...checklyConnectionKeys.all, "groups", connectionId, accountId] as const }; export const useChecklyConnectionListAccounts = ( @@ -35,3 +37,29 @@ export const useChecklyConnectionListAccounts = ( ...options }); }; + +export const useChecklyConnectionListGroups = ( + connectionId: string, + accountId: string, + options?: Omit< + UseQueryOptions< + TChecklyAccount[], + unknown, + TChecklyAccount[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: checklyConnectionKeys.listGroups(connectionId, accountId), + queryFn: async () => { + const { data } = await apiRequest.get<{ groups: TChecklyAccount[] }>( + `/api/v1/app-connections/checkly/${connectionId}/accounts/${accountId}/groups` + ); + + return data.groups; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx b/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx index f4ca87ad1..973943d1e 100644 --- a/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx +++ b/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx @@ -3,14 +3,16 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "../queries"; -import { TCloudflarePagesProject, TCloudflareWorkersScript } from "./types"; +import { TCloudflarePagesProject, TCloudflareWorkersScript, TCloudflareZone } from "./types"; const cloudflareConnectionKeys = { all: [...appConnectionKeys.all, "cloudflare"] as const, listPagesProjects: (connectionId: string) => [...cloudflareConnectionKeys.all, "pages-projects", connectionId] as const, listWorkersScripts: (connectionId: string) => - [...cloudflareConnectionKeys.all, "workers-scripts", connectionId] as const + [...cloudflareConnectionKeys.all, "workers-scripts", connectionId] as const, + listZones: (connectionId: string) => + [...cloudflareConnectionKeys.all, "zones", connectionId] as const }; export const useCloudflareConnectionListPagesProjects = ( @@ -62,3 +64,28 @@ export const useCloudflareConnectionListWorkersScripts = ( ...options }); }; + +export const useCloudflareConnectionListZones = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TCloudflareZone[], + unknown, + TCloudflareZone[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: cloudflareConnectionKeys.listZones(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/cloudflare/${connectionId}/cloudflare-zones` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/cloudflare/types.ts b/frontend/src/hooks/api/appConnections/cloudflare/types.ts index 1d5ea53d4..e8905e16c 100644 --- a/frontend/src/hooks/api/appConnections/cloudflare/types.ts +++ b/frontend/src/hooks/api/appConnections/cloudflare/types.ts @@ -6,3 +6,8 @@ export type TCloudflarePagesProject = { export type TCloudflareWorkersScript = { id: string; }; + +export type TCloudflareZone = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index c3a1fec05..7b041b797 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -7,6 +7,7 @@ export enum AppConnection { AzureAppConfiguration = "azure-app-configuration", AzureClientSecrets = "azure-client-secrets", AzureDevOps = "azure-devops", + AzureADCS = "azure-adcs", Databricks = "databricks", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", @@ -26,7 +27,7 @@ export enum AppConnection { Heroku = "heroku", Render = "render", Flyio = "flyio", - Gitlab = "gitlab", + GitLab = "gitlab", Cloudflare = "cloudflare", Bitbucket = "bitbucket", Zabbix = "zabbix", @@ -34,5 +35,6 @@ export enum AppConnection { Checkly = "checkly", Supabase = "supabase", DigitalOcean = "digital-ocean", + Netlify = "netlify", Okta = "okta" } diff --git a/frontend/src/hooks/api/appConnections/gitlab/queries.tsx b/frontend/src/hooks/api/appConnections/gitlab/queries.tsx index 155d56a23..baa9deecf 100644 --- a/frontend/src/hooks/api/appConnections/gitlab/queries.tsx +++ b/frontend/src/hooks/api/appConnections/gitlab/queries.tsx @@ -13,7 +13,7 @@ const gitlabConnectionKeys = { [...gitlabConnectionKeys.all, "groups", connectionId] as const }; -export const useGitlabConnectionListProjects = ( +export const useGitLabConnectionListProjects = ( connectionId: string, options?: Omit< UseQueryOptions< @@ -38,7 +38,7 @@ export const useGitlabConnectionListProjects = ( }); }; -export const useGitlabConnectionListGroups = ( +export const useGitLabConnectionListGroups = ( connectionId: string, options?: Omit< UseQueryOptions< diff --git a/frontend/src/hooks/api/appConnections/netlify/index.ts b/frontend/src/hooks/api/appConnections/netlify/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/netlify/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/netlify/queries.tsx b/frontend/src/hooks/api/appConnections/netlify/queries.tsx new file mode 100644 index 000000000..d196a69b5 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/netlify/queries.tsx @@ -0,0 +1,65 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TNetlifyAccount, TNetlifySite } from "./types"; + +const netlifyConnectionKeys = { + all: [...appConnectionKeys.all, "netlify"] as const, + listAccounts: (connectionId: string) => + [...netlifyConnectionKeys.all, "accounts", connectionId] as const, + listSites: (connectionId: string, accountId: string) => + [...netlifyConnectionKeys.all, "sites", connectionId, accountId] as const +}; + +export const useNetlifyConnectionListAccounts = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TNetlifyAccount[], + unknown, + TNetlifyAccount[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: netlifyConnectionKeys.listAccounts(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ accounts: TNetlifyAccount[] }>( + `/api/v1/app-connections/netlify/${connectionId}/accounts` + ); + + return data.accounts; + }, + ...options + }); +}; + +export const useNetlifyConnectionListSites = ( + connectionId: string, + accountId: string, + options?: Omit< + UseQueryOptions< + TNetlifySite[], + unknown, + TNetlifySite[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: netlifyConnectionKeys.listSites(connectionId, accountId), + queryFn: async () => { + const { data } = await apiRequest.get<{ sites: TNetlifySite[] }>( + `/api/v1/app-connections/netlify/${connectionId}/accounts/${accountId}/sites` + ); + + return data.sites; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/netlify/types.ts b/frontend/src/hooks/api/appConnections/netlify/types.ts new file mode 100644 index 000000000..aa514e41b --- /dev/null +++ b/frontend/src/hooks/api/appConnections/netlify/types.ts @@ -0,0 +1,9 @@ +export type TNetlifyAccount = { + id: string; + name: string; +}; + +export type TNetlifySite = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/queries.tsx b/frontend/src/hooks/api/appConnections/queries.tsx index 0b55cb79b..dbccade88 100644 --- a/frontend/src/hooks/api/appConnections/queries.tsx +++ b/frontend/src/hooks/api/appConnections/queries.tsx @@ -54,13 +54,13 @@ export const useAppConnectionOptions = ( export const useGetAppConnectionOption = (app: T) => { const { data: options = [], isPending } = useAppConnectionOptions(); - return useMemo( - () => ({ - option: (options.find((opt) => opt.app === app) as TAppConnectionOptionMap[T]) ?? {}, + return useMemo(() => { + const foundOption = options.find((opt) => opt.app === app); + return { + option: (foundOption as TAppConnectionOptionMap[T]) ?? {}, isLoading: isPending - }), - [options, app, isPending] - ); + }; + }, [options, app, isPending]); }; export const useListAppConnections = ( 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/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 7dc5907e3..67d8feb48 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -32,7 +32,7 @@ export type TAzureKeyVaultConnectionOption = TAppConnectionOptionBase & { }; export type TAzureAppConfigurationConnectionOption = TAppConnectionOptionBase & { - app: AppConnection.AzureKeyVault; + app: AppConnection.AzureAppConfiguration; oauthClientId?: string; }; @@ -124,7 +124,7 @@ export type TFlyioConnectionOption = TAppConnectionOptionBase & { }; export type TGitlabConnectionOption = TAppConnectionOptionBase & { - app: AppConnection.Gitlab; + app: AppConnection.GitLab; oauthClientId?: string; }; @@ -156,13 +156,22 @@ export type TDigitalOceanConnectionOption = TAppConnectionOptionBase & { app: AppConnection.DigitalOcean; }; +export type TNetlifyConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Netlify; +}; + export type TOktaConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Okta; }; +export type TAzureAdCsConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.AzureADCS; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption + | TGitHubRadarConnectionOption | TGcpConnectionOption | TAzureAppConfigurationConnectionOption | TAzureKeyVaultConnectionOption @@ -180,6 +189,7 @@ export type TAppConnectionOption = | TWindmillConnectionOption | TAuth0ConnectionOption | THCVaultConnectionOption + | TLdapConnectionOption | TTeamCityConnectionOption | TOCIConnectionOption | TOnePassConnectionOption @@ -192,8 +202,11 @@ export type TAppConnectionOption = | TZabbixConnectionOption | TRailwayConnectionOption | TChecklyConnectionOption + | TSupabaseConnectionOption | TDigitalOceanConnectionOption - | TOktaConnectionOption; + | TNetlifyConnectionOption + | TOktaConnectionOption + | TAzureAdCsConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -223,7 +236,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Heroku]: THerokuConnectionOption; [AppConnection.Render]: TRenderConnectionOption; [AppConnection.Flyio]: TFlyioConnectionOption; - [AppConnection.Gitlab]: TGitlabConnectionOption; + [AppConnection.GitLab]: TGitlabConnectionOption; [AppConnection.Cloudflare]: TCloudflareConnectionOption; [AppConnection.Bitbucket]: TBitbucketConnectionOption; [AppConnection.Zabbix]: TZabbixConnectionOption; @@ -231,5 +244,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Checkly]: TChecklyConnectionOption; [AppConnection.Supabase]: TSupabaseConnectionOption; [AppConnection.DigitalOcean]: TDigitalOceanConnectionOption; + [AppConnection.Netlify]: TNetlifyConnectionOption; [AppConnection.Okta]: TOktaConnectionOption; + [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/azure-adcs-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-adcs-connection.ts new file mode 100644 index 000000000..2da9d56b5 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/azure-adcs-connection.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +import { AppConnection } from "../enums"; +import { TRootAppConnection } from "./root-connection"; + +export enum AzureADCSConnectionMethod { + UsernamePassword = "username-password" +} + +export const CreateAzureADCSConnectionSchema = z.object({ + adcsUrl: z.string().url().min(1, "ADCS URL is required"), + username: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required") +}); + +export type TCreateAzureADCSConnection = z.infer; + +export type TAzureADCSConnection = TRootAppConnection & { app: AppConnection.AzureADCS } & { + method: AzureADCSConnectionMethod.UsernamePassword; + credentials: TCreateAzureADCSConnection; +}; diff --git a/frontend/src/hooks/api/appConnections/types/azure-app-configuration-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-app-configuration-connection.ts index b56003cad..092822da2 100644 --- a/frontend/src/hooks/api/appConnections/types/azure-app-configuration-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/azure-app-configuration-connection.ts @@ -2,15 +2,26 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; export enum AzureAppConfigurationConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } export type TAzureAppConfigurationConnection = TRootAppConnection & { app: AppConnection.AzureAppConfiguration; -} & { - method: AzureAppConfigurationConnectionMethod.OAuth; - credentials: { - code: string; - tenantId?: string; - }; -}; +} & ( + | { + method: AzureAppConfigurationConnectionMethod.OAuth; + credentials: { + code: string; + tenantId?: string; + }; + } + | { + method: AzureAppConfigurationConnectionMethod.ClientSecret; + credentials: { + clientId: string; + clientSecret: string; + tenantId: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts index ef30936e6..9334ac06a 100644 --- a/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts @@ -3,7 +3,8 @@ import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-con export enum AzureDevOpsConnectionMethod { OAuth = "oauth", - AccessToken = "access-token" + AccessToken = "access-token", + ClientSecret = "client-secret" } export type TAzureDevOpsConnection = TRootAppConnection & { @@ -24,4 +25,13 @@ export type TAzureDevOpsConnection = TRootAppConnection & { orgName: string; }; } + | { + method: AzureDevOpsConnectionMethod.ClientSecret; + credentials: { + clientSecret: string; + tenantId: string; + clientId: string; + orgName: string; + }; + } ); diff --git a/frontend/src/hooks/api/appConnections/types/azure-key-vault-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-key-vault-connection.ts index 68888531a..5cea148fa 100644 --- a/frontend/src/hooks/api/appConnections/types/azure-key-vault-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/azure-key-vault-connection.ts @@ -2,13 +2,24 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; export enum AzureKeyVaultConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } -export type TAzureKeyVaultConnection = TRootAppConnection & { app: AppConnection.AzureKeyVault } & { - method: AzureKeyVaultConnectionMethod.OAuth; - credentials: { - code: string; - tenantId?: string; - }; -}; +export type TAzureKeyVaultConnection = TRootAppConnection & { app: AppConnection.AzureKeyVault } & ( + | { + method: AzureKeyVaultConnectionMethod.OAuth; + credentials: { + code: string; + tenantId?: string; + }; + } + | { + method: AzureKeyVaultConnectionMethod.ClientSecret; + credentials: { + clientId: string; + clientSecret: string; + tenantId: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/github-connection.ts b/frontend/src/hooks/api/appConnections/types/github-connection.ts index d00936cda..27bed2dcb 100644 --- a/frontend/src/hooks/api/appConnections/types/github-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/github-connection.ts @@ -11,6 +11,8 @@ export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub method: GitHubConnectionMethod.OAuth; credentials: { code: string; + instanceType?: "cloud" | "server"; + host?: string; }; } | { @@ -18,6 +20,8 @@ export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub credentials: { code: string; installationId: string; + instanceType?: "cloud" | "server"; + host?: string; }; } ); diff --git a/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts b/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts index 667475126..d4f215dd7 100644 --- a/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts @@ -8,7 +8,7 @@ export enum GitLabConnectionMethod { OAuth = "oauth" } -export type TGitLabConnection = TRootAppConnection & { app: AppConnection.Gitlab } & ( +export type TGitLabConnection = TRootAppConnection & { app: AppConnection.GitLab } & ( | { method: GitLabConnectionMethod.AccessToken; credentials: { diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 2924c3009..e63979a8e 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -3,6 +3,7 @@ import { TOnePassConnection } from "./1password-connection"; import { TAppConnectionOption } from "./app-options"; import { TAuth0Connection } from "./auth0-connection"; import { TAwsConnection } from "./aws-connection"; +import { TAzureADCSConnection } from "./azure-adcs-connection"; import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection"; import { TAzureClientSecretsConnection } from "./azure-client-secrets-connection"; import { TAzureDevOpsConnection } from "./azure-devops-connection"; @@ -24,6 +25,7 @@ import { THumanitecConnection } from "./humanitec-connection"; import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TMySqlConnection } from "./mysql-connection"; +import { TNetlifyConnection } from "./netlify-connection"; import { TOCIConnection } from "./oci-connection"; import { TOktaConnection } from "./okta-connection"; import { TOracleDBConnection } from "./oracledb-connection"; @@ -40,6 +42,7 @@ import { TZabbixConnection } from "./zabbix-connection"; export * from "./1password-connection"; export * from "./auth0-connection"; export * from "./aws-connection"; +export * from "./azure-adcs-connection"; export * from "./azure-app-configuration-connection"; export * from "./azure-client-secrets-connection"; export * from "./azure-devops-connection"; @@ -82,6 +85,7 @@ export type TAppConnection = | TAzureAppConfigurationConnection | TAzureClientSecretsConnection | TAzureDevOpsConnection + | TAzureADCSConnection | TDatabricksConnection | THumanitecConnection | TTerraformCloudConnection @@ -109,6 +113,7 @@ export type TAppConnection = | TChecklyConnection | TSupabaseConnection | TDigitalOceanConnection + | TNetlifyConnection | TOktaConnection; export type TAvailableAppConnection = Pick; @@ -154,6 +159,7 @@ export type TAppConnectionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; [AppConnection.AzureClientSecrets]: TAzureClientSecretsConnection; [AppConnection.AzureDevOps]: TAzureDevOpsConnection; + [AppConnection.AzureADCS]: TAzureADCSConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; [AppConnection.TerraformCloud]: TTerraformCloudConnection; @@ -173,7 +179,7 @@ export type TAppConnectionMap = { [AppConnection.Heroku]: THerokuConnection; [AppConnection.Render]: TRenderConnection; [AppConnection.Flyio]: TFlyioConnection; - [AppConnection.Gitlab]: TGitLabConnection; + [AppConnection.GitLab]: TGitLabConnection; [AppConnection.Cloudflare]: TCloudflareConnection; [AppConnection.Bitbucket]: TBitbucketConnection; [AppConnection.Zabbix]: TZabbixConnection; @@ -181,5 +187,6 @@ export type TAppConnectionMap = { [AppConnection.Checkly]: TChecklyConnection; [AppConnection.Supabase]: TSupabaseConnection; [AppConnection.DigitalOcean]: TDigitalOceanConnection; + [AppConnection.Netlify]: TNetlifyConnection; [AppConnection.Okta]: TOktaConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/netlify-connection.ts b/frontend/src/hooks/api/appConnections/types/netlify-connection.ts new file mode 100644 index 000000000..567664d96 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/netlify-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum NetlifyConnectionMethod { + AccessToken = "access-token" +} + +export type TNetlifyConnection = TRootAppConnection & { + app: AppConnection.Netlify; + method: NetlifyConnectionMethod.AccessToken; + credentials: { + accessToken: string; + }; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/enums.ts b/frontend/src/hooks/api/auditLogStreams/enums.ts new file mode 100644 index 000000000..9c03dc2bd --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/enums.ts @@ -0,0 +1,5 @@ +export enum LogProvider { + Datadog = "datadog", + Splunk = "splunk", + Custom = "custom" +} diff --git a/frontend/src/hooks/api/auditLogStreams/index.tsx b/frontend/src/hooks/api/auditLogStreams/index.tsx index 72b1fba1a..0c2adeab0 100644 --- a/frontend/src/hooks/api/auditLogStreams/index.tsx +++ b/frontend/src/hooks/api/auditLogStreams/index.tsx @@ -1,6 +1,2 @@ -export { - useCreateAuditLogStream, - useDeleteAuditLogStream, - useUpdateAuditLogStream -} from "./mutations"; -export { useGetAuditLogStreamDetails, useGetAuditLogStreams } from "./queries"; +export * from "./mutations"; +export * from "./queries"; diff --git a/frontend/src/hooks/api/auditLogStreams/mutations.tsx b/frontend/src/hooks/api/auditLogStreams/mutations.tsx index 1ed93f95b..24253ca63 100644 --- a/frontend/src/hooks/api/auditLogStreams/mutations.tsx +++ b/frontend/src/hooks/api/auditLogStreams/mutations.tsx @@ -12,50 +12,54 @@ import { export const useCreateAuditLogStream = () => { const queryClient = useQueryClient(); - - return useMutation<{ auditLogStream: TAuditLogStream }, object, TCreateAuditLogStreamDTO>({ - mutationFn: async (dto) => { + return useMutation({ + mutationFn: async ({ provider, ...params }: TCreateAuditLogStreamDTO) => { const { data } = await apiRequest.post<{ auditLogStream: TAuditLogStream }>( - "/api/v1/audit-log-streams", - dto + `/api/v1/audit-log-streams/${provider}`, + params ); - return data; + + return data.auditLogStream; }, - onSuccess: (_, { orgId }) => { - queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list(orgId) }); - } + onSuccess: () => queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list() }) }); }; export const useUpdateAuditLogStream = () => { const queryClient = useQueryClient(); - - return useMutation<{ auditLogStream: TAuditLogStream }, object, TUpdateAuditLogStreamDTO>({ - mutationFn: async (dto) => { + return useMutation({ + mutationFn: async ({ auditLogStreamId, provider, ...params }: TUpdateAuditLogStreamDTO) => { const { data } = await apiRequest.patch<{ auditLogStream: TAuditLogStream }>( - `/api/v1/audit-log-streams/${dto.id}`, - dto + `/api/v1/audit-log-streams/${provider}/${auditLogStreamId}`, + params ); - return data; + + return data.auditLogStream; }, - onSuccess: (_, { orgId }) => { - queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list(orgId) }); + onSuccess: (_, { auditLogStreamId, provider }) => { + queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list() }); + queryClient.invalidateQueries({ + queryKey: auditLogStreamKeys.getById(provider, auditLogStreamId) + }); } }); }; export const useDeleteAuditLogStream = () => { const queryClient = useQueryClient(); - - return useMutation<{ auditLogStream: TAuditLogStream }, object, TDeleteAuditLogStreamDTO>({ - mutationFn: async (dto) => { + return useMutation({ + mutationFn: async ({ auditLogStreamId, provider }: TDeleteAuditLogStreamDTO) => { const { data } = await apiRequest.delete<{ auditLogStream: TAuditLogStream }>( - `/api/v1/audit-log-streams/${dto.id}` + `/api/v1/audit-log-streams/${provider}/${auditLogStreamId}` ); - return data; + + return data.auditLogStream; }, - onSuccess: (_, { orgId }) => { - queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list(orgId) }); + onSuccess: (_, { auditLogStreamId, provider }) => { + queryClient.invalidateQueries({ queryKey: auditLogStreamKeys.list() }); + queryClient.invalidateQueries({ + queryKey: auditLogStreamKeys.getById(provider, auditLogStreamId) + }); } }); }; diff --git a/frontend/src/hooks/api/auditLogStreams/queries.tsx b/frontend/src/hooks/api/auditLogStreams/queries.tsx index ff7d8b499..97d7d6267 100644 --- a/frontend/src/hooks/api/auditLogStreams/queries.tsx +++ b/frontend/src/hooks/api/auditLogStreams/queries.tsx @@ -1,40 +1,89 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TAuditLogStream } from "./types"; +import { TAuditLogStreamProviderOption } from "./types/provider-options"; +import { LogProvider } from "./enums"; +import { TAuditLogStream, TAuditLogStreamProviderMap } from "./types"; export const auditLogStreamKeys = { - list: (orgId: string) => ["audit-log-stream", { orgId }], - getById: (id: string) => ["audit-log-stream-details", { id }] + all: ["audit-log-stream"] as const, + options: () => [...auditLogStreamKeys.all, "options"] as const, + list: () => [...auditLogStreamKeys.all, "list"] as const, + getById: (provider: string, id: string) => + [...auditLogStreamKeys.all, provider, "get-by-id", id] as const }; -const fetchAuditLogStreams = async () => { - const { data } = await apiRequest.get<{ auditLogStreams: TAuditLogStream[] }>( - "/api/v1/audit-log-streams" - ); +export const useGetAuditLogStreamOptions = ( + options?: Omit< + UseQueryOptions< + TAuditLogStreamProviderOption[], + unknown, + TAuditLogStreamProviderOption[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: auditLogStreamKeys.options(), + queryFn: async () => { + const { data } = await apiRequest.get<{ providerOptions: TAuditLogStreamProviderOption[] }>( + "/api/v1/audit-log-streams/options" + ); - return data.auditLogStreams; -}; - -export const useGetAuditLogStreams = (orgId: string) => - useQuery({ - queryKey: auditLogStreamKeys.list(orgId), - queryFn: () => fetchAuditLogStreams(), - enabled: Boolean(orgId) + return data.providerOptions; + }, + ...options }); - -const fetchAuditLogStreamDetails = async (id: string) => { - const { data } = await apiRequest.get<{ auditLogStream: TAuditLogStream }>( - `/api/v1/audit-log-streams/${id}` - ); - - return data.auditLogStream; }; -export const useGetAuditLogStreamDetails = (id: string) => - useQuery({ - queryKey: auditLogStreamKeys.getById(id), - queryFn: () => fetchAuditLogStreamDetails(id), - enabled: Boolean(id) +export const useListAuditLogStreams = ( + options?: Omit< + UseQueryOptions< + TAuditLogStream[], + unknown, + TAuditLogStream[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: auditLogStreamKeys.list(), + queryFn: async () => { + const { data } = await apiRequest.get<{ auditLogStreams: TAuditLogStream[] }>( + "/api/v1/audit-log-streams" + ); + + return data.auditLogStreams; + }, + ...options }); +}; + +export const useGetAuditLogStreamById = ( + provider: T, + logStreamId: string, + options?: Omit< + UseQueryOptions< + TAuditLogStreamProviderMap[T], + unknown, + TAuditLogStreamProviderMap[T], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: auditLogStreamKeys.getById(provider, logStreamId), + queryFn: async () => { + const { data } = await apiRequest.get<{ auditLogStream: TAuditLogStreamProviderMap[T] }>( + `/api/v1/audit-log-streams/${provider}/${logStreamId}` + ); + + return data.auditLogStream; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types.ts b/frontend/src/hooks/api/auditLogStreams/types.ts deleted file mode 100644 index 8e21a3209..000000000 --- a/frontend/src/hooks/api/auditLogStreams/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type LogStreamHeaders = { - key: string; - value: string; -}; - -export type TAuditLogStream = { - id: string; - url: string; - headers?: LogStreamHeaders[]; -}; - -export type TCreateAuditLogStreamDTO = { - url: string; - headers?: LogStreamHeaders[]; - orgId: string; -}; - -export type TUpdateAuditLogStreamDTO = { - id: string; - url?: string; - headers?: LogStreamHeaders[]; - orgId: string; -}; - -export type TDeleteAuditLogStreamDTO = { - id: string; - orgId: string; -}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/index.ts b/frontend/src/hooks/api/auditLogStreams/types/index.ts new file mode 100644 index 000000000..d3dffe6a9 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/index.ts @@ -0,0 +1,25 @@ +import { LogProvider } from "../enums"; +import { TCustomProviderLogStream } from "./providers/custom-provider"; +import { TDatadogProviderLogStream } from "./providers/datadog-provider"; +import { TSplunkProviderLogStream } from "./providers/splunk-provider"; + +export type TAuditLogStream = + | TCustomProviderLogStream + | TDatadogProviderLogStream + | TSplunkProviderLogStream; + +export type TAuditLogStreamProviderMap = { + [LogProvider.Custom]: TCustomProviderLogStream; + [LogProvider.Datadog]: TDatadogProviderLogStream; + [LogProvider.Splunk]: TSplunkProviderLogStream; +}; + +export type TCreateAuditLogStreamDTO = Pick; +export type TUpdateAuditLogStreamDTO = Pick & { + provider: LogProvider; + auditLogStreamId: string; +}; +export type TDeleteAuditLogStreamDTO = { + provider: LogProvider; + auditLogStreamId: string; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/provider-options.ts b/frontend/src/hooks/api/auditLogStreams/types/provider-options.ts new file mode 100644 index 000000000..e9b06dfef --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/provider-options.ts @@ -0,0 +1,11 @@ +import { LogProvider } from "../enums"; + +export type TAuditLogStreamProviderOptionBase = { + name: string; +}; + +export type TAuditLogStreamProviderOption = { + [P in keyof typeof LogProvider]: TAuditLogStreamProviderOptionBase & { + provider: (typeof LogProvider)[P]; + }; +}[keyof typeof LogProvider]; diff --git a/frontend/src/hooks/api/auditLogStreams/types/providers/custom-provider.ts b/frontend/src/hooks/api/auditLogStreams/types/providers/custom-provider.ts new file mode 100644 index 000000000..83ff9e010 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/providers/custom-provider.ts @@ -0,0 +1,10 @@ +import { LogProvider } from "../../enums"; +import { TRootProviderLogStream } from "./root-provider"; + +export type TCustomProviderLogStream = TRootProviderLogStream & { + provider: LogProvider.Custom; + credentials: { + url: string; + headers: { key: string; value: string }[]; + }; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/providers/datadog-provider.ts b/frontend/src/hooks/api/auditLogStreams/types/providers/datadog-provider.ts new file mode 100644 index 000000000..85198bb1a --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/providers/datadog-provider.ts @@ -0,0 +1,10 @@ +import { LogProvider } from "../../enums"; +import { TRootProviderLogStream } from "./root-provider"; + +export type TDatadogProviderLogStream = TRootProviderLogStream & { + provider: LogProvider.Datadog; + credentials: { + url: string; + token: string; + }; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/providers/root-provider.ts b/frontend/src/hooks/api/auditLogStreams/types/providers/root-provider.ts new file mode 100644 index 000000000..5ea3137e5 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/providers/root-provider.ts @@ -0,0 +1,6 @@ +export type TRootProviderLogStream = { + id: string; + orgId: string; + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/src/hooks/api/auditLogStreams/types/providers/splunk-provider.ts b/frontend/src/hooks/api/auditLogStreams/types/providers/splunk-provider.ts new file mode 100644 index 000000000..bf5c676ef --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types/providers/splunk-provider.ts @@ -0,0 +1,10 @@ +import { LogProvider } from "../../enums"; +import { TRootProviderLogStream } from "./root-provider"; + +export type TSplunkProviderLogStream = TRootProviderLogStream & { + provider: LogProvider.Splunk; + credentials: { + hostname: string; + token: string; + }; +}; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 696f5745f..186a8e539 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -40,6 +40,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.GET_IDENTITY_UNIVERSAL_AUTH]: "Get universal auth", [EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Create universal auth client secret", [EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret", + [EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS]: "Clear universal auth lockouts", [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets", [EventType.CREATE_ENVIRONMENT]: "Create environment", [EventType.UPDATE_ENVIRONMENT]: "Update environment", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 159a61808..4fe8948dd 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -46,6 +46,7 @@ export enum EventType { GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", + CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth", diff --git a/frontend/src/hooks/api/auditLogs/queries.tsx b/frontend/src/hooks/api/auditLogs/queries.tsx index b254b46d3..8cb438c92 100644 --- a/frontend/src/hooks/api/auditLogs/queries.tsx +++ b/frontend/src/hooks/api/auditLogs/queries.tsx @@ -1,8 +1,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { AxiosError } from "axios"; -import { createNotification } from "@app/components/notifications"; import { apiRequest } from "@app/config/request"; +import { onRequestError } from "@app/hooks/api/reactQuery"; import { TReactQueryOptions } from "@app/types/reactQuery"; import { Actor, AuditLog, TGetAuditLogsFilter } from "./types"; @@ -46,12 +45,7 @@ export const useGetAuditLogs = ( ); return data.auditLogs; } catch (error) { - if (error instanceof AxiosError) { - createNotification({ - type: "error", - text: error.response?.data.message - }); - } + onRequestError(error); return []; } }, diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 0b9e201fe..1ca819e60 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -326,6 +326,14 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } +interface ClearIdentityUniversalAuthLockoutsEvent { + type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS; + metadata: { + identityId: string; + clientSecretId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -892,6 +900,7 @@ export type Event = | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent | RevokeIdentityUniversalAuthClientSecretEvent + | ClearIdentityUniversalAuthLockoutsEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 796fd3152..cb7e3f64c 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -8,26 +8,24 @@ import { organizationKeys } from "../organization/queries"; import { setAuthToken } from "../reactQuery"; import { workspaceKeys } from "../workspace"; import { - ChangePasswordDTO, CompleteAccountDTO, CompleteAccountSignupDTO, GetAuthTokenAPI, GetBackupEncryptedPrivateKeyDTO, - IssueBackupPrivateKeyDTO, Login1DTO, Login1Res, Login2DTO, Login2Res, LoginLDAPDTO, LoginLDAPRes, + LoginV3DTO, + LoginV3Res, MfaMethod, ResetPasswordDTO, ResetPasswordV2DTO, ResetUserPasswordV2DTO, SendMfaTokenDTO, SetupPasswordDTO, - SRP1DTO, - SRPR1Res, TOauthTokenExchangeDTO, UserAgentType, UserEncryptionVersion, @@ -50,21 +48,14 @@ export const login2 = async (loginDetails: Login2DTO) => { return data; }; -export const loginLDAPRedirect = async (loginLDAPDetails: LoginLDAPDTO) => { - const { data } = await apiRequest.post("/api/v1/ldap/login", loginLDAPDetails); // return if account is complete or not + provider auth token +export const loginV3 = async (loginDetails: LoginV3DTO) => { + const { data } = await apiRequest.post("/api/v3/auth/login", loginDetails); return data; }; -export const useLogin1 = () => { - return useMutation({ - mutationFn: async (details: { - email: string; - clientPublicKey: string; - providerAuthToken?: string; - }) => { - return login1(details); - } - }); +export const loginLDAPRedirect = async (loginLDAPDetails: LoginLDAPDTO) => { + const { data } = await apiRequest.post("/api/v1/ldap/login", loginLDAPDetails); // return if account is complete or not + provider auth token + return data; }; export const selectOrganization = async (data: { @@ -143,11 +134,6 @@ export const useOauthTokenExchange = () => { }); }; -export const srp1 = async (details: SRP1DTO) => { - const { data } = await apiRequest.post("/api/v1/password/srp1", details); - return data; -}; - export const completeAccountSignup = async (details: CompleteAccountSignupDTO) => { const { data } = await apiRequest.post("/api/v3/signup/complete-account/signup", details); return data; @@ -158,14 +144,6 @@ export const completeAccountSignupInvite = async (details: CompleteAccountDTO) = return data; }; -export const useCompleteAccountSignup = () => { - return useMutation({ - mutationFn: async (details: CompleteAccountSignupDTO) => { - return completeAccountSignup(details); - } - }); -}; - export const useSendMfaToken = () => { return useMutation({ mutationFn: async ({ email }) => { @@ -263,11 +241,6 @@ export const useVerifyPasswordResetCode = () => { }); }; -export const issueBackupPrivateKey = async (details: IssueBackupPrivateKeyDTO) => { - const { data } = await apiRequest.post("/api/v1/password/backup-private-key", details); - return data; -}; - export const getBackupEncryptedPrivateKey = async ({ verificationToken }: GetBackupEncryptedPrivateKeyDTO) => { @@ -328,20 +301,6 @@ export const useResetUserPasswordV2 = () => { }); }; -export const changePassword = async (details: ChangePasswordDTO) => { - const { data } = await apiRequest.post("/api/v1/password/change-password", details); - return data; -}; - -export const useChangePassword = () => { - // note: use after srp1 - return useMutation({ - mutationFn: async (details: ChangePasswordDTO) => { - return changePassword(details); - } - }); -}; - // Refresh token is set as cookie when logged in // Using that we fetch the auth bearer token needed for auth calls export const fetchAuthToken = async () => { diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index cbd20b643..fd9d6fb5c 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -49,13 +49,19 @@ export type Login2DTO = { password: string; }; +export type LoginV3DTO = { + email: string; + password: string; + providerAuthToken?: string; + captchaToken?: string; +}; + export type Login1Res = { serverPublicKey: string; salt: string; }; export type Login2Res = { - mfaEnabled: boolean; token: string; encryptionVersion?: number; protectedKey?: string; @@ -67,6 +73,11 @@ export type Login2Res = { tag?: string; }; +export type LoginV3Res = { + accessToken: string; + mfaEnabled: boolean; +}; + export type LoginLDAPDTO = { organizationSlug: string; username: string; @@ -77,28 +88,10 @@ export type LoginLDAPRes = { nextUrl: string; }; -export type SRP1DTO = { - clientPublicKey: string; -}; - -export type SRPR1Res = { - serverPublicKey: string; - salt: string; -}; - export type CompleteAccountDTO = { email: string; firstName: string; lastName: string; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - publicKey: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; - salt: string; - verifier: string; password: string; tokenMetadata?: string; }; @@ -116,19 +109,6 @@ export type VerifySignupInviteDTO = { organizationId: string; }; -export type ChangePasswordDTO = { - password: string; - clientProof: string; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; - salt: string; - verifier: string; -}; - export type ResetPasswordDTO = { protectedKey: string; protectedKeyIV: string; @@ -153,27 +133,11 @@ export type ResetUserPasswordV2DTO = { }; export type SetupPasswordDTO = { - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; - salt: string; - verifier: string; + email: string; token: string; password: string; }; -export type IssueBackupPrivateKeyDTO = { - encryptedPrivateKey: string; - iv: string; - tag: string; - salt: string; - verifier: string; - clientProof: string; -}; - export type GetBackupEncryptedPrivateKeyDTO = { verificationToken: string; }; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 6d05b5ef3..778656da4 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,6 +1,7 @@ +import { AppConnection } from "../appConnections/enums"; import { SshCaStatus } from "../sshCa"; import { SshCertTemplateStatus } from "../sshCertificateTemplates"; -import { CaStatus, InternalCaType } from "./enums"; +import { AcmeDnsProvider, CaCapability, CaStatus, CaType, InternalCaType } from "./enums"; export const caTypeToNameMap: { [K in InternalCaType]: string } = { [InternalCaType.ROOT]: "Root", @@ -13,6 +14,38 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" }; +export const ACME_DNS_PROVIDER_NAME_MAP: Record = { + [AcmeDnsProvider.ROUTE53]: "Route53", + [AcmeDnsProvider.Cloudflare]: "Cloudflare" +}; + +export const ACME_DNS_PROVIDER_APP_CONNECTION_MAP: Record = { + [AcmeDnsProvider.ROUTE53]: AppConnection.AWS, + [AcmeDnsProvider.Cloudflare]: AppConnection.Cloudflare +}; + +export const CA_TYPE_CAPABILITIES_MAP: Record = { + [CaType.INTERNAL]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.REVOKE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + ], + [CaType.ACME]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.REVOKE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + ], + [CaType.AZURE_AD_CS]: [CaCapability.ISSUE_CERTIFICATES, CaCapability.RENEW_CERTIFICATES] +}; + +/** + * Check if a certificate authority type supports a specific capability + */ +export const caSupportsCapability = (caType: CaType, capability: CaCapability): boolean => { + const capabilities = CA_TYPE_CAPABILITIES_MAP[caType] || []; + return capabilities.includes(capability); +}; + export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => { switch (status) { case CaStatus.ACTIVE: diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index b2b725612..a68f3e862 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -1,6 +1,7 @@ export enum CaType { INTERNAL = "internal", - ACME = "acme" + ACME = "acme", + AZURE_AD_CS = "azure-ad-cs" } export enum InternalCaType { @@ -19,5 +20,12 @@ export enum CaRenewalType { } export enum AcmeDnsProvider { - ROUTE53 = "route53" + ROUTE53 = "route53", + Cloudflare = "cloudflare" +} + +export enum CaCapability { + ISSUE_CERTIFICATES = "issue-certificates", + REVOKE_CERTIFICATES = "revoke-certificates", + RENEW_CERTIFICATES = "renew-certificates" } diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index 82e9ea3be..36526ec18 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -9,6 +9,7 @@ export { useUpdateCa } from "./mutations"; export { + useGetAzureAdcsTemplates, useGetCa, useGetCaById, useGetCaCert, @@ -17,5 +18,6 @@ export { useGetCaCrls, useGetCaCsr, useListCasByProjectId, - useListCasByTypeAndProjectId + useListCasByTypeAndProjectId, + useListExternalCasByProjectId } from "./queries"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index c05760beb..f6204d4fc 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -39,6 +39,10 @@ export const useUpdateCa = () => { queryClient.invalidateQueries({ queryKey: caKeys.getCaByNameAndProjectId(caName, projectId) }); + // Invalidate external CAs list + queryClient.invalidateQueries({ + queryKey: caKeys.listExternalCasByProjectId(projectId) + }); } }); }; @@ -57,6 +61,10 @@ export const useCreateCa = () => { queryClient.invalidateQueries({ queryKey: caKeys.listCasByTypeAndProjectId(type, projectId) }); + // Invalidate external CAs list + queryClient.invalidateQueries({ + queryKey: caKeys.listExternalCasByProjectId(projectId) + }); } }); }; @@ -79,6 +87,10 @@ export const useDeleteCa = () => { queryClient.invalidateQueries({ queryKey: caKeys.listCasByTypeAndProjectId(type, projectId) }); + // Invalidate external CAs list + queryClient.invalidateQueries({ + queryKey: caKeys.listExternalCasByProjectId(projectId) + }); } }); }; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index 4f10c6e84..68e8d2c12 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -4,20 +4,25 @@ import { apiRequest } from "@app/config/request"; import { TCertificateTemplate } from "../certificateTemplates/types"; import { CaType } from "./enums"; -import { TCertificateAuthority, TUnifiedCertificateAuthority } from "./types"; +import { TAzureAdCsTemplate, TCertificateAuthority, TUnifiedCertificateAuthority } from "./types"; export const caKeys = { getCaById: (caId: string) => [{ caId }, "ca"], getCaByNameAndProjectId: (caName: string, projectId: string) => [{ caName, projectId }, "ca"], listCasByTypeAndProjectId: (type: CaType, projectId: string) => [{ type, projectId }, "cas"], listCasByProjectId: (projectId: string) => [{ projectId }, "cas"], + listExternalCasByProjectId: (projectId: string) => [{ projectId }, "external-cas"], getCaCerts: (caId: string) => [{ caId }, "ca-cert"], getCaCrls: (caId: string) => [{ caId }, "ca-crls"], getCaCert: (caId: string) => [{ caId }, "ca-cert"], getCaCsr: (caId: string) => [{ caId }, "ca-csr"], getCaCrl: (caId: string) => [{ caId }, "ca-crl"], getCaCertTemplates: (caId: string) => [{ caId }, "ca-cert-templates"], - getCaEstConfig: (caId: string) => [{ caId }, "ca-est-config"] + getCaEstConfig: (caId: string) => [{ caId }, "ca-est-config"], + getAzureAdcsTemplates: (caId: string, projectId: string) => [ + { caId, projectId }, + "azure-adcs-templates" + ] }; export const useGetCa = ({ @@ -67,6 +72,34 @@ export const useListCasByProjectId = (projectId: string) => { }); }; +export const useListExternalCasByProjectId = (projectId: string) => { + return useQuery({ + queryKey: caKeys.listExternalCasByProjectId(projectId), + queryFn: async () => { + const [acmeResponse, azureAdCsResponse] = await Promise.allSettled([ + apiRequest.get( + `/api/v1/pki/ca/${CaType.ACME}?projectId=${projectId}` + ), + apiRequest.get( + `/api/v1/pki/ca/${CaType.AZURE_AD_CS}?projectId=${projectId}` + ) + ]); + + const allCas: TUnifiedCertificateAuthority[] = []; + + if (acmeResponse.status === "fulfilled") { + allCas.push(...acmeResponse.value.data); + } + + if (azureAdCsResponse.status === "fulfilled") { + allCas.push(...azureAdCsResponse.value.data); + } + + return allCas; + } + }); +}; + export const useGetCaById = (caId: string) => { return useQuery({ queryKey: caKeys.getCaById(caId), @@ -156,3 +189,22 @@ export const useGetCaCertTemplates = (caId: string) => { enabled: Boolean(caId) }); }; + +export const useGetAzureAdcsTemplates = ({ + caId, + projectId +}: { + caId: string; + projectId: string; +}) => { + return useQuery({ + queryKey: caKeys.getAzureAdcsTemplates(caId, projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + templates: TAzureAdCsTemplate[]; + }>(`/api/v1/pki/ca/azure-ad-cs/${caId}/templates?projectId=${projectId}`); + return data; + }, + enabled: Boolean(caId && projectId) + }); +}; diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index f3703fe52..336688ca4 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -11,11 +11,26 @@ export type TAcmeCertificateAuthority = { configuration: { dnsAppConnectionId: string; dnsProviderConfig: { - provider: AcmeDnsProvider.ROUTE53; + provider: AcmeDnsProvider; hostedZoneId: string; }; directoryUrl: string; accountEmail: string; + eabKid?: string; + eabHmacKey?: string; + }; +}; + +export type TAzureAdCsCertificateAuthority = { + id: string; + projectId: string; + type: CaType.AZURE_AD_CS; + status: CaStatus; + name: string; + enableDirectIssuance: boolean; + configuration: { + azureAdcsConnectionId: string; + templateName: string; }; }; @@ -48,6 +63,7 @@ export type TInternalCertificateAuthority = { export type TUnifiedCertificateAuthority = | TAcmeCertificateAuthority + | TAzureAdCsCertificateAuthority | TInternalCertificateAuthority; export type TCreateCertificateAuthorityDTO = Omit; @@ -114,6 +130,12 @@ export type TSignIntermediateResponse = { serialNumber: string; }; +export type TAzureAdCsTemplate = { + id: string; + name: string; + description?: string; +}; + export type TImportCaCertificateDTO = { caId: string; projectSlug: string; diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index e748e3e5b..fde3a5ee3 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -1,5 +1,6 @@ import { useCallback } from "react"; import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { AxiosError } from "axios"; import { apiRequest } from "@app/config/request"; import { @@ -273,6 +274,12 @@ export const useGetProjectSecretsDetails = ( ...options, // wait for all values to be available enabled: Boolean(projectId) && (options?.enabled ?? true), + retry: (count, error) => { + // don't retry 404s + if (error instanceof AxiosError && error.status === 404) return false; + + return count <= 5; + }, queryKey: dashboardKeys.getProjectSecretsDetails({ secretPath, search, diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index 78c56394f..fbd5107cd 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -72,7 +72,7 @@ export type DashboardProjectSecretsOverview = Omit< DashboardProjectSecretsOverviewResponse, "secrets" | "secretRotations" > & { - secrets?: SecretV3RawSanitized[]; + secrets?: (SecretV3RawSanitized & { sourceEnv?: string })[]; secretRotations?: (TSecretRotationV2 & { secrets: (SecretV3RawSanitized | null)[]; })[]; 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/githubOrgSyncConfig/index.tsx b/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx index 585426469..66b0246dc 100644 --- a/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx +++ b/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx @@ -1,6 +1,7 @@ export { useCreateGithubSyncOrgConfig, useDeleteGithubSyncOrgConfig, + useSyncAllGithubTeams, useUpdateGithubSyncOrgConfig } from "./mutations"; export { githubOrgSyncConfigQueryKeys } from "./queries"; diff --git a/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx b/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx index 0cb9b56e8..3c78550ed 100644 --- a/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx +++ b/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx @@ -40,3 +40,19 @@ export const useDeleteGithubSyncOrgConfig = () => { } }); }; + +export const useSyncAllGithubTeams = () => { + return useMutation({ + mutationFn: async (): Promise<{ + totalUsers: number; + errors: string[]; + createdTeams: string[]; + updatedTeams: string[]; + removedMemberships: number; + syncDuration: number; + }> => { + const response = await apiRequest.post("/api/v1/github-org-sync-config/sync-all-teams"); + return response.data; + } + }); +}; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 165c94395..5189f187c 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -18,6 +18,7 @@ import { AddIdentityTlsCertAuthDTO, AddIdentityTokenAuthDTO, AddIdentityUniversalAuthDTO, + ClearIdentityUniversalAuthLockoutsDTO, ClientSecretData, CreateIdentityDTO, CreateIdentityUniversalAuthClientSecretDTO, @@ -148,7 +149,11 @@ export const useAddIdentityUniversalAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }) => { const { data: { identityUniversalAuth } @@ -157,7 +162,11 @@ export const useAddIdentityUniversalAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return identityUniversalAuth; }, @@ -183,7 +192,11 @@ export const useUpdateIdentityUniversalAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }) => { const { data: { identityUniversalAuth } @@ -193,7 +206,11 @@ export const useUpdateIdentityUniversalAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return identityUniversalAuth; }, @@ -275,6 +292,25 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => { }); }; +export const useClearIdentityUniversalAuthLockouts = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { + data: { deleted } + } = await apiRequest.post<{ deleted: number }>( + `/api/v1/auth/universal-auth/identities/${identityId}/clear-lockouts` + ); + return deleted; + }, + onSuccess: (_, { identityId }) => { + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityUniversalAuth(identityId) + }); + } + }); +}; + export const useAddIdentityGcpAuth = () => { const queryClient = useQueryClient(); return useMutation({ @@ -1385,6 +1421,7 @@ export const useAddIdentityLdapAuth = () => { return useMutation({ mutationFn: async ({ identityId, + templateId, url, bindDN, bindPass, @@ -1400,6 +1437,7 @@ export const useAddIdentityLdapAuth = () => { const { data } = await apiRequest.post<{ identityLdapAuth: IdentityLdapAuth }>( `/api/v1/auth/ldap-auth/identities/${identityId}`, { + templateId, url, bindDN, bindPass, @@ -1432,6 +1470,7 @@ export const useUpdateIdentityLdapAuth = () => { return useMutation({ mutationFn: async ({ identityId, + templateId, url, bindDN, bindPass, @@ -1447,6 +1486,7 @@ export const useUpdateIdentityLdapAuth = () => { const { data } = await apiRequest.patch<{ identityLdapAuth: IdentityLdapAuth }>( `/api/v1/auth/ldap-auth/identities/${identityId}`, { + templateId, url, bindDN, bindPass, diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 1af1cc24c..36f9eae4e 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -16,6 +16,7 @@ export type Identity = { name: string; hasDeleteProtection: boolean; authMethods: IdentityAuthMethod[]; + activeLockoutAuthMethods: IdentityAuthMethod[]; createdAt: string; updatedAt: string; isInstanceAdmin?: boolean; @@ -41,6 +42,8 @@ export type IdentityMembershipOrg = { id: string; identity: Identity; organization: string; + lastLoginAuthMethod?: IdentityAuthMethod; + lastLoginTime?: string; metadata: { key: string; value: string; id: string }[]; role: "admin" | "member" | "viewer" | "no-access" | "custom"; customRole?: TOrgRole; @@ -111,6 +114,10 @@ export type IdentityUniversalAuth = { accessTokenNumUsesLimit: number; accessTokenTrustedIps: IdentityTrustedIp[]; accessTokenPeriod: number; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; export type AddIdentityUniversalAuthDTO = { @@ -126,6 +133,10 @@ export type AddIdentityUniversalAuthDTO = { accessTokenTrustedIps: { ipAddress: string; }[]; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; export type UpdateIdentityUniversalAuthDTO = { @@ -141,6 +152,10 @@ export type UpdateIdentityUniversalAuthDTO = { accessTokenTrustedIps?: { ipAddress: string; }[]; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; }; export type DeleteIdentityUniversalAuthDTO = { @@ -556,6 +571,10 @@ export type DeleteIdentityUniversalAuthClientSecretDTO = { clientSecretId: string; }; +export type ClearIdentityUniversalAuthLockoutsDTO = { + identityId: string; +}; + export type IdentityTokenAuth = { identityId: string; accessTokenTTL: number; @@ -567,10 +586,11 @@ export type IdentityTokenAuth = { export type AddIdentityLdapAuthDTO = { organizationId: string; identityId: string; - url: string; - bindDN: string; - bindPass: string; - searchBase: string; + templateId?: string; + url?: string; + bindDN?: string; + bindPass?: string; + searchBase?: string; searchFilter: string; ldapCaCertificate?: string; allowedFields?: { @@ -588,6 +608,7 @@ export type AddIdentityLdapAuthDTO = { export type UpdateIdentityLdapAuthDTO = { identityId: string; organizationId: string; + templateId?: string; url?: string; bindDN?: string; bindPass?: string; @@ -612,10 +633,11 @@ export type DeleteIdentityLdapAuthDTO = { }; export type IdentityLdapAuth = { - url: string; - bindDN: string; - bindPass: string; - searchBase: string; + url?: string; + bindDN?: string; + templateId?: string; + bindPass?: string; + searchBase?: string; searchFilter: string; ldapCaCertificate?: string; allowedFields?: { diff --git a/frontend/src/hooks/api/identityAuthTemplates/index.tsx b/frontend/src/hooks/api/identityAuthTemplates/index.tsx new file mode 100644 index 000000000..177955438 --- /dev/null +++ b/frontend/src/hooks/api/identityAuthTemplates/index.tsx @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/identityAuthTemplates/mutations.tsx b/frontend/src/hooks/api/identityAuthTemplates/mutations.tsx new file mode 100644 index 000000000..8c05f1e46 --- /dev/null +++ b/frontend/src/hooks/api/identityAuthTemplates/mutations.tsx @@ -0,0 +1,97 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { identityAuthTemplatesKeys } from "./queries"; +import { + CreateIdentityAuthTemplateDTO, + DeleteIdentityAuthTemplateDTO, + IdentityAuthTemplate, + MachineAuthTemplateUsage, + UnlinkTemplateUsageDTO, + UpdateIdentityAuthTemplateDTO +} from "./types"; + +export const useCreateIdentityAuthTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: CreateIdentityAuthTemplateDTO) => { + const { data } = await apiRequest.post<{ template: IdentityAuthTemplate }>( + "/api/v1/identity-templates", + dto + ); + return data.template; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplates({ organizationId }) + }); + } + }); +}; + +export const useUpdateIdentityAuthTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: UpdateIdentityAuthTemplateDTO) => { + const { data } = await apiRequest.patch<{ template: IdentityAuthTemplate }>( + `/api/v1/identity-templates/${dto.templateId}`, + dto + ); + return data.template; + }, + onSuccess: (_, { organizationId, templateId }) => { + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplates({ organizationId }) + }); + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplate(templateId) + }); + } + }); +}; + +export const useDeleteIdentityAuthTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: DeleteIdentityAuthTemplateDTO) => { + await apiRequest.delete(`/api/v1/identity-templates/${dto.templateId}`, { + params: { organizationId: dto.organizationId } + }); + }, + onSuccess: (_, { organizationId, templateId }) => { + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplates({ organizationId }) + }); + queryClient.removeQueries({ + queryKey: identityAuthTemplatesKeys.getTemplate(templateId) + }); + } + }); +}; + +export const useUnlinkTemplateUsage = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: UnlinkTemplateUsageDTO) => { + const { data } = await apiRequest.post( + `/api/v1/identity-templates/${dto.templateId}/delete-usage`, + { identityIds: dto.identityIds }, + { params: { organizationId: dto.organizationId } } + ); + return data; + }, + onSuccess: (_, { templateId, organizationId }) => { + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplateUsages(templateId) + }); + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplates({ organizationId }) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/identityAuthTemplates/queries.tsx b/frontend/src/hooks/api/identityAuthTemplates/queries.tsx new file mode 100644 index 000000000..a9981c0c4 --- /dev/null +++ b/frontend/src/hooks/api/identityAuthTemplates/queries.tsx @@ -0,0 +1,89 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + GetIdentityAuthTemplatesDTO, + GetTemplateUsagesDTO, + IdentityAuthTemplate, + MachineAuthTemplateUsage, + MachineIdentityAuthMethod +} from "./types"; + +export const identityAuthTemplatesKeys = { + all: ["identity-auth-templates"] as const, + getTemplates: (dto: GetIdentityAuthTemplatesDTO) => + [...identityAuthTemplatesKeys.all, "list", dto] as const, + getTemplate: (templateId: string) => + [...identityAuthTemplatesKeys.all, "single", templateId] as const, + getAvailableTemplates: (authMethod: MachineIdentityAuthMethod) => + [...identityAuthTemplatesKeys.all, "available", authMethod] as const, + getTemplateUsages: (templateId: string) => + [...identityAuthTemplatesKeys.all, "usages", templateId] as const +}; + +export const useGetIdentityAuthTemplates = (dto: GetIdentityAuthTemplatesDTO) => { + return useQuery({ + queryKey: identityAuthTemplatesKeys.getTemplates(dto), + queryFn: async () => { + const { data } = await apiRequest.get<{ + templates: IdentityAuthTemplate[]; + totalCount: number; + }>("/api/v1/identity-templates/search", { + params: { + organizationId: dto.organizationId, + limit: dto.limit || 50, + offset: dto.offset || 0, + ...(dto.search && { search: dto.search }) + } + }); + return data; + }, + enabled: Boolean(dto.organizationId) + }); +}; + +export const useGetIdentityAuthTemplate = (templateId: string, organizationId: string) => { + return useQuery({ + queryKey: identityAuthTemplatesKeys.getTemplate(templateId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/identity-templates/${templateId}`, + { + params: { organizationId } + } + ); + return data; + }, + enabled: Boolean(templateId) && Boolean(organizationId) + }); +}; + +export const useGetAvailableTemplates = (authMethod: MachineIdentityAuthMethod) => { + return useQuery({ + queryKey: identityAuthTemplatesKeys.getAvailableTemplates(authMethod), + queryFn: async () => { + const { data } = await apiRequest.get("/api/v1/identity-templates", { + params: { authMethod } + }); + return data; + }, + enabled: Boolean(authMethod) + }); +}; + +export const useGetTemplateUsages = (dto: GetTemplateUsagesDTO) => { + return useQuery({ + queryKey: identityAuthTemplatesKeys.getTemplateUsages(dto.templateId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/identity-templates/${dto.templateId}/usage`, + { + params: { organizationId: dto.organizationId } + } + ); + return data; + }, + enabled: Boolean(dto.templateId) && Boolean(dto.organizationId) + }); +}; diff --git a/frontend/src/hooks/api/identityAuthTemplates/types.ts b/frontend/src/hooks/api/identityAuthTemplates/types.ts new file mode 100644 index 000000000..860f9a9fc --- /dev/null +++ b/frontend/src/hooks/api/identityAuthTemplates/types.ts @@ -0,0 +1,78 @@ +export enum MachineIdentityAuthMethod { + LDAP = "ldap" +} + +export interface LdapTemplateFields { + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + ldapCaCertificate?: string; +} + +export interface IdentityAuthTemplate { + id: string; + name: string; + authMethod: MachineIdentityAuthMethod; + organizationId: string; + templateFields: LdapTemplateFields; + createdAt: string; + updatedAt: string; +} + +export interface CreateIdentityAuthTemplateDTO { + organizationId: string; + name: string; + authMethod: MachineIdentityAuthMethod; + templateFields: LdapTemplateFields; +} + +export interface UpdateIdentityAuthTemplateDTO { + templateId: string; + organizationId: string; + name?: string; + templateFields?: Partial; +} + +export interface DeleteIdentityAuthTemplateDTO { + templateId: string; + organizationId: string; +} + +export interface GetIdentityAuthTemplatesDTO { + organizationId: string; + limit?: number; + offset?: number; + search?: string; +} + +export interface MachineAuthTemplateUsage { + identityId: string; + identityName: string; +} + +export interface GetTemplateUsagesDTO { + templateId: string; + organizationId: string; +} + +export interface UnlinkTemplateUsageDTO { + templateId: string; + identityIds: string[]; + organizationId: string; +} + +export const TEMPLATE_ERROR_MESSAGES = { + UNLINK_SUCCESS: "Successfully unlinked template usages", + UNLINK_FAILED: "Failed to unlink template usages", + SINGLE_UNLINK_SUCCESS: "Successfully unlinked template usage", + SINGLE_UNLINK_FAILED: "Failed to unlink template usage" +} as const; + +export const TEMPLATE_UI_LABELS = { + VIEW_USAGES: "View Usages", + EDIT_TEMPLATE: "Edit Template", + DELETE_TEMPLATE: "Delete Template", + UNLINK: "Unlink", + UNSELECT_ALL: "Unselect All" +} as const; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 4b4967f16..78dcd3b79 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -15,6 +15,7 @@ export * from "./gateways"; export * from "./githubOrgSyncConfig"; export * from "./groups"; export * from "./identities"; +export * from "./identityAuthTemplates"; export * from "./identityProjectAdditionalPrivilege"; export * from "./incidentContacts"; export * from "./integrationAuth"; diff --git a/frontend/src/hooks/api/ldapConfig/mutations.tsx b/frontend/src/hooks/api/ldapConfig/mutations.tsx index 6bab003cb..12fc4e189 100644 --- a/frontend/src/hooks/api/ldapConfig/mutations.tsx +++ b/frontend/src/hooks/api/ldapConfig/mutations.tsx @@ -151,10 +151,23 @@ export const useDeleteLDAPGroupMapping = () => { export const useTestLDAPConnection = () => { return useMutation({ - mutationFn: async (ldapConfigId: string) => { - const { data } = await apiRequest.post( - `/api/v1/ldap/config/${ldapConfigId}/test-connection` - ); + mutationFn: async ({ + url, + bindDN, + bindPass, + caCert + }: { + url: string; + bindDN: string; + bindPass: string; + caCert: string; + }) => { + const { data } = await apiRequest.post("/api/v1/ldap/config/test-connection", { + url, + bindDN, + bindPass, + caCert + }); return data; } }); diff --git a/frontend/src/hooks/api/migration/index.ts b/frontend/src/hooks/api/migration/index.ts index f8dd99d03..0c2adeab0 100644 --- a/frontend/src/hooks/api/migration/index.ts +++ b/frontend/src/hooks/api/migration/index.ts @@ -1 +1,2 @@ export * from "./mutations"; +export * from "./queries"; diff --git a/frontend/src/hooks/api/migration/mutations.tsx b/frontend/src/hooks/api/migration/mutations.tsx index 529d5c463..182797954 100644 --- a/frontend/src/hooks/api/migration/mutations.tsx +++ b/frontend/src/hooks/api/migration/mutations.tsx @@ -46,18 +46,21 @@ export const useImportVault = () => { vaultAccessToken, vaultNamespace, vaultUrl, - mappingType + mappingType, + gatewayId }: { vaultAccessToken: string; vaultNamespace?: string; vaultUrl: string; mappingType: string; + gatewayId?: string; }) => { await apiRequest.post("/api/v3/external-migration/vault/", { vaultAccessToken, vaultNamespace, vaultUrl, - mappingType + mappingType, + gatewayId }); } }); diff --git a/frontend/src/hooks/api/migration/queries.tsx b/frontend/src/hooks/api/migration/queries.tsx new file mode 100644 index 000000000..e96533b09 --- /dev/null +++ b/frontend/src/hooks/api/migration/queries.tsx @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { ExternalMigrationProviders } from "./types"; + +const externalMigrationQueryKeys = { + customMigrationAvailable: (provider: ExternalMigrationProviders) => [ + "custom-migration-available", + provider + ] +}; + +export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.customMigrationAvailable(provider), + queryFn: () => + apiRequest.get<{ enabled: boolean }>( + `/api/v3/external-migration/custom-migration-enabled/${provider}` + ) + }); +}; diff --git a/frontend/src/hooks/api/migration/types.ts b/frontend/src/hooks/api/migration/types.ts new file mode 100644 index 000000000..945f18d8e --- /dev/null +++ b/frontend/src/hooks/api/migration/types.ts @@ -0,0 +1,4 @@ +export enum ExternalMigrationProviders { + Vault = "vault", + EnvKey = "env-key" +} diff --git a/frontend/src/hooks/api/notifications/mutations.tsx b/frontend/src/hooks/api/notifications/mutations.tsx new file mode 100644 index 000000000..34ab7aa26 --- /dev/null +++ b/frontend/src/hooks/api/notifications/mutations.tsx @@ -0,0 +1,70 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { useOrganization } from "@app/context"; + +import { notificationKeys } from "./queries"; +import { TUserNotification } from "./types"; + +export const useMarkAllNotificationsAsRead = () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg.id || ""; + + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + await apiRequest.post("/api/v1/notifications/user/mark-as-read"); + }, + onSuccess: () => { + queryClient.setQueryData(notificationKeys.list(orgId), (oldData) => { + if (!oldData) return oldData; + return oldData.map((notification) => ({ + ...notification, + isRead: true + })); + }); + } + }); +}; + +export const useUpdateNotification = () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg.id || ""; + + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ notificationId, isRead }: { notificationId: string; isRead: boolean }) => { + const { data } = await apiRequest.patch<{ notification: TUserNotification }>( + `/api/v1/notifications/user/${notificationId}`, + { isRead } + ); + return data.notification; + }, + onSuccess: (updatedNotification) => { + queryClient.setQueryData(notificationKeys.list(orgId), (oldData) => { + if (!oldData) return oldData; + return oldData.map((notification) => + notification.id === updatedNotification.id ? updatedNotification : notification + ); + }); + } + }); +}; + +export const useDeleteNotification = () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg.id || ""; + + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (notificationId: string) => { + await apiRequest.delete(`/api/v1/notifications/user/${notificationId}`); + }, + onSuccess: (_, notificationId) => { + queryClient.setQueryData(notificationKeys.list(orgId), (oldData) => { + if (!oldData) return oldData; + return oldData.filter((notification) => notification.id !== notificationId); + }); + } + }); +}; diff --git a/frontend/src/hooks/api/notifications/queries.tsx b/frontend/src/hooks/api/notifications/queries.tsx new file mode 100644 index 000000000..f7a31afe6 --- /dev/null +++ b/frontend/src/hooks/api/notifications/queries.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { useOrganization } from "@app/context"; + +import { TUserNotification } from "./types"; + +export const notificationKeys = { + all: ["notifications"] as const, + list: (orgId: string) => [...notificationKeys.all, "list", { orgId }] as const +}; + +export const useGetMyNotifications = () => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg.id || ""; + + return useQuery({ + queryKey: notificationKeys.list(orgId), + queryFn: async () => { + const { + data: { notifications } + } = await apiRequest.get<{ notifications: TUserNotification[] }>( + "/api/v1/notifications/user" + ); + return notifications; + }, + refetchInterval: 30 * 1000 // Poll every 30 seconds + }); +}; diff --git a/frontend/src/hooks/api/notifications/types.ts b/frontend/src/hooks/api/notifications/types.ts new file mode 100644 index 000000000..97552046c --- /dev/null +++ b/frontend/src/hooks/api/notifications/types.ts @@ -0,0 +1,10 @@ +export interface TUserNotification { + id: string; + userId: string; + type: string; + title: string; + body?: string | null; + link?: string | null; + isRead: boolean; + createdAt: string; +} diff --git a/frontend/src/hooks/api/orgAdmin/index.tsx b/frontend/src/hooks/api/orgAdmin/index.tsx index 57eed413a..c0fead35c 100644 --- a/frontend/src/hooks/api/orgAdmin/index.tsx +++ b/frontend/src/hooks/api/orgAdmin/index.tsx @@ -1,2 +1 @@ export { useOrgAdminAccessProject } from "./mutation"; -export { useOrgAdminGetProjects } from "./queries"; diff --git a/frontend/src/hooks/api/orgAdmin/queries.tsx b/frontend/src/hooks/api/orgAdmin/queries.tsx deleted file mode 100644 index 2856de0a2..000000000 --- a/frontend/src/hooks/api/orgAdmin/queries.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { apiRequest } from "@app/config/request"; - -import { Workspace } from "../types"; -import { TOrgAdminGetProjectsDTO } from "./types"; - -export const orgAdminQueryKeys = { - getProjects: (filter: TOrgAdminGetProjectsDTO) => ["org-admin-projects", filter] as const -}; - -export const useOrgAdminGetProjects = ({ search, offset, limit = 50 }: TOrgAdminGetProjectsDTO) => { - return useQuery({ - queryKey: orgAdminQueryKeys.getProjects({ search, offset, limit }), - queryFn: async () => { - const { data } = await apiRequest.get<{ projects: Workspace[]; count: number }>( - "/api/v1/organization-admin/projects", - { - params: { - limit, - offset, - search - } - } - ); - - return data; - } - }); -}; diff --git a/frontend/src/hooks/api/orgAdmin/types.ts b/frontend/src/hooks/api/orgAdmin/types.ts index 87626a466..f8138615b 100644 --- a/frontend/src/hooks/api/orgAdmin/types.ts +++ b/frontend/src/hooks/api/orgAdmin/types.ts @@ -1,9 +1,3 @@ -export type TOrgAdminGetProjectsDTO = { - limit?: number; - offset?: number; - search?: string; -}; - export type TOrgAdminAccessProjectDTO = { projectId: string; }; 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..e9c36fada 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; @@ -157,3 +159,8 @@ export enum OrgIdentityOrderBy { Name = "name", Role = "role" } + +export enum OrgMembershipStatus { + Invited = "invited", + Accepted = "accepted" +} diff --git a/frontend/src/hooks/api/pkiSubscriber/types.ts b/frontend/src/hooks/api/pkiSubscriber/types.ts index 628cfec0a..23ca6baf3 100644 --- a/frontend/src/hooks/api/pkiSubscriber/types.ts +++ b/frontend/src/hooks/api/pkiSubscriber/types.ts @@ -10,6 +10,16 @@ export enum SubscriberOperationStatus { FAILED = "failed" } +export type TPkiSubscriberProperties = { + azureTemplateType?: string; + organization?: string; + organizationalUnit?: string; + country?: string; + state?: string; + locality?: string; + emailAddress?: string; +}; + export type TPkiSubscriber = { id: string; projectId: string; @@ -27,6 +37,7 @@ export type TPkiSubscriber = { lastOperationStatus?: SubscriberOperationStatus; lastOperationMessage?: string; lastOperationAt?: string; + properties?: TPkiSubscriberProperties; }; export type TCreatePkiSubscriberDTO = { @@ -40,6 +51,7 @@ export type TCreatePkiSubscriberDTO = { extendedKeyUsages: CertExtendedKeyUsage[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; + properties?: TPkiSubscriberProperties; }; export type TUpdatePkiSubscriberDTO = { @@ -55,6 +67,7 @@ export type TUpdatePkiSubscriberDTO = { extendedKeyUsages?: CertExtendedKeyUsage[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; + properties?: TPkiSubscriberProperties; }; export type TDeletePkiSubscriberDTO = { diff --git a/frontend/src/hooks/api/reminders/queries.tsx b/frontend/src/hooks/api/reminders/queries.tsx index 68c00913d..557bc24ea 100644 --- a/frontend/src/hooks/api/reminders/queries.tsx +++ b/frontend/src/hooks/api/reminders/queries.tsx @@ -12,14 +12,15 @@ export const useCreateReminder = (secretId: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ message, repeatDays, nextReminderDate, recipients }) => { + mutationFn: async ({ message, repeatDays, nextReminderDate, recipients, fromDate }) => { const { data } = await apiRequest.post<{ reminder: Reminder }>( `/api/v1/reminders/secrets/${secretId}`, { message, repeatDays, nextReminderDate, - recipients + recipients, + fromDate } ); return data.reminder; diff --git a/frontend/src/hooks/api/reminders/types.ts b/frontend/src/hooks/api/reminders/types.ts index 245805aea..cbe96aeaf 100644 --- a/frontend/src/hooks/api/reminders/types.ts +++ b/frontend/src/hooks/api/reminders/types.ts @@ -2,6 +2,7 @@ export type CreateReminderDTO = { message?: string | null; repeatDays?: number | null; nextReminderDate?: Date | null; + fromDate?: Date | null; secretId: string; recipients?: 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/secretScanningV2/enums.ts b/frontend/src/hooks/api/secretScanningV2/enums.ts index 40e5ea7dd..a4fd7bd34 100644 --- a/frontend/src/hooks/api/secretScanningV2/enums.ts +++ b/frontend/src/hooks/api/secretScanningV2/enums.ts @@ -1,6 +1,7 @@ export enum SecretScanningDataSource { GitHub = "github", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + GitLab = "gitlab" } export enum SecretScanningScanStatus { diff --git a/frontend/src/hooks/api/secretScanningV2/types/gitlab-data-source.ts b/frontend/src/hooks/api/secretScanningV2/types/gitlab-data-source.ts new file mode 100644 index 000000000..82f1002e0 --- /dev/null +++ b/frontend/src/hooks/api/secretScanningV2/types/gitlab-data-source.ts @@ -0,0 +1,31 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { SecretScanningDataSource } from "../enums"; +import { TSecretScanningDataSourceBase } from "./shared"; + +export enum GitLabDataSourceScope { + Project = "project", + Group = "group" +} + +export type TGitLabDataSource = TSecretScanningDataSourceBase & { + type: SecretScanningDataSource.GitLab; + config: + | { + groupId: number; + groupName?: string; + includeProjects: string[]; + scope: GitLabDataSourceScope.Group; + } + | { + projectName?: string; + projectId: number; + scope: GitLabDataSourceScope.Project; + }; +}; + +export type TGitLabDataSourceOption = { + name: string; + type: SecretScanningDataSource.GitLab; + connection: AppConnection.GitLab; +}; diff --git a/frontend/src/hooks/api/secretScanningV2/types/index.ts b/frontend/src/hooks/api/secretScanningV2/types/index.ts index 078a7051d..2c45c9bcf 100644 --- a/frontend/src/hooks/api/secretScanningV2/types/index.ts +++ b/frontend/src/hooks/api/secretScanningV2/types/index.ts @@ -1,3 +1,7 @@ +import { + TGitLabDataSource, + TGitLabDataSourceOption +} from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; import { DiscriminativePick } from "@app/types"; import { @@ -11,7 +15,10 @@ import { import { TBitbucketDataSource, TBitbucketDataSourceOption } from "./bitbucket-data-source"; import { TGitHubDataSource, TGitHubDataSourceOption } from "./github-data-source"; -export type TSecretScanningDataSource = TGitHubDataSource | TBitbucketDataSource; +export type TSecretScanningDataSource = + | TGitHubDataSource + | TBitbucketDataSource + | TGitLabDataSource; export type TSecretScanningDataSourceWithDetails = TSecretScanningDataSource & { lastScannedAt: string | null; @@ -24,7 +31,10 @@ export type TListSecretScanningDataSources = { dataSources: TSecretScanningDataSourceWithDetails[]; }; -export type TSecretScanningDataSourceOption = TGitHubDataSourceOption | TBitbucketDataSourceOption; +export type TSecretScanningDataSourceOption = + | TGitHubDataSourceOption + | TBitbucketDataSourceOption + | TGitLabDataSourceOption; export type TListSecretScanningDataSourceOptions = { dataSourceOptions: TSecretScanningDataSourceOption[]; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 11de8cd62..c230cd9c6 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -27,6 +27,7 @@ export enum SecretSync { Railway = "railway", Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", + Netlify = "netlify", Bitbucket = "bitbucket" } diff --git a/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts b/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts index 40718d261..10a8601de 100644 --- a/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts @@ -3,11 +3,17 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; +export enum ChecklySyncScope { + Global = "global", + Group = "group" +} export type TChecklySync = TRootSecretSync & { destination: SecretSync.Checkly; destinationConfig: { accountId: string; accountName: string; + groupId?: string; + groupName?: string; }; connection: { app: AppConnection.Checkly; diff --git a/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts b/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts index ed88ab685..cf5fc5e0e 100644 --- a/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts @@ -29,7 +29,7 @@ export type TGitLabSync = TRootSecretSync & { shouldHideSecrets?: boolean; }; connection: { - app: AppConnection.Gitlab; + app: AppConnection.GitLab; name: string; id: string; }; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 2ab76341b..f7acda1f4 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -21,6 +21,7 @@ import { TGitLabSync } from "./gitlab-sync"; import { THCVaultSync } from "./hc-vault-sync"; import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; +import { TNetlifySync } from "./netlify-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; import { TRailwaySync } from "./railway-sync"; import { TRenderSync } from "./render-sync"; @@ -67,6 +68,7 @@ export type TSecretSync = | TChecklySync | TSupabaseSync | TDigitalOceanAppPlatformSync + | TNetlifySync | TBitbucketSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/netlify-sync.ts b/frontend/src/hooks/api/secretSyncs/types/netlify-sync.ts new file mode 100644 index 000000000..1163224ef --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/netlify-sync.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TNetlifySync = TRootSecretSync & { + destination: SecretSync.Netlify; + destinationConfig: { + accountId: string; + accountName: string; + siteId?: string; + siteName?: string; + context?: string; + }; + connection: { + app: AppConnection.Netlify; + name: string; + id: 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/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 10796c6e2..443b74df8 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -88,7 +88,8 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => { path: el.secretPath, secretMetadata: el.secretMetadata, isRotatedSecret: el.isRotatedSecret, - rotationId: el.rotationId + rotationId: el.rotationId, + reminder: el.reminder }; if (el.type === SecretType.Personal) { diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 29da8e578..1b232cfac 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -1,4 +1,5 @@ import { ProjectPermissionActions } from "@app/context"; +import { Reminder } from "@app/hooks/api/reminders/types"; import { PendingAction } from "../secretFolders/types"; import type { WsTag } from "../tags/types"; @@ -69,6 +70,7 @@ export type SecretV3RawSanitized = { rotationId?: string; isPending?: boolean; pendingAction?: PendingAction; + reminder?: Reminder; }; export type SecretV3Raw = { @@ -94,6 +96,7 @@ export type SecretV3Raw = { isRotatedSecret?: boolean; rotationId?: string; secretReminderRecipients?: SecretReminderRecipient[]; + reminder?: Reminder; }; export type SecretV3RawResponse = { diff --git a/frontend/src/hooks/api/serverDetails/types.ts b/frontend/src/hooks/api/serverDetails/types.ts index 3e22c2684..d43521382 100644 --- a/frontend/src/hooks/api/serverDetails/types.ts +++ b/frontend/src/hooks/api/serverDetails/types.ts @@ -5,4 +5,5 @@ export type ServerStatus = { secretScanningConfigured: boolean; redisConfigured: boolean; samlDefaultOrgSlug: string; + auditLogStorageDisabled: boolean; }; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 6042df67d..0c3733cc8 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; @@ -55,4 +56,5 @@ export type SubscriptionPlan = { enterpriseAppConnections: boolean; cardDeclined?: boolean; cardDeclinedReason?: string; + machineIdentityAuthTemplates: boolean; }; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 84bd05e07..b108a98c4 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -26,16 +26,16 @@ export const useAddUserToWsNonE2EE = () => { }); }; -export const sendEmailVerificationCode = async (username: string) => { +export const sendEmailVerificationCode = async (token: string) => { return apiRequest.post("/api/v2/users/me/emails/code", { - username + token }); }; export const useSendEmailVerificationCode = () => { return useMutation({ - mutationFn: async (username: string) => { - await sendEmailVerificationCode(username); + mutationFn: async (token: string) => { + await sendEmailVerificationCode(token); return {}; } }); diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index b740b58ee..77289fee6 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -17,7 +17,8 @@ import { APIKeyData, AuthMethod, CreateAPIKeyRes, - DeletOrgMembershipDTO, + DeleteOrgMembershipBatchDTO, + DeleteOrgMembershipDTO, OrgUser, RenameUserDTO, TokenVersion, @@ -243,7 +244,7 @@ export const useGetOrgMembershipProjectMemberships = ( export const useDeleteOrgMembership = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: ({ membershipId, orgId }) => { return apiRequest.delete(`/api/v2/organizations/${orgId}/memberships/${membershipId}`); }, @@ -253,10 +254,27 @@ export const useDeleteOrgMembership = () => { }); }; +export const useDeleteOrgMembershipBatch = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ membershipIds, orgId }) => { + return apiRequest.delete(`/api/v2/organizations/${orgId}/memberships`, { + data: { + membershipIds + } + }); + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries({ queryKey: userKeys.getOrgUsers(orgId) }); + } + }); +}; + export const useDeactivateOrgMembership = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: ({ membershipId, orgId }) => { return apiRequest.post( `/api/v2/organizations/${orgId}/memberships/${membershipId}/deactivate` @@ -477,14 +495,6 @@ export const useGetMyOrganizationProjects = (orgId: string) => { }); }; -export const fetchMyPrivateKey = async () => { - const { - data: { privateKey } - } = await apiRequest.get<{ privateKey: string }>("/api/v1/user/private-key"); - - return privateKey; -}; - export const useListUserGroupMemberships = (username: string) => { return useQuery({ queryKey: userKeys.listUserGroupMemberships(username), diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 963ab100e..5a90d4b64 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -19,10 +19,10 @@ export type User = { createdAt: Date; updatedAt: Date; username: string; - email?: string; + email?: string | null; superAdmin: boolean; - firstName?: string; - lastName?: string; + firstName?: string | null; + lastName?: string | null; authProvider?: AuthMethod; authMethods: AuthMethod[]; isMfaEnabled: boolean; @@ -68,6 +68,8 @@ export type OrgUser = { deniedPermissions: any[]; roleId: string; isActive: boolean; + lastLoginAuthMethod?: AuthMethod; + lastLoginTime?: string; }; export type TProjectMembership = { @@ -81,6 +83,7 @@ export type TProjectMembership = { export type TWorkspaceUser = { id: string; user: { + isOrgMembershipActive: boolean; email: string; username: string; firstName: string; @@ -143,11 +146,16 @@ export type UpdateOrgMembershipDTO = { metadata?: { key: string; value: string }[]; }; -export type DeletOrgMembershipDTO = { +export type DeleteOrgMembershipDTO = { membershipId: string; orgId: string; }; +export type DeleteOrgMembershipBatchDTO = { + membershipIds: string[]; + orgId: string; +}; + export type AddUserToOrgDTO = { inviteeEmails: string[]; organizationRoleSlug: string; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 7a0e3d2e2..3c7a6d30c 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -47,7 +47,8 @@ import { UpdateEnvironmentDTO, UpdatePitVersionLimitDTO, UpdateProjectDTO, - Workspace + Workspace, + WorkspaceEnv } from "./types"; export const fetchWorkspaceById = async (workspaceId: string) => { @@ -396,12 +397,16 @@ export const useDeleteWorkspace = () => { export const useCreateWsEnvironment = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ workspaceId, name, slug }) => { - return apiRequest.post(`/api/v1/workspace/${workspaceId}/environments`, { - name, - slug - }); + return useMutation({ + mutationFn: async ({ workspaceId, name, slug }) => { + const { data } = await apiRequest.post<{ environment: WorkspaceEnv }>( + `/api/v1/workspace/${workspaceId}/environments`, + { + name, + slug + } + ); + return data.environment; }, onSuccess: () => { queryClient.invalidateQueries({ diff --git a/frontend/src/hooks/useResizableColWidth.tsx b/frontend/src/hooks/useResizableColWidth.tsx index f2ad80625..6af269217 100644 --- a/frontend/src/hooks/useResizableColWidth.tsx +++ b/frontend/src/hooks/useResizableColWidth.tsx @@ -1,12 +1,13 @@ -import { MouseEvent, useCallback, useEffect, useRef, useState } from "react"; +import { MouseEvent, RefObject, useCallback, useEffect, useRef, useState } from "react"; type Params = { minWidth: number; maxWidth: number; initialWidth: number; + ref: RefObject; }; -export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth }: Params) => { +export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth, ref }: Params) => { const [colWidth, setColWidth] = useState(initialWidth); const [isResizing, setIsResizing] = useState(false); const startX = useRef(0); @@ -63,6 +64,28 @@ export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth }: Param }; }, [isResizing, handleMouseMove, handleMouseUp]); + useEffect(() => { + const element = ref?.current; + if (!element) return; + + const handleResize = () => { + if (colWidth > maxWidth) { + setColWidth(Math.max(maxWidth, minWidth)); + } else if (ref.current?.clientWidth && colWidth > ref.current.clientWidth * 0.9) { + // this else is a fallback to ensure col is always visible + setColWidth(initialWidth); + } + }; + + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(element); + + // eslint-disable-next-line consistent-return + return () => { + resizeObserver.disconnect(); + }; + }, [ref, maxWidth, colWidth]); + return { colWidth, handleMouseDown, diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx index e136df3f2..d77b41f5f 100644 --- a/frontend/src/hooks/utils/secrets-overview.tsx +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -130,7 +130,11 @@ export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secr const getEnvSecretKeyCount = useCallback( (env: string) => { - return secrets?.filter((secret) => secret.env === env).length ?? 0; + return ( + secrets?.filter((secret) => + secret.sourceEnv ? secret.sourceEnv === env : secret.env === env + ).length ?? 0 + ); }, [secrets] ); diff --git a/frontend/src/layouts/AdminLayout/AdminLayout.tsx b/frontend/src/layouts/AdminLayout/AdminLayout.tsx index 097654f26..21be0bb82 100644 --- a/frontend/src/layouts/AdminLayout/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout/AdminLayout.tsx @@ -1,11 +1,15 @@ import { useTranslation } from "react-i18next"; import { faMobile } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Outlet, useRouterState } from "@tanstack/react-router"; +import { Outlet } from "@tanstack/react-router"; import { Banner } from "@app/components/page-frames/Banner"; -import { BreadcrumbContainer, TBreadcrumbFormat } from "@app/components/v2"; -import { useServerConfig } from "@app/context"; +import { useServerConfig, useSubscription } from "@app/context"; +import { useFetchServerStatus } from "@app/hooks/api"; +import { AuditLogBanner } from "@app/layouts/OrganizationLayout/components/AuditLogBanner"; +import { Navbar } from "@app/layouts/OrganizationLayout/components/NavBar"; +import { RedisBanner } from "@app/layouts/OrganizationLayout/components/RedisBanner"; +import { SmtpBanner } from "@app/layouts/OrganizationLayout/components/SmtpBanner"; import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner"; import { AdminSidebar } from "./Sidebar"; @@ -13,26 +17,27 @@ import { AdminSidebar } from "./Sidebar"; export const AdminLayout = () => { const { t } = useTranslation(); const { config } = useServerConfig(); - - const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); - - const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; + const { data: serverDetails, isLoading } = useFetchServerStatus(); + const { subscription } = useSubscription(); const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen"; return ( <> -
diff --git a/frontend/src/layouts/AdminLayout/Sidebar.tsx b/frontend/src/layouts/AdminLayout/Sidebar.tsx index e1cd223f8..25b327102 100644 --- a/frontend/src/layouts/AdminLayout/Sidebar.tsx +++ b/frontend/src/layouts/AdminLayout/Sidebar.tsx @@ -1,57 +1,62 @@ -import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faBuilding, + faChevronLeft, + faCog, + faDatabase, + faKey, + faLock, + faPlug, + faUserTie +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, useMatchRoute } from "@tanstack/react-router"; -import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; +import { Menu, MenuGroup, MenuItem } from "@app/components/v2"; const generalTabs = [ { label: "General", - icon: "settings-cog", + icon: faCog, link: "/admin/" }, { label: "Encryption", - icon: "lock-closed", + icon: faLock, link: "/admin/encryption" }, { label: "Authentication", - icon: "check", + icon: faCheckCircle, link: "/admin/authentication" }, { label: "Integrations", - icon: "sliding-carousel", + icon: faPlug, link: "/admin/integrations" }, { label: "Caching", - icon: "note", + icon: faDatabase, link: "/admin/caching" }, { label: "Environment Variables", - icon: "unlock", + icon: faKey, link: "/admin/environment" } ]; -const resourceTabs = [ +const othersTabs = [ { - label: "Organizations", - icon: "groups", - link: "/admin/resources/organizations" + label: "Access Controls", + icon: faUserTie, + link: "/admin/access-management" }, { - label: "User Identities", - icon: "user", - link: "/admin/resources/user-identities" - }, - { - label: "Machine Identities", - icon: "wrench", - link: "/admin/resources/machine-identities" + label: "Resource Overview", + icon: faBuilding, + link: "/admin/resources/overview" } ]; @@ -61,6 +66,44 @@ export const AdminSidebar = () => { return ( ); diff --git a/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx b/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx index 9064eec7f..c33f81699 100644 --- a/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx +++ b/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx @@ -6,12 +6,16 @@ import { twMerge } from "tailwind-merge"; import { CreateOrgModal } from "@app/components/organization/CreateOrgModal"; import { Banner } from "@app/components/page-frames/Banner"; -import { useServerConfig } from "@app/context"; +import { useServerConfig, useSubscription } from "@app/context"; import { usePopUp } from "@app/hooks"; +import { useFetchServerStatus } from "@app/hooks/api"; +import { AuditLogBanner } from "./components/AuditLogBanner"; import { InsecureConnectionBanner } from "./components/InsecureConnectionBanner"; import { Navbar } from "./components/NavBar"; import { OrgSidebar } from "./components/OrgSidebar"; +import { RedisBanner } from "./components/RedisBanner"; +import { SmtpBanner } from "./components/SmtpBanner"; export const OrganizationLayout = () => { const { config } = useServerConfig(); @@ -27,6 +31,9 @@ export const OrganizationLayout = () => { const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen"; + const { data: serverDetails, isLoading } = useFetchServerStatus(); + const { subscription } = useSubscription(); + return ( <> @@ -34,6 +41,9 @@ export const OrganizationLayout = () => { className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`} > + {!isLoading && !serverDetails?.redisConfigured && } + {!isLoading && !serverDetails?.emailConfigured && } + {!isLoading && subscription.auditLogs && } {!window.isSecureContext && }
diff --git a/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx b/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx new file mode 100644 index 000000000..755611587 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx @@ -0,0 +1,21 @@ +import { useFetchServerStatus, useListAuditLogStreams } from "@app/hooks/api"; + +import { OrgAlertBanner } from "../OrgAlertBanner"; + +export const AuditLogBanner = () => { + const { data: status, isLoading: isLoadingStatus } = useFetchServerStatus(); + const { data: streams, isLoading: isLoadingStreams } = useListAuditLogStreams(); + + if (isLoadingStreams || isLoadingStatus || !streams) return null; + + if (status?.auditLogStorageDisabled && !streams.length) { + return ( + + ); + } + + return null; +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/index.ts b/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/index.ts new file mode 100644 index 000000000..c7bf7d475 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/index.ts @@ -0,0 +1 @@ +export * from "./AuditLogBanner"; diff --git a/frontend/src/layouts/OrganizationLayout/components/InsecureConnectionBanner/InsecureConnectionBanner.tsx b/frontend/src/layouts/OrganizationLayout/components/InsecureConnectionBanner/InsecureConnectionBanner.tsx index e213276b3..084fb6ff5 100644 --- a/frontend/src/layouts/OrganizationLayout/components/InsecureConnectionBanner/InsecureConnectionBanner.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/InsecureConnectionBanner/InsecureConnectionBanner.tsx @@ -1,37 +1,10 @@ -import { useState } from "react"; -import { faWarning, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { IconButton } from "@app/components/v2"; +import { OrgAlertBanner } from "../OrgAlertBanner"; export const InsecureConnectionBanner = () => { - const [isAcknowledged, setIsAcknowledged] = useState( - localStorage.getItem("insecureConnectionAcknowledged") ?? false - ); - - const handleDismiss = () => { - setIsAcknowledged(true); - localStorage.setItem("insecureConnectionAcknowledged", "true"); - }; - - if (isAcknowledged) return null; - return ( -
- - - Your connection to this Infisical instance is not secured via HTTPS. Some features may not - behave as expected. - - - - -
+ ); }; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 96da887b1..e9bb6a902 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -11,13 +11,14 @@ import { faExclamationTriangle, faInfo, faInfoCircle, + faServer, faSignOut, faUser, faUsers } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQueryClient } from "@tanstack/react-query"; -import { Link, useNavigate, useRouter, useRouterState } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router"; import { Mfa } from "@app/components/auth/Mfa"; import { createNotification } from "@app/components/notifications"; @@ -48,6 +49,7 @@ import { AuthMethod } from "@app/hooks/api/users/types"; import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils"; import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel"; +import { NotificationDropdown } from "./NotificationDropdown"; const getPlan = (subscription: SubscriptionPlan) => { if (subscription.groups) return "Enterprise"; @@ -127,6 +129,7 @@ export const Navbar = () => { const router = useRouter(); const queryClient = useQueryClient(); + const location = useLocation(); const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; @@ -188,6 +191,8 @@ export const Navbar = () => { ); } + const isServerAdminPanel = location.pathname.startsWith("/admin"); + return (
@@ -196,108 +201,134 @@ export const Navbar = () => {

/

-
- - -
-
- -
-
{currentOrg?.name}
-
-
- {getPlan(subscription)} -
- {subscription.cardDeclined && ( - -
- -
-
- )} -
-
- - -
- - - -
-
- + -
organizations
- {orgs?.map((org) => { - return ( - - + + +
+ + +
organizations
+ {orgs?.map((org) => { + return ( + + + + ); + })} +
+ } onClick={logOutUser}> + Log Out - ); - })} -
- } onClick={logOutUser}> - Log Out - - - -
-

/

- {breadcrumbs ? ( - - ) : null} + + +
+

/

+ {breadcrumbs ? ( + + ) : null} + + )}
-
+
@@ -351,9 +382,10 @@ export const Navbar = () => { )} + -
+
diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx new file mode 100644 index 000000000..59e6861fc --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx @@ -0,0 +1,60 @@ +import Markdown from "react-markdown"; +import { faCircle, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { formatDistance } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { IconButton, Tooltip } from "@app/components/v2"; +import { TUserNotification } from "@app/hooks/api/notifications/types"; + +type Props = { + notification: TUserNotification; + onDelete: (notificationId: string) => void; +}; + +export const Notification = ({ notification, onDelete }: Props) => { + return ( +
+
+
+ {!notification.isRead && ( + + )} + {notification.title}} delayDuration={300}> + + {notification.title} + + + + {formatDistance(notification.createdAt, new Date())} ago + +
+ {notification.body && ( + + {notification.body} + + )} +
+
+ { + e.stopPropagation(); + onDelete(notification.id); + }} + > + + +
+
+ ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/NotificationDropdown.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/NotificationDropdown.tsx new file mode 100644 index 000000000..a5e9166d9 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/NotificationDropdown.tsx @@ -0,0 +1,116 @@ +import { useMemo } from "react"; +import { faBell } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useRouter } from "@tanstack/react-router"; + +import { + ContentLoader, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from "@app/components/v2"; +import { + useDeleteNotification, + useMarkAllNotificationsAsRead, + useUpdateNotification +} from "@app/hooks/api/notifications/mutations"; +import { useGetMyNotifications } from "@app/hooks/api/notifications/queries"; + +import { Notification } from "./Notification"; + +export const NotificationDropdown = () => { + const router = useRouter(); + + const { data: notifications, isLoading } = useGetMyNotifications(); + const { mutate: markAllAsRead } = useMarkAllNotificationsAsRead(); + const { mutate: updateNotification } = useUpdateNotification(); + const { mutate: deleteNotification } = useDeleteNotification(); + + const unreadCount = useMemo( + () => notifications?.filter((n) => !n.isRead).length || 0, + [notifications] + ); + + return ( + + +
+ + {unreadCount > 0 && ( + + {unreadCount > 99 ? "99+" : unreadCount} + + )} +
+
+ +
+
+ Notifications + +
+
+ {isLoading && ( +
+ +
+ )} + {!isLoading && notifications?.length === 0 && ( +
+ + No new notifications + + We'll let you know when something important happens. + +
+ )} + {!isLoading && notifications && notifications.length > 0 && ( +
+ {notifications.map((notification) => ( +
{ + if (!notification.isRead) { + updateNotification({ notificationId: notification.id, isRead: true }); + } + if (notification.link) { + router.navigate({ to: notification.link }); + } + }} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + if (!notification.isRead) { + updateNotification({ notificationId: notification.id, isRead: true }); + } + if (notification.link) { + router.navigate({ to: notification.link }); + } + }} + > + +
+ ))} +
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/OrgAlertBanner.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/OrgAlertBanner.tsx new file mode 100644 index 000000000..80adce989 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/OrgAlertBanner.tsx @@ -0,0 +1,52 @@ +import { faArrowUpRightFromSquare, faWarning, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +type Props = { + text: string; + link?: string; +}; + +export const OrgAlertBanner = ({ text, link }: Props) => { + const [isDismissed, setIsDismissed] = useToggle(false); + + if (isDismissed) return null; + + return ( +
+ + {text}{" "} + {link && ( + <> + Learn how to configure it + + + here + + + + . + + )} + setIsDismissed.on()} + > + + +
+ ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/index.ts b/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/index.ts new file mode 100644 index 000000000..277fc4064 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/index.ts @@ -0,0 +1 @@ +export * from "./OrgAlertBanner"; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx index 7b2e5f154..4ab5051f8 100644 --- a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx @@ -7,7 +7,6 @@ import { faPlug, faShare, faTable, - faUserCog, faUsers, faUserTie } from "@fortawesome/free-solid-svg-icons"; @@ -181,18 +180,6 @@ export const OrgSidebar = ({ isHidden }: Props) => { Share Secret - - - -
- } - > - Organization Admin - - {user.superAdmin && ( { + return ( + + ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/RedisBanner/index.ts b/frontend/src/layouts/OrganizationLayout/components/RedisBanner/index.ts new file mode 100644 index 000000000..d5d6821e3 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/RedisBanner/index.ts @@ -0,0 +1 @@ +export * from "./RedisBanner"; diff --git a/frontend/src/layouts/OrganizationLayout/components/SmtpBanner/SmtpBanner.tsx b/frontend/src/layouts/OrganizationLayout/components/SmtpBanner/SmtpBanner.tsx new file mode 100644 index 000000000..624dde04d --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/SmtpBanner/SmtpBanner.tsx @@ -0,0 +1,10 @@ +import { OrgAlertBanner } from "../OrgAlertBanner"; + +export const SmtpBanner = () => { + return ( + + ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/SmtpBanner/index.ts b/frontend/src/layouts/OrganizationLayout/components/SmtpBanner/index.ts new file mode 100644 index 000000000..305503a79 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/SmtpBanner/index.ts @@ -0,0 +1 @@ +export * from "./SmtpBanner"; diff --git a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx index 2b4fcff80..995083ee7 100644 --- a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx +++ b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx @@ -36,7 +36,10 @@ export const AssumePrivilegeModeBanner = () => { }, { onSuccess: () => { - const url = getProjectHomePage(currentWorkspace.type); + const url = getProjectHomePage( + currentWorkspace.type, + currentWorkspace.environments + ); window.location.href = url.replace("$projectId", currentWorkspace.id); } } diff --git a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx index b66717a5c..ebc28a227 100644 --- a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx +++ b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx @@ -101,7 +101,7 @@ export const ProjectSelect = () => {
{ // to reproduce change this back to router.push and switch between two projects with different env count // look into this on dashboard revamp const url = linkOptions({ - to: getProjectHomePage(workspace.type), + to: getProjectHomePage(workspace.type, workspace.environments), params: { projectId: workspace.id } diff --git a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx index ad31d9017..0584e8e9c 100644 --- a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx +++ b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx @@ -11,7 +11,7 @@ import { faVault } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { Badge, Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; @@ -31,6 +31,7 @@ export const SecretManagerLayout = () => { const { t } = useTranslation(); const workspaceId = currentWorkspace?.id || ""; const projectSlug = currentWorkspace?.slug || ""; + const location = useLocation(); const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId @@ -73,11 +74,21 @@ export const SecretManagerLayout = () => { {({ isActive }) => ( - +
diff --git a/frontend/src/lib/crypto/index.ts b/frontend/src/lib/crypto/index.ts deleted file mode 100644 index d0d6cd5dd..000000000 --- a/frontend/src/lib/crypto/index.ts +++ /dev/null @@ -1,124 +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"; -import { issueBackupPrivateKey, srp1 } from "@app/hooks/api/auth/queries"; - -export const generateUserBackupKey = async (email: string, password: string) => { - // eslint-disable-next-line new-cap - const clientKey = new jsrp.client(); - // eslint-disable-next-line new-cap - const clientPassword = new jsrp.client(); - - await new Promise((resolve) => { - clientPassword.init({ username: email, password }, () => resolve(null)); - }); - const clientPublicKey = clientPassword.getPublicKey(); - const srpKeys = await srp1({ clientPublicKey }); - clientPassword.setSalt(srpKeys.salt); - clientPassword.setServerPublicKey(srpKeys.serverPublicKey); - - const clientProof = clientPassword.getProof(); // called M1 - const generatedKey = crypto.randomBytes(16).toString("hex"); - - await new Promise((resolve) => { - clientKey.init({ username: email, password: generatedKey }, () => resolve(null)); - }); - - const { salt, verifier } = await new Promise<{ salt: string; verifier: string }>( - (resolve, reject) => { - clientKey.createVerifier((err, res) => { - if (err) return reject(err); - return resolve(res); - }); - } - ); - const { ciphertext, iv, tag } = Aes256Gcm.encrypt({ - text: String(localStorage.getItem("PRIVATE_KEY")), - secret: generatedKey - }); - - await issueBackupPrivateKey({ - encryptedPrivateKey: ciphertext, - iv, - tag, - salt, - verifier, - clientProof - }); - - return generatedKey; -}; - -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/OrganizationResourcesPage/OrganizationResourcesPage.tsx b/frontend/src/pages/admin/AccessManagementPage/AccessManagementPage.tsx similarity index 60% rename from frontend/src/pages/admin/OrganizationResourcesPage/OrganizationResourcesPage.tsx rename to frontend/src/pages/admin/AccessManagementPage/AccessManagementPage.tsx index 34bc0e57b..8128a53e0 100644 --- a/frontend/src/pages/admin/OrganizationResourcesPage/OrganizationResourcesPage.tsx +++ b/frontend/src/pages/admin/AccessManagementPage/AccessManagementPage.tsx @@ -3,23 +3,23 @@ import { useTranslation } from "react-i18next"; import { PageHeader } from "@app/components/v2"; -import { OrganizationsTable } from "./components"; +import { ServerAdminsTable } from "./components"; -export const OrganizationResourcesPage = () => { +export const AccessManagementPage = () => { const { t } = useTranslation(); return (
- {t("common.head-title", { title: "Admin" })} + {t("common.head-title", { title: "Access Control" })}
- +
diff --git a/frontend/src/pages/admin/AccessManagementPage/components/AddServerAdminModal.tsx b/frontend/src/pages/admin/AccessManagementPage/components/AddServerAdminModal.tsx new file mode 100644 index 000000000..aa02d40e8 --- /dev/null +++ b/frontend/src/pages/admin/AccessManagementPage/components/AddServerAdminModal.tsx @@ -0,0 +1,141 @@ +import { useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2"; +import { useDebounce } from "@app/hooks"; +import { useAdminGetUsers, useAdminGrantServerAdminAccess } from "@app/hooks/api"; +import { User } from "@app/hooks/api/users/types"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onClose: () => void; +}; + +const getUserLabel = (user: Pick) => { + const { firstName, lastName, username, email } = user; + + const name = `${firstName ?? ""} ${lastName ?? ""}`.trim(); + const userEmail = email || username; + + if (!name) return userEmail; + + return `${name}${userEmail ? ` (${userEmail})` : ""}`; +}; + +const AddServerAdminSchema = z.object({ + user: z.object({ + id: z.string(), + firstName: z.string().nullish(), + lastName: z.string().nullish(), + email: z.string().nullish(), + username: z.string() + }) +}); + +type FormData = z.infer; + +const Content = ({ onClose }: ContentProps) => { + const grantAdmin = useAdminGrantServerAdminAccess(); + + const { + handleSubmit, + control, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(AddServerAdminSchema) + }); + + const [searchUserFilter, setSearchUserFilter] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500); + + const { data, isFetching } = useAdminGetUsers( + { + limit: 20, + searchTerm: debouncedSearchTerm, + adminsOnly: false + }, + { + placeholderData: (prev) => prev + } + ); + + const users = useMemo(() => data?.pages.flat().filter((user) => !user.superAdmin) ?? [], [data]); + + const onSubmit = async ({ user }: FormData) => { + try { + await grantAdmin.mutateAsync(user.id); + + createNotification({ + type: "success", + text: "Successfully granted server admin status" + }); + onClose(); + } catch { + createNotification({ + text: "Failed to grant server admin status", + type: "error" + }); + } + }; + + return ( +
+ ( + + getUserLabel(user)} + getOptionValue={(user) => user.id} + value={field.value} + onChange={field.onChange} + onInputChange={(value) => { + setSearchUserFilter(value); + if (!value) setDebouncedSearchTerm(""); + }} + /> + + )} + control={control} + name="user" + /> +
+ + +
+ + ); +}; + +export const AddServerAdminModal = ({ isOpen, onOpenChange }: Props) => { + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/pages/admin/AccessManagementPage/components/ServerAdminsTable.tsx b/frontend/src/pages/admin/AccessManagementPage/components/ServerAdminsTable.tsx new file mode 100644 index 000000000..e9298c62b --- /dev/null +++ b/frontend/src/pages/admin/AccessManagementPage/components/ServerAdminsTable.tsx @@ -0,0 +1,476 @@ +import { Dispatch, SetStateAction, useState } from "react"; +import { + faEllipsisV, + faMagnifyingGlass, + faPlus, + faShieldHalved, + faTrash, + faUsers, + faUserXmark, + faWarning, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { InfiniteData } from "@tanstack/react-query"; +import { twMerge } from "tailwind-merge"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { + Badge, + Button, + Checkbox, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { useSubscription, useUser } from "@app/context"; +import { useDebounce, usePopUp } from "@app/hooks"; +import { + useAdminBulkDeleteUsers, + useAdminDeleteUser, + useAdminGetUsers, + useRemoveUserServerAdminAccess +} from "@app/hooks/api"; +import { User } from "@app/hooks/api/users/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; +import { AddServerAdminModal } from "@app/pages/admin/AccessManagementPage/components/AddServerAdminModal"; + +const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user"; + +const ServerAdminsPanelTable = ({ + handlePopUpOpen, + users: usersPages, + isPending, + searchUserFilter, + setSearchUserFilter, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + selectedUsers, + setSelectedUsers +}: { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["removeUser", "upgradePlan", "addServerAdmin", "removeServerAdmin"] + >, + data?: { + username: string; + id: string; + message?: string; + } + ) => void; + isPending: boolean; + users: InfiniteData | undefined; + searchUserFilter: string; + setSearchUserFilter: (filter: string) => void; + selectedUsers: User[]; + setSelectedUsers: Dispatch>; + isFetchingNextPage: boolean; + fetchNextPage: () => void; + hasNextPage: boolean; +}) => { + const { subscription } = useSubscription(); + + const users = usersPages?.pages.flat(); + + const isEmpty = !isPending && !users?.length; + + const selectedUserIds = selectedUsers.map((user) => user.id); + + const isPageSelected = users?.length + ? users.every((user) => selectedUserIds.includes(user.id)) + : false; + + // eslint-disable-next-line no-nested-ternary + const isPageIndeterminate = isPageSelected + ? false + : users?.length + ? users?.some((user) => selectedUserIds.includes(user.id)) + : false; + + return ( + <> +
+ setSearchUserFilter(e.target.value)} + leftIcon={} + placeholder="Search admins..." + className="flex-1" + /> + +
+
+ + + + + + + + + + + {isPending && } + {!isPending && + users?.map((user) => { + const { username, email, firstName, lastName, id } = user; + const name = firstName || lastName ? `${firstName} ${lastName}` : null; + + const isSelected = selectedUserIds.includes(id); + return ( + + + + + + + ); + })} + +
+ { + if (isPageSelected) { + setSelectedUsers((prev) => + prev.filter((u) => !users?.find((user) => user.id === u.id)) + ); + } else { + setSelectedUsers((prev) => [ + ...prev, + ...(users?.filter((u) => !prev.find((user) => user.id === u.id)) ?? []) + ]); + } + }} + /> + NameUsername +
+ { + e.stopPropagation(); + setSelectedUsers((prev) => + isSelected ? prev.filter((u) => u.id !== id) : [...prev, user] + ); + }} + /> + +

+ {name ?? Not Set} +

+
+

{username || email}

+
+
+ + + + + + + + { + e.stopPropagation(); + handlePopUpOpen("removeUser", { username, id }); + }} + icon={} + > + Remove User + + + + +
+ } + onClick={(e) => { + e.stopPropagation(); + if (!subscription?.instanceUserManagement) { + handlePopUpOpen("upgradePlan", { + username, + id, + message: removeServerAdminUpgradePlanMessage + }); + return; + } + handlePopUpOpen("removeServerAdmin", { username, id }); + }} + > + Remove Server Admin + + + + +
+ {!isPending && isEmpty && } +
+ {!isEmpty && ( + + )} +
+ + ); +}; + +export const ServerAdminsTable = () => { + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "removeUser", + "upgradePlan", + "addServerAdmin", + "removeServerAdmin", + "removeUsers" + ] as const); + + const { + user: { id: userId } + } = useUser(); + + const { mutateAsync: deleteUser } = useAdminDeleteUser(); + const { mutateAsync: deleteUsers } = useAdminBulkDeleteUsers(); + const { mutateAsync: removeAdminAccess } = useRemoveUserServerAdminAccess(); + + const [selectedUsers, setSelectedUsers] = useState([]); + const [searchUserFilter, setSearchUserFilter] = useState(""); + const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); + + const { + data: users, + isPending, + isFetchingNextPage, + hasNextPage, + fetchNextPage + } = useAdminGetUsers({ + limit: 20, + searchTerm: debouncedSearchTerm, + adminsOnly: true + }); + + const handleRemoveUser = async () => { + const { id } = popUp?.removeUser?.data as { id: string; username: string }; + + try { + await deleteUser(id); + createNotification({ + type: "success", + text: "Successfully deleted user" + }); + } catch { + createNotification({ + type: "error", + text: "Error deleting user" + }); + } + + handlePopUpClose("removeUser"); + }; + + const handleRemoveServerAdminAccess = async () => { + const { id } = popUp?.removeServerAdmin?.data as { id: string; username: string }; + + try { + await removeAdminAccess(id); + createNotification({ + type: "success", + text: "Successfully removed server admin access from user" + }); + } catch { + createNotification({ + type: "error", + text: "Error removing server admin access from user" + }); + } + + handlePopUpClose("removeServerAdmin"); + }; + + const handleRemoveUsers = async () => { + try { + await deleteUsers(selectedUsers.map((user) => user.id)); + + createNotification({ + text: "Successfully removed users", + type: "success" + }); + + setSelectedUsers([]); + handlePopUpClose("removeUsers"); + } catch { + createNotification({ + text: "Failed to remove users", + type: "error" + }); + } + }; + + return ( + <> +
0 && "h-16" + )} + > +
+
{selectedUsers.length} Selected
+ + +
+
+
+ + handlePopUpToggle("removeUser", isOpen)} + onDeleteApproved={handleRemoveUser} + /> + handlePopUpToggle("removeServerAdmin", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleRemoveServerAdminAccess} + buttonText="Remove Access" + /> + handlePopUpToggle("addServerAdmin", isOpen)} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`} + /> + handlePopUpToggle("removeUsers", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => handleRemoveUsers()} + buttonText="Remove" + > +
+ The following users will be deleted: +
+
+
    + {selectedUsers?.map((user) => { + const email = user.email ?? user.username; + return ( +
  • +
    +

    + {user.firstName || user.lastName ? ( + <> + {`${`${user.firstName} ${user.lastName}`.trim()} `}( + {email}) + + ) : ( + {email} + )}{" "} +

    + {userId === user.id && ( + +
    + + + Deleting Yourself + +
    +
    + )} +
    +
  • + ); + })} +
+
+
+
+ + ); +}; diff --git a/frontend/src/pages/admin/AccessManagementPage/components/index.tsx b/frontend/src/pages/admin/AccessManagementPage/components/index.tsx new file mode 100644 index 000000000..fa73b9d70 --- /dev/null +++ b/frontend/src/pages/admin/AccessManagementPage/components/index.tsx @@ -0,0 +1 @@ +export * from "./ServerAdminsTable"; diff --git a/frontend/src/pages/admin/UserIdentitiesResourcesPage/route.tsx b/frontend/src/pages/admin/AccessManagementPage/route.tsx similarity index 55% rename from frontend/src/pages/admin/UserIdentitiesResourcesPage/route.tsx rename to frontend/src/pages/admin/AccessManagementPage/route.tsx index 6d1420ee5..0980fd7e3 100644 --- a/frontend/src/pages/admin/UserIdentitiesResourcesPage/route.tsx +++ b/frontend/src/pages/admin/AccessManagementPage/route.tsx @@ -1,11 +1,11 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router"; -import { UserIdentitiesResourcesPage } from "./UserIdentitiesResourcesPage"; +import { AccessManagementPage } from "./AccessManagementPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities" + "/_authenticate/_inject-org-details/admin/_admin-layout/access-management" )({ - component: UserIdentitiesResourcesPage, + component: AccessManagementPage, beforeLoad: async () => { return { breadcrumbs: [ @@ -14,9 +14,9 @@ export const Route = createFileRoute( link: linkOptions({ to: "/admin" }) }, { - label: "User Identities", + label: "Access Control", link: linkOptions({ - to: "/admin/resources/user-identities" + to: "/admin/access-management" }) } ] diff --git a/frontend/src/pages/admin/GeneralPage/GeneralPage.tsx b/frontend/src/pages/admin/GeneralPage/GeneralPage.tsx index c9215a165..e5a76e93c 100644 --- a/frontend/src/pages/admin/GeneralPage/GeneralPage.tsx +++ b/frontend/src/pages/admin/GeneralPage/GeneralPage.tsx @@ -2,11 +2,13 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { PageHeader } from "@app/components/v2"; +import { useGetServerConfig } from "@app/hooks/api/admin"; -import { GeneralPageForm } from "./components"; +import { GeneralPageForm, UsageReportSection } from "./components"; export const GeneralPage = () => { const { t } = useTranslation(); + const { data: serverConfig } = useGetServerConfig(); return (
@@ -19,7 +21,10 @@ export const GeneralPage = () => { title="General" description="Manage general settings for your Infisical instance." /> - +
+ + {serverConfig?.isOfflineUsageReportsEnabled && } +
diff --git a/frontend/src/pages/admin/GeneralPage/components/GeneralPageForm.tsx b/frontend/src/pages/admin/GeneralPage/components/GeneralPageForm.tsx index febc7f328..da15f5eea 100644 --- a/frontend/src/pages/admin/GeneralPage/components/GeneralPageForm.tsx +++ b/frontend/src/pages/admin/GeneralPage/components/GeneralPageForm.tsx @@ -103,7 +103,7 @@ export const GeneralPageForm = () => { return (
diff --git a/frontend/src/pages/admin/GeneralPage/components/UsageReportSection.tsx b/frontend/src/pages/admin/GeneralPage/components/UsageReportSection.tsx new file mode 100644 index 000000000..d05800996 --- /dev/null +++ b/frontend/src/pages/admin/GeneralPage/components/UsageReportSection.tsx @@ -0,0 +1,53 @@ +import { faDownload, faFileAlt, faSpinner } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Card, CardTitle } from "@app/components/v2"; +import { downloadFile } from "@app/helpers/download"; +import { useGenerateUsageReport } from "@app/hooks/api/admin/mutation"; + +export const UsageReportSection = () => { + const generateUsageReport = useGenerateUsageReport(); + + const handleGenerateReport = async () => { + try { + const response = await generateUsageReport.mutateAsync(); + const { csvContent, filename } = response; + + downloadFile(csvContent, filename, "text/csv"); + + createNotification({ + text: `Usage report downloaded: "${filename}"`, + type: "success" + }); + } catch (error) { + console.error("Failed to generate usage report:", error); + createNotification({ + text: "Failed to generate usage report. Please try again.", + type: "error" + }); + } + }; + + return ( + + + + Offline Usage Reports + + +
+ Generate secure usage reports for offline license compliance and billing verification. +
+ + +
+ ); +}; diff --git a/frontend/src/pages/admin/GeneralPage/components/index.ts b/frontend/src/pages/admin/GeneralPage/components/index.ts index 15ea2f5ba..2ec57dadf 100644 --- a/frontend/src/pages/admin/GeneralPage/components/index.ts +++ b/frontend/src/pages/admin/GeneralPage/components/index.ts @@ -1 +1,2 @@ export { GeneralPageForm } from "./GeneralPageForm"; +export { UsageReportSection } from "./UsageReportSection"; diff --git a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/MachineIdentitiesResourcesPage.tsx b/frontend/src/pages/admin/MachineIdentitiesResourcesPage/MachineIdentitiesResourcesPage.tsx deleted file mode 100644 index b3faefcbf..000000000 --- a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/MachineIdentitiesResourcesPage.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Helmet } from "react-helmet"; -import { useTranslation } from "react-i18next"; - -import { PageHeader } from "@app/components/v2"; - -import { MachineIdentitiesTable } from "./components"; - -export const MachineIdentitiesResourcesPage = () => { - const { t } = useTranslation(); - - return ( -
- - {t("common.head-title", { title: "Admin" })} - -
-
- - -
-
-
- ); -}; diff --git a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/index.tsx b/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/index.tsx deleted file mode 100644 index 007cc568a..000000000 --- a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { MachineIdentitiesTable } from "./MachineIdentitiesTable"; diff --git a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/route.tsx b/frontend/src/pages/admin/MachineIdentitiesResourcesPage/route.tsx deleted file mode 100644 index 359db53b2..000000000 --- a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/route.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { createFileRoute, linkOptions } from "@tanstack/react-router"; - -import { MachineIdentitiesResourcesPage } from "./MachineIdentitiesResourcesPage"; - -export const Route = createFileRoute( - "/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities" -)({ - component: MachineIdentitiesResourcesPage, - beforeLoad: async () => { - return { - breadcrumbs: [ - { - label: "Admin", - link: linkOptions({ to: "/admin" }) - }, - { - label: "Machine Identities", - link: linkOptions({ - to: "/admin/resources/machine-identities" - }) - } - ] - }; - } -}); diff --git a/frontend/src/pages/admin/OrganizationResourcesPage/components/OrganizationsTable.tsx b/frontend/src/pages/admin/OrganizationResourcesPage/components/OrganizationsTable.tsx deleted file mode 100644 index 0740d43bc..000000000 --- a/frontend/src/pages/admin/OrganizationResourcesPage/components/OrganizationsTable.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import { useState } from "react"; -import { - faBuilding, - faCircleQuestion, - faEllipsis, - faMagnifyingGlass -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { createNotification } from "@app/components/notifications"; -import { - Badge, - Button, - DeleteActionModal, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - EmptyState, - Input, - Modal, - ModalContent, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tooltip, - Tr -} from "@app/components/v2"; -import { useDebounce, usePopUp } from "@app/hooks"; -import { - useAdminDeleteOrganization, - useAdminDeleteOrganizationMembership, - useAdminDeleteUser, - useAdminGetOrganizations -} from "@app/hooks/api"; -import { OrganizationWithProjects } from "@app/hooks/api/admin/types"; -import { UsePopUpState } from "@app/hooks/usePopUp"; - -const ViewMembersModalContent = ({ - popUp, - handlePopUpOpen -}: { - popUp: UsePopUpState<["viewMembers"]>; - handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>, - data?: { - username?: string; - membershipId?: string; - userId?: string; - orgName?: string; - orgId?: string; - organization?: OrganizationWithProjects; - } - ) => void; -}) => { - const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects; - - return ( -
- {organization?.members?.map((member) => ( -
-
-
-

-

- {member.user.firstName ? ( -
- {member.user.firstName} {member.user.lastName} -
- ) : ( -

Not set

- )} -
-
-
{member.user.username || member.user.email}
- -
- {member.role.replace("-", " ")} - {Boolean(member.roleId) && ( - - - - )} -
-
-
-

-
-
-
- - - - - - - handlePopUpOpen("deleteOrganizationMembership", { - membershipId: member.membershipId, - orgId: organization.id, - username: member.user.username, - orgName: organization.name - }) - } - > - Remove From Organization - - handlePopUpOpen("deleteUser", { userId: member.user.id })} - > - Delete User - - - -
-
- ))} -
- ); -}; - -const ViewMembersModal = ({ - isOpen, - onOpenChange, - popUp, - handlePopUpOpen -}: { - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; - popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>; - handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]> - ) => void; -}) => { - return ( - - { - event.preventDefault(); - }} - title="Organization Members" - subTitle="View the members of the organization." - > - - - - ); -}; - -const OrganizationsPanelTable = ({ - popUp, - handlePopUpOpen, - handlePopUpToggle -}: { - popUp: UsePopUpState< - ["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"] - >; - handlePopUpOpen: ( - popUpName: keyof UsePopUpState< - ["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"] - >, - data?: { - orgName?: string; - orgId?: string; - message?: string; - organization?: OrganizationWithProjects; - } - ) => void; - handlePopUpToggle: ( - popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>, - isOpen?: boolean - ) => void; -}) => { - const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState(""); - const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500); - - const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = - useAdminGetOrganizations({ - limit: 20, - searchTerm: debouncedSearchTerm - }); - - const isEmpty = !isPending && !data?.pages?.[0].length; - - return ( - <> -
- setSearchOrganizationsFilter(e.target.value)} - leftIcon={} - placeholder="Search organizations..." - className="flex-1" - /> -
-
- - - - - - - - - - - {isPending && } - {!isPending && - data?.pages?.map((orgs) => - orgs.map((org) => { - return ( - - - - - - - ); - }) - )} - -
NameMembersProjects -
- {org.name ? ( - org.name - ) : ( - Not set - )} - - {org.members.length} {org.members.length === 1 ? "member" : "members"} - - - {org.projects.length} {org.projects.length === 1 ? "project" : "projects"} - -
- - -
- -
-
- - { - e.stopPropagation(); - handlePopUpOpen("deleteOrganization", { - orgId: org.id, - orgName: org.name - }); - }} - > - Delete Organization - - -
-
-
- {!isPending && isEmpty && } -
- {!isEmpty && ( - - )} -
- handlePopUpToggle("viewMembers", isOpen)} - /> - - ); -}; - -export const OrganizationsTable = () => { - const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "deleteOrganization", - "deleteOrganizationMembership", - "deleteUser", - "viewMembers" - ] as const); - - const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization(); - const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership(); - const { mutateAsync: deleteUser } = useAdminDeleteUser(); - - const handleDeleteOrganization = async () => { - const { orgId } = popUp?.deleteOrganization?.data as { orgId: string }; - - await deleteOrganization(orgId); - createNotification({ - type: "success", - text: "Successfully deleted organization" - }); - - handlePopUpClose("deleteOrganization"); - }; - - const handleDeleteOrganizationMembership = async () => { - const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as { - orgId: string; - membershipId: string; - }; - - if (!orgId || !membershipId) { - return; - } - - await deleteOrganizationMembership({ organizationId: orgId, membershipId }); - createNotification({ - type: "success", - text: "Successfully removed user from organization" - }); - - handlePopUpClose("viewMembers"); - handlePopUpClose("deleteOrganizationMembership"); - }; - - const handleDeleteUser = async () => { - const { userId } = popUp?.deleteUser?.data as { userId: string }; - - if (!userId) { - return; - } - - await deleteUser(userId); - createNotification({ - type: "success", - text: "Successfully deleted user" - }); - - handlePopUpClose("viewMembers"); - handlePopUpClose("deleteUser"); - }; - - return ( -
- - handlePopUpToggle("deleteOrganization", isOpen)} - onDeleteApproved={handleDeleteOrganization} - /> - handlePopUpToggle("deleteOrganizationMembership", isOpen)} - onDeleteApproved={handleDeleteOrganizationMembership} - /> - handlePopUpToggle("deleteUser", isOpen)} - onDeleteApproved={handleDeleteUser} - /> -
- ); -}; diff --git a/frontend/src/pages/admin/OrganizationResourcesPage/components/index.tsx b/frontend/src/pages/admin/OrganizationResourcesPage/components/index.tsx deleted file mode 100644 index 9a054c599..000000000 --- a/frontend/src/pages/admin/OrganizationResourcesPage/components/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { OrganizationsTable } from "./OrganizationsTable"; diff --git a/frontend/src/pages/admin/ResourceOverviewPage/ResourceOverviewPage.tsx b/frontend/src/pages/admin/ResourceOverviewPage/ResourceOverviewPage.tsx new file mode 100644 index 000000000..e9096d39a --- /dev/null +++ b/frontend/src/pages/admin/ResourceOverviewPage/ResourceOverviewPage.tsx @@ -0,0 +1,42 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; + +import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; + +import { MachineIdentitiesTable, OrganizationsTable, UserIdentitiesTable } from "./components"; + +export const ResourceOverviewPage = () => { + const { t } = useTranslation(); + + return ( +
+ + {t("common.head-title", { title: "Resource Overview" })} + +
+
+ + + + Organizations + Users + Identities + + + + + + + + + + + +
+
+
+ ); +}; diff --git a/frontend/src/pages/admin/ResourceOverviewPage/components/AddOrganizationModal.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/AddOrganizationModal.tsx new file mode 100644 index 000000000..9b96ac5c3 --- /dev/null +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/AddOrganizationModal.tsx @@ -0,0 +1,203 @@ +import { useMemo, useState } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { CreatableSelect } from "@app/components/v2/CreatableSelect"; +import { useDebounce } from "@app/hooks"; +import { useAdminGetUsers, useServerAdminCreateOrganization } from "@app/hooks/api"; +import { User } from "@app/hooks/api/users/types"; +import { GenericResourceNameSchema } from "@app/lib/schemas"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onClose: () => void; +}; + +type Invitee = Pick; +type NewOption = { label: string; value: string }; + +const getUserLabel = (user: Invitee | NewOption) => { + if (Object.prototype.hasOwnProperty.call(user, "value")) { + return (user as NewOption).label; + } + + const { firstName, lastName, username, email } = user as Invitee; + + const name = `${firstName ?? ""} ${lastName ?? ""}`.trim(); + const userEmail = email || username; + + if (!name) return userEmail; + + return `${name}${userEmail ? ` (${userEmail})` : ""}`; +}; + +const AddOrgSchema = z.object({ + name: GenericResourceNameSchema.nonempty("Organization name required"), + invitees: z + .object({ + id: z.string(), + firstName: z.string().nullish(), + lastName: z.string().nullish(), + email: z.string().nullish(), + username: z.string().nullish() + }) + .array() + .min(1, "At least one admin is required") +}); + +type FormData = z.infer; + +const Content = ({ onClose }: ContentProps) => { + const createOrg = useServerAdminCreateOrganization(); + + const { + handleSubmit, + control, + formState: { isSubmitting } + } = useForm({ + defaultValues: { + name: "", + invitees: [] + }, + resolver: zodResolver(AddOrgSchema) + }); + + const [searchUserFilter, setSearchUserFilter] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500); + + const { data, isFetching } = useAdminGetUsers( + { + limit: 20, + searchTerm: debouncedSearchTerm, + adminsOnly: false + }, + { + placeholderData: (prev) => prev + } + ); + + const users = useMemo(() => data?.pages.flat() ?? [], [data]); + + const onSubmit = async ({ name, invitees }: FormData) => { + try { + await createOrg.mutateAsync({ + name, + inviteAdminEmails: invitees + .filter((user) => Boolean(user.email)) + .map((user) => user.email) as string[] + }); + + createNotification({ + type: "success", + text: "Successfully created organization" + }); + onClose(); + } catch { + createNotification({ + text: "Failed to create organization", + type: "error" + }); + } + }; + + const { append } = useFieldArray({ control, name: "invitees" }); + + return ( + + ( + + + + )} + control={control} + name="name" + /> + ( + + ( +

Invite new users to this organization by typing out their email address.

+ )} + onCreateOption={(inputValue) => + append({ id: `${inputValue}_${Math.random()}`, email: inputValue }) + } + formatCreateLabel={(inputValue) => `Invite "${inputValue}"`} + isValidNewOption={(input) => + Boolean(input) && + z.string().email().safeParse(input).success && + !users + ?.flatMap((user) => { + const emails: string[] = []; + + if (user.email) { + emails.push(user.email); + } + + if (user.username) { + emails.push(user.username); + } + + return emails; + }) + .includes(input) + } + isLoading={searchUserFilter !== debouncedSearchTerm || isFetching} + className="w-full" + placeholder="Search users or invite new ones..." + isMulti + name="members" + options={users} + getOptionLabel={(user) => getUserLabel(user)} + getOptionValue={(user) => user.id} + value={field.value} + onChange={field.onChange} + onInputChange={(value) => { + setSearchUserFilter(value); + if (!value) setDebouncedSearchTerm(""); + }} + /> +
+ )} + control={control} + name="invitees" + /> +
+ + +
+ + ); +}; + +export const AddOrganizationModal = ({ isOpen, onOpenChange }: Props) => { + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx similarity index 76% rename from frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx rename to frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx index 7d4527032..6370bab54 100644 --- a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx @@ -1,5 +1,11 @@ import { useState } from "react"; -import { faEllipsis, faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons"; +import { + faEllipsisV, + faMagnifyingGlass, + faShieldHalved, + faWrench, + faXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; @@ -12,6 +18,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, EmptyState, + IconButton, Input, Table, TableContainer, @@ -88,18 +95,33 @@ const IdentityPanelTable = ({ {isInstanceAdmin && (
- -
- -
+ + + + - + {isInstanceAdmin && ( { e.stopPropagation(); handlePopUpOpen("removeServerAdmin", { name, id }); }} + icon={ +
+ + +
+ } > Remove Server Admin
@@ -114,7 +136,7 @@ const IdentityPanelTable = ({ )} - {!isPending && isEmpty && } + {!isPending && isEmpty && } {!isEmpty && ( )}
@@ -161,6 +183,12 @@ export const MachineIdentitiesTable = () => { return (
+
+
+

Machine Identities

+

Manage machine identities across your instance.

+
+
; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>, + data?: { + username?: string; + membershipId?: string; + userId?: string; + orgName?: string; + orgId?: string; + organization?: OrganizationWithProjects; + } + ) => void; +}) => { + const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects; + const [resendInviteId, setResendInviteId] = useState(null); + + const members = organization?.members ?? []; + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + orderBy, + setOrderBy, + setOrderDirection, + toggleOrderDirection + } = usePagination(MembersOrderBy.Name, { + initPerPage: ORG_MEMBERS_TABLE_LIMIT + }); + + const filteredMembers = useMemo( + () => + members + ?.filter( + ({ user: u }) => + u?.firstName?.toLowerCase().includes(search.toLowerCase()) || + u?.lastName?.toLowerCase().includes(search.toLowerCase()) || + u?.username?.toLowerCase().includes(search.toLowerCase()) || + u?.email?.toLowerCase().includes(search.toLowerCase()) + ) + .sort((a, b) => { + const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + let valueOne: string | null; + let valueTwo: string | null; + + switch (orderBy) { + case MembersOrderBy.Email: + valueOne = memberOne.user.email || memberOne.user.username; + valueTwo = memberTwo.user.email || memberTwo.user.username; + break; + case MembersOrderBy.Name: + default: + valueOne = memberOne.user.firstName ?? memberOne.user.lastName; + valueTwo = memberTwo.user.firstName ?? memberTwo.user.lastName; + } + + if (!valueOne) return 1; + if (!valueTwo) return -1; + + return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase()); + }), + [members, search, orderBy, orderDirection] + ); + + const handleSort = (column: MembersOrderBy) => { + if (column === orderBy) { + toggleOrderDirection(); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + useResetPageHelper({ + totalCount: filteredMembers.length, + offset, + setPage + }); + + const resendOrgInvite = useServerAdminResendOrgInvite(); + + const onResendInvite = async (membershipId: string) => { + setResendInviteId(membershipId); + try { + await resendOrgInvite.mutateAsync({ + membershipId, + organizationId: organization.id + }); + + createNotification({ + text: "Successfully resent org invitation", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to resend org invitation", + type: "error" + }); + } finally { + setResendInviteId(null); + } + }; + + return ( + <> + setSearch(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + + + + + + + + + + + + {filteredMembers.slice(offset, perPage * page).map((member) => { + const { username, email, firstName, lastName, id } = member.user; + const { role, status } = member; + const name = firstName || lastName ? `${firstName} ${lastName}` : null; + + return ( + + + + + + + ); + })} + +
+
+ Name + handleSort(MembersOrderBy.Name)} + > + + +
+
+
+ Email + handleSort(MembersOrderBy.Email)} + > + + +
+
+
+ Role +
+
+
+
+
+

+ {name ?? Not Set} +

+
+
+
+

{username || email}

+ {role === OrgMembershipRole.Admin && + status !== OrgMembershipStatus.Accepted && ( + + )} +
+
+ +

{member.role.replace("-", " ")}

+ {Boolean(member.roleId) && ( + + + + )} +
+
+
+ + + + + + + + } + onClick={() => + handlePopUpOpen("deleteOrganizationMembership", { + membershipId: member.membershipId, + orgId: organization.id, + username: member.user.username, + orgName: organization.name + }) + } + > + Remove From Organization + + } + onClick={() => + handlePopUpOpen("deleteUser", { userId: member.user.id }) + } + > + Delete User + + + +
+
+ {!filteredMembers.length && ( + + )} +
+ {Boolean(filteredMembers.length) && ( + + )} + + ); +}; + +const ViewMembersModal = ({ + isOpen, + onOpenChange, + popUp, + handlePopUpOpen +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]> + ) => void; +}) => { + return ( + + { + event.preventDefault(); + }} + title="Organization Members" + subTitle="View the members of the organization." + className="h-full max-w-4xl" + bodyClassName="flex flex-col h-full" + > + + + + ); +}; + +const OrganizationsPanelTable = ({ + popUp, + handlePopUpOpen, + handlePopUpToggle +}: { + popUp: UsePopUpState< + [ + "deleteOrganization", + "viewMembers", + "deleteOrganizationMembership", + "deleteUser", + "createOrganization" + ] + >; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + [ + "deleteOrganization", + "viewMembers", + "deleteOrganizationMembership", + "deleteUser", + "createOrganization" + ] + >, + data?: { + orgName?: string; + orgId?: string; + message?: string; + organization?: OrganizationWithProjects; + } + ) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>, + isOpen?: boolean + ) => void; +}) => { + const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState(""); + const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500); + + const { user } = useUser(); + + const navigate = useNavigate(); + const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = + useAdminGetOrganizations({ + limit: 20, + searchTerm: debouncedSearchTerm + }); + + const isEmpty = !isPending && !data?.pages?.[0].length; + + const { mutateAsync: accessOrganization } = useServerAdminAccessOrg(); + + const handleAccessOrg = async (orgId: string) => { + try { + await accessOrganization(orgId); + + navigate({ + to: "/login/select-organization", + search: { + org_id: orgId + } + }); + + createNotification({ + text: "Successfully joined organization", + type: "success" + }); + } catch { + createNotification({ + text: "Failed to join organization", + type: "error" + }); + } + }; + + return ( + <> + setSearchOrganizationsFilter(e.target.value)} + leftIcon={} + placeholder="Search organizations..." + className="flex-1" + /> +
+ + + + + + + + + + + {isPending && } + {!isPending && + data?.pages?.map((orgs) => + orgs.map((org) => { + const isMember = org.members.find((member) => member.user.id === user.id); + + return ( + + + + + + + ); + }) + )} + +
NameMembersProjects +
+
+ {org.name ? ( +

{org.name}

+ ) : ( + Not Set + )} +
+
+ + + {org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"} + +
+ {isMember && ( + +
+ +
+
+ )} + + + + + + + + {!isMember && ( + { + e.stopPropagation(); + handleAccessOrg(org.id); + }} + icon={} + > + Join Organization + + )} + { + e.stopPropagation(); + handlePopUpOpen("deleteOrganization", { + orgId: org.id, + orgName: org.name + }); + }} + icon={} + > + Delete Organization + + + +
+
+ {!isPending && isEmpty && } +
+ {!isEmpty && ( + + )} +
+ handlePopUpToggle("viewMembers", isOpen)} + /> + + ); +}; + +export const OrganizationsTable = () => { + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "deleteOrganization", + "deleteOrganizationMembership", + "deleteUser", + "viewMembers", + "createOrganization" + ] as const); + + const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization(); + const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership(); + const { mutateAsync: deleteUser } = useAdminDeleteUser(); + + const handleDeleteOrganization = async () => { + const { orgId } = popUp?.deleteOrganization?.data as { orgId: string }; + + await deleteOrganization(orgId); + createNotification({ + type: "success", + text: "Successfully deleted organization" + }); + + handlePopUpClose("deleteOrganization"); + }; + + const handleDeleteOrganizationMembership = async () => { + const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as { + orgId: string; + membershipId: string; + }; + + if (!orgId || !membershipId) { + return; + } + + await deleteOrganizationMembership({ organizationId: orgId, membershipId }); + createNotification({ + type: "success", + text: "Successfully removed user from organization" + }); + + handlePopUpClose("viewMembers"); + handlePopUpClose("deleteOrganizationMembership"); + }; + + const handleDeleteUser = async () => { + const { userId } = popUp?.deleteUser?.data as { userId: string }; + + if (!userId) { + return; + } + + await deleteUser(userId); + createNotification({ + type: "success", + text: "Successfully deleted user" + }); + + handlePopUpClose("viewMembers"); + handlePopUpClose("deleteUser"); + }; + + return ( +
+
+
+

Organizations

+

+ Manage, join and view organizations across your instance. +

+
+ +
+ + handlePopUpToggle("deleteOrganization", isOpen)} + onDeleteApproved={handleDeleteOrganization} + /> + handlePopUpToggle("deleteOrganizationMembership", isOpen)} + onDeleteApproved={handleDeleteOrganizationMembership} + /> + handlePopUpToggle("deleteUser", isOpen)} + onDeleteApproved={handleDeleteUser} + /> + handlePopUpToggle("createOrganization", isOpen)} + /> +
+ ); +}; diff --git a/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx new file mode 100644 index 000000000..9856ee497 --- /dev/null +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx @@ -0,0 +1,573 @@ +import { Dispatch, SetStateAction, useState } from "react"; +import { + faCheckCircle, + faEllipsisV, + faFilter, + faMagnifyingGlass, + faShieldHalved, + faTrash, + faUsers, + faUserShield, + faUserXmark, + faWarning, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { InfiniteData } from "@tanstack/react-query"; +import { twMerge } from "tailwind-merge"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { + Badge, + Button, + Checkbox, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { useSubscription, useUser } from "@app/context"; +import { useDebounce, usePopUp } from "@app/hooks"; +import { + useAdminBulkDeleteUsers, + useAdminDeleteUser, + useAdminGetUsers, + useAdminGrantServerAdminAccess, + useRemoveUserServerAdminAccess +} from "@app/hooks/api"; +import { User } from "@app/hooks/api/users/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const addServerAdminUpgradePlanMessage = "Granting another user Server Admin permissions"; +const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user"; + +const UserPanelTable = ({ + handlePopUpOpen, + users: usersPages, + isPending, + adminsOnly, + searchUserFilter, + setSearchUserFilter, + setAdminsOnly, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + selectedUsers, + setSelectedUsers +}: { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["removeUser", "upgradePlan", "upgradeToServerAdmin", "removeServerAdmin"] + >, + data?: { + username: string; + id: string; + message?: string; + } + ) => void; + isPending: boolean; + users: InfiniteData | undefined; + adminsOnly: boolean; + setAdminsOnly: (adminsOnly: boolean) => void; + searchUserFilter: string; + setSearchUserFilter: (filter: string) => void; + selectedUsers: User[]; + setSelectedUsers: Dispatch>; + isFetchingNextPage: boolean; + fetchNextPage: () => void; + hasNextPage: boolean; +}) => { + const { subscription } = useSubscription(); + + const users = usersPages?.pages.flat(); + + const isEmpty = !isPending && !users?.length; + const isTableFiltered = Boolean(adminsOnly); + + const selectedUserIds = selectedUsers.map((user) => user.id); + + const isPageSelected = users?.length + ? users.every((user) => selectedUserIds.includes(user.id)) + : false; + + // eslint-disable-next-line no-nested-ternary + const isPageIndeterminate = isPageSelected + ? false + : users?.length + ? users?.some((user) => selectedUserIds.includes(user.id)) + : false; + + return ( + <> +
+ setSearchUserFilter(e.target.value)} + leftIcon={} + placeholder="Search users..." + className="flex-1" + /> + + + + + + + + Filter By + { + e.preventDefault(); + setAdminsOnly(!adminsOnly); + }} + icon={adminsOnly && } + iconPos="right" + > +
+ + Server Admins +
+
+
+
+
+
+ + + + + + + + + + + {isPending && } + {!isPending && + users?.map((user) => { + const { username, email, firstName, lastName, id, superAdmin } = user; + const name = firstName || lastName ? `${firstName} ${lastName}` : null; + + const isSelected = selectedUserIds.includes(id); + return ( + + + + + + + ); + })} + +
+ { + if (isPageSelected) { + setSelectedUsers((prev) => + prev.filter((u) => !users?.find((user) => user.id === u.id)) + ); + } else { + setSelectedUsers((prev) => [ + ...prev, + ...(users?.filter((u) => !prev.find((user) => user.id === u.id)) ?? []) + ]); + } + }} + /> + NameUsername +
+ { + e.stopPropagation(); + setSelectedUsers((prev) => + isSelected ? prev.filter((u) => u.id !== id) : [...prev, user] + ); + }} + /> + +
+

+ {name ?? Not Set} +

+ {superAdmin && ( + + Server Admin + + )} +
+
+

{username || email}

+
+
+ + + + + + + + { + e.stopPropagation(); + handlePopUpOpen("removeUser", { username, id }); + }} + icon={} + > + Remove User + + {!superAdmin && ( + } + onClick={(e) => { + e.stopPropagation(); + if (!subscription?.instanceUserManagement) { + handlePopUpOpen("upgradePlan", { + username, + id, + message: addServerAdminUpgradePlanMessage + }); + return; + } + handlePopUpOpen("upgradeToServerAdmin", { username, id }); + }} + > + Make User Server Admin + + )} + {superAdmin && ( + + + +
+ } + onClick={(e) => { + e.stopPropagation(); + if (!subscription?.instanceUserManagement) { + handlePopUpOpen("upgradePlan", { + username, + id, + message: removeServerAdminUpgradePlanMessage + }); + return; + } + handlePopUpOpen("removeServerAdmin", { username, id }); + }} + > + Remove Server Admin + + )} + + + +
+ {!isPending && isEmpty && } +
+ {!isEmpty && ( + + )} +
+ + ); +}; + +export const UserIdentitiesTable = () => { + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "removeUser", + "upgradePlan", + "upgradeToServerAdmin", + "removeServerAdmin", + "removeUsers" + ] as const); + + const { + user: { id: userId } + } = useUser(); + + const { mutateAsync: deleteUser } = useAdminDeleteUser(); + const { mutateAsync: deleteUsers } = useAdminBulkDeleteUsers(); + const { mutateAsync: grantAdminAccess } = useAdminGrantServerAdminAccess(); + const { mutateAsync: removeAdminAccess } = useRemoveUserServerAdminAccess(); + + const [selectedUsers, setSelectedUsers] = useState([]); + const [searchUserFilter, setSearchUserFilter] = useState(""); + const [adminsOnly, setAdminsOnly] = useState(false); + const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); + + const { + data: users, + isPending, + isFetchingNextPage, + hasNextPage, + fetchNextPage + } = useAdminGetUsers({ + limit: 20, + searchTerm: debouncedSearchTerm, + adminsOnly + }); + + const handleRemoveUser = async () => { + const { id } = popUp?.removeUser?.data as { id: string; username: string }; + + try { + await deleteUser(id); + createNotification({ + type: "success", + text: "Successfully deleted user" + }); + } catch { + createNotification({ + type: "error", + text: "Error deleting user" + }); + } + + handlePopUpClose("removeUser"); + }; + + const handleGrantServerAdminAccess = async () => { + const { id } = popUp?.upgradeToServerAdmin?.data as { id: string; username: string }; + + try { + await grantAdminAccess(id); + createNotification({ + type: "success", + text: "Successfully granted server admin access to user" + }); + } catch { + createNotification({ + type: "error", + text: "Error granting server admin access to user" + }); + } + + handlePopUpClose("upgradeToServerAdmin"); + }; + + const handleRemoveServerAdminAccess = async () => { + const { id } = popUp?.removeServerAdmin?.data as { id: string; username: string }; + + try { + await removeAdminAccess(id); + createNotification({ + type: "success", + text: "Successfully removed server admin access from user" + }); + } catch { + createNotification({ + type: "error", + text: "Error removing server admin access from user" + }); + } + + handlePopUpClose("removeServerAdmin"); + }; + + const handleRemoveUsers = async () => { + try { + await deleteUsers(selectedUsers.map((user) => user.id)); + + createNotification({ + text: "Successfully removed users", + type: "success" + }); + + setSelectedUsers([]); + handlePopUpClose("removeUsers"); + } catch { + createNotification({ + text: "Failed to remove users", + type: "error" + }); + } + }; + + return ( + <> +
0 && "h-16" + )} + > +
+
{selectedUsers.length} Selected
+ + +
+
+
+
+
+

User Identities

+

Manage user identities across your instance.

+
+
+ + handlePopUpToggle("removeUser", isOpen)} + onDeleteApproved={handleRemoveUser} + /> + handlePopUpToggle("upgradeToServerAdmin", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleGrantServerAdminAccess} + buttonText="Grant Access" + /> + handlePopUpToggle("removeServerAdmin", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleRemoveServerAdminAccess} + buttonText="Remove Access" + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`} + /> + handlePopUpToggle("removeUsers", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => handleRemoveUsers()} + buttonText="Delete" + > +
+ The following users will be deleted: +
+
+
    + {selectedUsers?.map((user) => { + const email = user.email ?? user.username; + return ( +
  • +
    +

    + {user.firstName || user.lastName ? ( + <> + {`${`${user.firstName} ${user.lastName}`.trim()} `}( + {email}) + + ) : ( + {email} + )}{" "} +

    + {userId === user.id && ( + +
    + + + Deleting Yourself + +
    +
    + )} +
    +
  • + ); + })} +
+
+
+
+ + ); +}; diff --git a/frontend/src/pages/admin/ResourceOverviewPage/components/index.ts b/frontend/src/pages/admin/ResourceOverviewPage/components/index.ts new file mode 100644 index 000000000..ce70dee05 --- /dev/null +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/index.ts @@ -0,0 +1,3 @@ +export * from "./MachineIdentitiesTable"; +export * from "./OrganizationsTable"; +export * from "./UserIdentitiesTable"; diff --git a/frontend/src/pages/admin/OrganizationResourcesPage/route.tsx b/frontend/src/pages/admin/ResourceOverviewPage/route.tsx similarity index 66% rename from frontend/src/pages/admin/OrganizationResourcesPage/route.tsx rename to frontend/src/pages/admin/ResourceOverviewPage/route.tsx index 9b0bf8f12..ea27c1a06 100644 --- a/frontend/src/pages/admin/OrganizationResourcesPage/route.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/route.tsx @@ -1,11 +1,11 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router"; -import { OrganizationResourcesPage } from "./OrganizationResourcesPage"; +import { ResourceOverviewPage } from "./ResourceOverviewPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations" + "/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview" )({ - component: OrganizationResourcesPage, + component: ResourceOverviewPage, beforeLoad: async () => { return { breadcrumbs: [ @@ -14,9 +14,9 @@ export const Route = createFileRoute( link: linkOptions({ to: "/admin" }) }, { - label: "Organizations", + label: "Resource Overview", link: linkOptions({ - to: "/admin/resources/organizations" + to: "/admin/resources/overview" }) } ] diff --git a/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx b/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx index 2d6aeb9d6..f7f3649ca 100644 --- a/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx +++ b/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx @@ -8,12 +8,10 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; // TODO(akhilmhdh): rewrite this into module functions in lib -import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage"; 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({ @@ -49,27 +47,14 @@ 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); - saveTokenToLocalStorage({ - publicKey: userPass.publicKey, - encryptedPrivateKey: userPass.encryptedPrivateKey, - iv: userPass.encryptedPrivateKeyIV, - tag: userPass.encryptedPrivateKeyTag, - privateKey - }); await selectOrganization({ organizationId: res.organization.id }); // TODO(akhilmhdh): This is such a confusing pattern and too unreliable diff --git a/frontend/src/pages/admin/UserIdentitiesResourcesPage/UserIdentitiesResourcesPage.tsx b/frontend/src/pages/admin/UserIdentitiesResourcesPage/UserIdentitiesResourcesPage.tsx deleted file mode 100644 index 35c4d9466..000000000 --- a/frontend/src/pages/admin/UserIdentitiesResourcesPage/UserIdentitiesResourcesPage.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Helmet } from "react-helmet"; -import { useTranslation } from "react-i18next"; - -import { PageHeader } from "@app/components/v2"; - -import { UserIdentitiesTable } from "./components"; - -export const UserIdentitiesResourcesPage = () => { - const { t } = useTranslation(); - - return ( -
- - {t("common.head-title", { title: "Admin" })} - -
-
- - -
-
-
- ); -}; diff --git a/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx b/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx deleted file mode 100644 index 500796797..000000000 --- a/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import { useState } from "react"; -import { - faCheckCircle, - faEllipsis, - faFilter, - faMagnifyingGlass, - faUsers, - faUserShield -} 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"; -import { - Badge, - Button, - DeleteActionModal, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - EmptyState, - IconButton, - Input, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr -} from "@app/components/v2"; -import { useSubscription } from "@app/context"; -import { useDebounce, usePopUp } from "@app/hooks"; -import { - useAdminDeleteUser, - useAdminGetUsers, - useAdminGrantServerAdminAccess, - useRemoveUserServerAdminAccess -} from "@app/hooks/api"; -import { UsePopUpState } from "@app/hooks/usePopUp"; - -const addServerAdminUpgradePlanMessage = "Granting another user Server Admin permissions"; -const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user"; - -const UserPanelTable = ({ - handlePopUpOpen -}: { - handlePopUpOpen: ( - popUpName: keyof UsePopUpState< - ["removeUser", "upgradePlan", "upgradeToServerAdmin", "removeServerAdmin"] - >, - data?: { - username: string; - id: string; - message?: string; - } - ) => void; -}) => { - const [searchUserFilter, setSearchUserFilter] = useState(""); - const [adminsOnly, setAdminsOnly] = useState(false); - const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); - const { subscription } = useSubscription(); - - const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({ - limit: 20, - searchTerm: debouncedSearchTerm, - adminsOnly - }); - - const isEmpty = !isPending && !data?.pages?.[0].length; - const isTableFiltered = Boolean(adminsOnly); - - return ( - <> -
- setSearchUserFilter(e.target.value)} - leftIcon={} - placeholder="Search users..." - className="flex-1" - /> - - - - - - - - Filter By - { - e.preventDefault(); - setAdminsOnly(!adminsOnly); - }} - icon={adminsOnly && } - iconPos="right" - > -
- - Server Admins -
-
-
-
-
-
- - - - - - - - - - {isPending && } - {!isPending && - data?.pages?.map((users) => - users.map(({ username, email, firstName, lastName, id, superAdmin }) => { - const name = firstName || lastName ? `${firstName} ${lastName}` : "-"; - - return ( - - - - - - ); - }) - )} - -
NameUsername -
- {name} - {superAdmin && ( - - Server Admin - - )} - {email} -
- - -
- -
-
- - { - e.stopPropagation(); - handlePopUpOpen("removeUser", { username, id }); - }} - > - Remove User - - {!superAdmin && ( - { - e.stopPropagation(); - if (!subscription?.instanceUserManagement) { - handlePopUpOpen("upgradePlan", { - username, - id, - message: addServerAdminUpgradePlanMessage - }); - return; - } - handlePopUpOpen("upgradeToServerAdmin", { username, id }); - }} - > - Make User Server Admin - - )} - {superAdmin && ( - { - e.stopPropagation(); - if (!subscription?.instanceUserManagement) { - handlePopUpOpen("upgradePlan", { - username, - id, - message: removeServerAdminUpgradePlanMessage - }); - return; - } - handlePopUpOpen("removeServerAdmin", { username, id }); - }} - > - Remove Server Admin - - )} - -
-
-
- {!isPending && isEmpty && } -
- {!isEmpty && ( - - )} -
- - ); -}; - -export const UserIdentitiesTable = () => { - const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "removeUser", - "upgradePlan", - "upgradeToServerAdmin", - "removeServerAdmin" - ] as const); - - const { mutateAsync: deleteUser } = useAdminDeleteUser(); - const { mutateAsync: grantAdminAccess } = useAdminGrantServerAdminAccess(); - const { mutateAsync: removeAdminAccess } = useRemoveUserServerAdminAccess(); - - const handleRemoveUser = async () => { - const { id } = popUp?.removeUser?.data as { id: string; username: string }; - - try { - await deleteUser(id); - createNotification({ - type: "success", - text: "Successfully deleted user" - }); - } catch { - createNotification({ - type: "error", - text: "Error deleting user" - }); - } - - handlePopUpClose("removeUser"); - }; - - const handleGrantServerAdminAccess = async () => { - const { id } = popUp?.upgradeToServerAdmin?.data as { id: string; username: string }; - - try { - await grantAdminAccess(id); - createNotification({ - type: "success", - text: "Successfully granted server admin access to user" - }); - } catch { - createNotification({ - type: "error", - text: "Error granting server admin access to user" - }); - } - - handlePopUpClose("upgradeToServerAdmin"); - }; - - const handleRemoveServerAdminAccess = async () => { - const { id } = popUp?.removeServerAdmin?.data as { id: string; username: string }; - - try { - await removeAdminAccess(id); - createNotification({ - type: "success", - text: "Successfully removed server admin access from user" - }); - } catch { - createNotification({ - type: "error", - text: "Error removing server admin access from user" - }); - } - - handlePopUpClose("removeServerAdmin"); - }; - - return ( -
- - handlePopUpToggle("removeUser", isOpen)} - onDeleteApproved={handleRemoveUser} - /> - handlePopUpToggle("upgradeToServerAdmin", isOpen)} - deleteKey="confirm" - onDeleteApproved={handleGrantServerAdminAccess} - buttonText="Grant Access" - /> - handlePopUpToggle("removeServerAdmin", isOpen)} - deleteKey="confirm" - onDeleteApproved={handleRemoveServerAdminAccess} - buttonText="Remove Access" - /> - handlePopUpToggle("upgradePlan", isOpen)} - text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`} - /> -
- ); -}; diff --git a/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/index.tsx b/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/index.tsx deleted file mode 100644 index 3fe6b81f3..000000000 --- a/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { UserIdentitiesTable } from "./UserIdentitiesTable"; diff --git a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx index 91432f9be..9666a1af4 100644 --- a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx @@ -11,14 +11,14 @@ import { createNotification } from "@app/components/notifications"; import attemptCliLogin from "@app/components/utilities/attemptCliLogin"; import attemptLogin from "@app/components/utilities/attemptLogin"; import SecurityClient from "@app/components/utilities/SecurityClient"; -import { Button, Input, Spinner } from "@app/components/v2"; +import { Button, ContentLoader, Input } from "@app/components/v2"; import { envConfig } from "@app/config/env"; import { SessionStorageKeys } from "@app/const"; import { useToggle } from "@app/hooks"; import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -import { fetchMyPrivateKey, fetchUserDuplicateAccounts } from "@app/hooks/api/users/queries"; +import { fetchUserDuplicateAccounts } from "@app/hooks/api/users/queries"; import { EmailDuplicationConfirmation } from "@app/pages/auth/SelectOrgPage/EmailDuplicationConfirmation"; import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils"; @@ -70,9 +70,6 @@ export const PasswordStep = ({ // set JWT token SecurityClient.setToken(oauthLogin.token); - const privateKey = await fetchMyPrivateKey(); - localStorage.setItem("PRIVATE_KEY", privateKey); - // case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org if (organizationId) { const finishWithOrgWorkflow = async () => { @@ -92,7 +89,7 @@ export const PasswordStep = ({ console.log("organization id was present. new JWT token to be used in CLI:", token); const instance = axios.create(); const payload = { - privateKey, + privateKey: "", // note(daniel): no longer needed by the CLI, because the CLI only uses the private key to create service tokens, and the private key isn't used anymore when creating service tokens. email, JTWToken: token }; @@ -329,9 +326,8 @@ export const PasswordStep = ({ if (hasExchangedPrivateKey) { return ( -
- -

Loading, please wait

+
+
); } diff --git a/frontend/src/pages/auth/PasswordSetupPage/PasswordSetupPage.tsx b/frontend/src/pages/auth/PasswordSetupPage/PasswordSetupPage.tsx index 311d613ef..a134220e8 100644 --- a/frontend/src/pages/auth/PasswordSetupPage/PasswordSetupPage.tsx +++ b/frontend/src/pages/auth/PasswordSetupPage/PasswordSetupPage.tsx @@ -1,22 +1,14 @@ -import crypto from "crypto"; - import { FormEvent, useState } from "react"; import { faCheck, faEye, faEyeSlash, faKey, faX } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import jsrp from "jsrp"; import { createNotification } from "@app/components/notifications"; import passwordCheck from "@app/components/utilities/checks/password/PasswordCheck"; -import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; -import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto"; import { Button, Card, CardTitle, FormControl, Input } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; import { useSetupPassword } from "@app/hooks/api/auth/queries"; -// eslint-disable-next-line new-cap -const client = new jsrp.client(); - export const PasswordSetupPage = () => { const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); @@ -65,83 +57,31 @@ export const PasswordSetupPage = () => { setPasswordsMatch(true); if (!errorCheck) { - client.init( - { - username: email, + try { + await setupPassword.mutateAsync({ + email, + token, password - }, - async () => { - client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => { - const derivedKey = await deriveArgonKey({ - password, - salt: result.salt, - mem: 65536, - time: 3, - parallelism: 1, - hashLen: 32 - }); + }); - if (!derivedKey) throw new Error("Failed to derive key from password"); + setIsRedirecting(true); - const key = crypto.randomBytes(32); + createNotification({ + type: "success", + title: "Password successfully set", + text: "Redirecting to login..." + }); - // create encrypted private key by encrypting the private - // key with the symmetric key [key] - const { - ciphertext: encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag - } = Aes256Gcm.encrypt({ - text: localStorage.getItem("PRIVATE_KEY") as string, - 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) - }); - - try { - await setupPassword.mutateAsync({ - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt: result.salt, - verifier: result.verifier, - token, - password - }); - - setIsRedirecting(true); - - createNotification({ - type: "success", - title: "Password successfully set", - text: "Redirecting to login..." - }); - - setTimeout(() => { - window.location.href = "/login"; - }, 3000); - } catch (error) { - createNotification({ - type: "error", - text: (error as Error).message ?? "Error setting password" - }); - navigate({ to: "/personal-settings" }); - } - }); - } - ); + setTimeout(() => { + window.location.href = "/login"; + }, 3000); + } catch (error) { + createNotification({ + type: "error", + text: (error as Error).message ?? "Error setting password" + }); + navigate({ to: "/personal-settings" }); + } } }; diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx index f66be2d4b..b22f0f0fa 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx @@ -1,26 +1,21 @@ import { useState } from "react"; -import { Spinner } from "@app/components/v2"; +import { ContentLoader } from "@app/components/v2"; import { useGetMyDuplicateAccount } from "@app/hooks/api"; import { EmailDuplicationConfirmation } from "./EmailDuplicationConfirmation"; import { SelectOrganizationSection } from "./SelectOrgSection"; -const LoadingScreen = () => { - return ( -
- -

Loading, please wait

-
- ); -}; - export const SelectOrganizationPage = () => { const duplicateAccounts = useGetMyDuplicateAccount(); const [removeDuplicateLater, setRemoveDuplicateLater] = useState(false); if (duplicateAccounts.isPending) { - return ; + return ( +
+ +
+ ); } if (duplicateAccounts.data?.duplicateAccounts?.length && !removeDuplicateLater) { diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index ac194cd05..4dd0bbe24 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -12,7 +12,7 @@ import { Mfa } from "@app/components/auth/Mfa"; import { createNotification } from "@app/components/notifications"; import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin"; import SecurityClient from "@app/components/utilities/SecurityClient"; -import { Button, Spinner } from "@app/components/v2"; +import { Button, ContentLoader, Spinner } from "@app/components/v2"; import { SessionStorageKeys } from "@app/const"; import { OrgMembershipRole } from "@app/helpers/roles"; import { useToggle } from "@app/hooks"; @@ -29,15 +29,6 @@ import { AuthMethod } from "@app/hooks/api/users/types"; import { navigateUserToOrg } from "../LoginPage/Login.utils"; -const LoadingScreen = () => { - return ( -
- -

Loading, please wait

-
- ); -}; - export const SelectOrganizationSection = () => { const navigate = useNavigate(); const { t } = useTranslation(); @@ -91,16 +82,25 @@ 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) { + if (organization.googleSsoAuthEnforced) { + if (authToken.authMethod !== AuthMethod.GOOGLE) { + url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`; + + if (callbackPort) { + url += `&callback_port=${callbackPort}`; + } + } + } else 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) { @@ -108,8 +108,13 @@ export const SelectOrganizationSection = () => { } } - 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 @@ -132,11 +137,8 @@ export const SelectOrganizationSection = () => { } if (callbackPort) { - const privateKey = localStorage.getItem("PRIVATE_KEY"); - let error: string | null = null; - if (!privateKey) error = "Private key not found"; if (!user?.email) error = "User email not found"; if (!token) error = "No token found"; @@ -151,7 +153,7 @@ export const SelectOrganizationSection = () => { const payload = { JTWToken: token, email: user?.email, - privateKey + privateKey: "" } as IsCliLoginSuccessful["loginResponse"]; // send request to server endpoint @@ -228,7 +230,11 @@ export const SelectOrganizationSection = () => { !user || ((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa) ) { - return ; + return ( +
+ +
+ ); } return ( diff --git a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx index a4ebe7a11..e05e0f2ad 100644 --- a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx +++ b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx @@ -1,23 +1,14 @@ -/* eslint-disable no-nested-ternary */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import crypto from "crypto"; - import { useState } from "react"; import { Helmet } from "react-helmet"; import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; -import jsrp from "jsrp"; import { Mfa } from "@app/components/auth/Mfa"; import InputField from "@app/components/basic/InputField"; import checkPassword from "@app/components/utilities/checks/password/checkPassword"; -import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; -import { deriveArgonKey, generateKeyPair } from "@app/components/utilities/cryptography/crypto"; -import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button } from "@app/components/v2"; -import { useServerConfig } from "@app/context"; import { useToggle } from "@app/hooks"; import { completeAccountSignupInvite, @@ -28,9 +19,6 @@ import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { isLoggedIn } from "@app/hooks/api/reactQuery"; -// eslint-disable-next-line new-cap -const client = new jsrp.client(); - type Errors = { tooShort?: string; tooLong?: string; @@ -67,7 +55,6 @@ export const SignupInvitePage = () => { const metadata = queryParams.get("metadata") || undefined; const { mutateAsync: selectOrganization } = useSelectOrganization(); - const { config } = useServerConfig(); const loggedIn = isLoggedIn(); @@ -94,123 +81,56 @@ export const SignupInvitePage = () => { } if (!errorCheck) { - // Generate a random pair of a public and a private key - const { publicKey, privateKey } = await generateKeyPair(config.fipsEnabled); + try { + const { token: jwtToken } = await completeAccountSignupInvite({ + email, + password, + firstName, + lastName, + tokenMetadata: metadata + }); - localStorage.setItem("PRIVATE_KEY", privateKey); + // unset temporary signup JWT token and set JWT token + SecurityClient.setSignupToken(""); + SecurityClient.setToken(jwtToken); - client.init( - { - username: email, - password - }, - async () => { - client.createVerifier(async (_err, result) => { - try { - const derivedKey = await deriveArgonKey({ - password, - salt: result.salt, - mem: 65536, - time: 3, - parallelism: 1, - hashLen: 32 - }); + const userOrgs = await fetchOrganizations(); - if (!derivedKey) throw new Error("Failed to derive key from password"); + const orgId = userOrgs[0].id; - const key = crypto.randomBytes(32); + if (!orgId) throw new Error("You are not part of any organization"); - // 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) - }); - - const { token: jwtToken } = await completeAccountSignupInvite({ - email, - password, - firstName, - lastName, - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt: result.salt, - verifier: result.verifier, - tokenMetadata: metadata - }); - - // unset temporary signup JWT token and set JWT token - SecurityClient.setSignupToken(""); - SecurityClient.setToken(jwtToken); - - saveTokenToLocalStorage({ - publicKey, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - privateKey - }); - - const userOrgs = await fetchOrganizations(); - - const orgId = userOrgs[0].id; - - if (!orgId) throw new Error("You are not part of any organization"); - - const completeSignupFlow = async () => { - const { - token: mfaToken, - isMfaEnabled, - mfaMethod - } = await selectOrganization({ - organizationId: orgId - }); - - if (isMfaEnabled) { - SecurityClient.setMfaToken(mfaToken); - if (mfaMethod) { - setRequiredMfaMethod(mfaMethod); - } - toggleShowMfa.on(); - setMfaSuccessCallback(() => completeSignupFlow); - return; - } - - localStorage.setItem("orgData.id", orgId); - - navigate({ - to: "/organization/projects" - }); - }; - - await completeSignupFlow(); - } catch (error) { - setIsLoading(false); - console.error(error); - } + const completeSignupFlow = async () => { + const { + token: mfaToken, + isMfaEnabled, + mfaMethod + } = await selectOrganization({ + organizationId: orgId }); - } - ); + + if (isMfaEnabled) { + SecurityClient.setMfaToken(mfaToken); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } + toggleShowMfa.on(); + setMfaSuccessCallback(() => completeSignupFlow); + return; + } + + localStorage.setItem("orgData.id", orgId); + + navigate({ + to: "/organization/projects" + }); + }; + + await completeSignupFlow(); + } catch (error) { + setIsLoading(false); + console.error(error); + } } else { setIsLoading(false); } diff --git a/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx index a200d3f7c..983b1ec61 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -114,7 +114,16 @@ export const EmailConfirmationStep = ({ const resendCode = async () => { try { - await sendEmailVerificationCode(username); + const queryParams = new URLSearchParams(window.location.search); + const token = queryParams.get("token"); + if (!token) { + createNotification({ + text: "Failed to resend code, no token found", + type: "error" + }); + return; + } + await sendEmailVerificationCode(token); createNotification({ text: "Successfully resent code", type: "success" diff --git a/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx index 85343e79e..1b10ed94c 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -3,24 +3,16 @@ import crypto from "crypto"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "@tanstack/react-router"; -import jsrp from "jsrp"; import { Mfa } from "@app/components/auth/Mfa"; -import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; -import { deriveArgonKey, generateKeyPair } from "@app/components/utilities/cryptography/crypto"; -import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button, Input } from "@app/components/v2"; -import { useServerConfig } from "@app/context"; import { initProjectHelper } from "@app/helpers/project"; import { useToggle } from "@app/hooks"; import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -// eslint-disable-next-line new-cap -const client = new jsrp.client(); - type Props = { username: string; password: string; @@ -64,7 +56,6 @@ export const UserInfoSSOStep = ({ const { mutateAsync: selectOrganization } = useSelectOrganization(); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const navigate = useNavigate(); - const { config } = useServerConfig(); useEffect(() => { const randomPassword = crypto.randomBytes(32).toString("hex"); @@ -93,131 +84,64 @@ export const UserInfoSSOStep = ({ } if (!errorCheck) { - // Generate a random pair of a public and a private key - const { publicKey, privateKey } = await generateKeyPair(config.fipsEnabled); - localStorage.setItem("PRIVATE_KEY", privateKey); + try { + const response = await completeAccountSignup({ + email: username, + password, + firstName: name.split(" ")[0], + lastName: name.split(" ").slice(1).join(" "), + providerAuthToken, + organizationName, + attributionSource, + useDefaultOrg: forceDefaultOrg + }); - client.init( - { - username, - password - }, - async () => { - client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => { - try { - // TODO: moduralize into KeyService - const derivedKey = await deriveArgonKey({ - password, - salt: result.salt, - mem: 65536, - time: 3, - parallelism: 1, - hashLen: 32 - }); + // unset signup JWT token and set JWT token + SecurityClient.setSignupToken(""); + SecurityClient.setToken(response.token); + SecurityClient.setProviderAuthToken(""); - if (!derivedKey) throw new Error("Failed to derive key from password"); + const userOrgs = await fetchOrganizations(); + const orgId = userOrgs[0]?.id; - const key = crypto.randomBytes(32); + const completeSignupFlow = async () => { + try { + const { isMfaEnabled, token, mfaMethod } = await selectOrganization({ + organizationId: orgId + }); - // 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) - }); - - const response = await completeAccountSignup({ - email: username, - password, - firstName: name.split(" ")[0], - lastName: name.split(" ").slice(1).join(" "), - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - providerAuthToken, - salt: result.salt, - verifier: result.verifier, - organizationName, - attributionSource, - useDefaultOrg: forceDefaultOrg - }); - - // unset signup JWT token and set JWT token - SecurityClient.setSignupToken(""); - SecurityClient.setToken(response.token); - SecurityClient.setProviderAuthToken(""); - - saveTokenToLocalStorage({ - publicKey, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - privateKey - }); - - const userOrgs = await fetchOrganizations(); - const orgId = userOrgs[0]?.id; - - const completeSignupFlow = async () => { - try { - const { isMfaEnabled, token, mfaMethod } = await selectOrganization({ - organizationId: orgId - }); - - if (isMfaEnabled) { - SecurityClient.setMfaToken(token); - if (mfaMethod) { - setRequiredMfaMethod(mfaMethod); - } - toggleShowMfa.on(); - setMfaSuccessCallback(() => completeSignupFlow); - return; - } - - // only create example project if not joining existing org - if (!providerOrganizationName) { - await initProjectHelper({ - projectName: "Example Project" - }); - } - - localStorage.setItem("orgData.id", orgId); - navigate({ - to: "/organization/projects" - }); - } catch (error) { - setIsLoading(false); - console.error(error); - } - }; - - await completeSignupFlow(); - } catch (error) { - setIsLoading(false); - console.error(error); + if (isMfaEnabled) { + SecurityClient.setMfaToken(token); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } + toggleShowMfa.on(); + setMfaSuccessCallback(() => completeSignupFlow); + return; } - }); - } - ); + + // only create example project if not joining existing org + if (!providerOrganizationName) { + await initProjectHelper({ + projectName: "Example Project" + }); + } + + localStorage.setItem("orgData.id", orgId); + navigate({ + to: "/organization/projects" + }); + } catch (error) { + setIsLoading(false); + console.error(error); + } + }; + + await completeSignupFlow(); + } catch (error) { + setIsLoading(false); + console.error(error); + } } else { setIsLoading(false); } diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index b7d91a851..68757f549 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -1,5 +1,6 @@ -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"; import { z } from "zod"; @@ -16,7 +17,15 @@ import { Switch } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { + TAvailableAppConnection, + useListAvailableAppConnections +} from "@app/hooks/api/appConnections"; +import { + TCloudflareZone, + useCloudflareConnectionListZones +} from "@app/hooks/api/appConnections/cloudflare"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AcmeDnsProvider, @@ -26,32 +35,84 @@ import { useGetCa, useUpdateCa } from "@app/hooks/api/ca"; +import { + ACME_DNS_PROVIDER_APP_CONNECTION_MAP, + ACME_DNS_PROVIDER_NAME_MAP +} from "@app/hooks/api/ca/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { slugSchema } from "@app/lib/schemas"; -const schema = z +const REQUIRED_EAB_DIRECTORIES = [ + "https://acme.digicert.com/v2/acme/directory", + "https://acme.zerossl.com/v2/DV90", + "https://acme.ssl.com/sslcom-dv-rsa", + "https://acme.ssl.com/sslcom-dv-ecc", + "https://dv.acme-v02.api.pki.goog/directory", + "https://acme.sectigo.com/v2/OV", + "https://acme.sectigo.com/v2/EV", + "https://acme.cisco.com/ACMEv2/directory" +]; + +const baseSchema = z.object({ + type: z.nativeEnum(CaType), + name: slugSchema({ + field: "Name" + }), + enableDirectIssuance: z.boolean(), + status: z.nativeEnum(CaStatus) +}); + +const acmeConfigurationSchema = z .object({ - type: z.nativeEnum(CaType), - name: slugSchema({ - field: "Name" + dnsAppConnection: z.object({ + id: z.string(), + name: z.string() }), - enableDirectIssuance: z.boolean(), - status: z.nativeEnum(CaStatus), - configuration: z.object({ - dnsAppConnection: z.object({ - id: z.string(), - name: z.string() - }), - // currently specific to Route53 but can be extended to others by differentiating via the provider property - dnsProviderConfig: z.object({ - provider: z.nativeEnum(AcmeDnsProvider), - hostedZoneId: z.string() - }), - directoryUrl: z.string(), - accountEmail: z.string() - }) + dnsProviderConfig: z.object({ + provider: z.nativeEnum(AcmeDnsProvider), + hostedZoneId: z.string() + }), + directoryUrl: z.string(), + accountEmail: z.string(), + eabKid: z.string().optional(), + eabHmacKey: z.string().optional() }) - .required(); + .superRefine((data, ctx) => { + if (REQUIRED_EAB_DIRECTORIES.includes(data.directoryUrl)) { + if (!data.eabKid || data.eabKid.trim() === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "EAB Key Identifier (KID) is required for this directory URL", + path: ["eabKid"] + }); + } + if (!data.eabHmacKey || data.eabHmacKey.trim() === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "EAB HMAC Key is required for this directory URL", + path: ["eabHmacKey"] + }); + } + } + }); + +const azureAdCsConfigurationSchema = z.object({ + azureAdcsConnection: z.object({ + id: z.string(), + name: z.string() + }) +}); + +const schema = z.discriminatedUnion("type", [ + baseSchema.extend({ + type: z.literal(CaType.ACME), + configuration: acmeConfigurationSchema + }), + baseSchema.extend({ + type: z.literal(CaType.AZURE_AD_CS), + configuration: azureAdCsConfigurationSchema + }) +]); export type FormData = z.infer; @@ -60,12 +121,15 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void; }; -const caTypes = [{ label: "ACME", value: CaType.ACME }]; +const caTypes = [ + { label: "ACME", value: CaType.ACME }, + { label: "Azure AD Certificate Service", value: CaType.AZURE_AD_CS } +]; export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { const { currentWorkspace } = useWorkspace(); - const { data: ca } = useGetCa({ + const { data: ca, isLoading: isCaLoading } = useGetCa({ caName: (popUp?.ca?.data as { name: string })?.name || "", projectId: currentWorkspace?.id || "", type: (popUp?.ca?.data as { type: CaType })?.type || "" @@ -81,42 +145,107 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { formState: { isSubmitting }, watch } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - type: CaType.ACME, - name: "", - status: CaStatus.ACTIVE, - enableDirectIssuance: true, - configuration: { - dnsAppConnection: { - id: "", - name: "" - }, - dnsProviderConfig: { - provider: AcmeDnsProvider.ROUTE53, - hostedZoneId: "" - }, - directoryUrl: "", - accountEmail: "" - } - } + resolver: zodResolver(schema) }); const caType = watch("type"); - const dnsProvider = watch("configuration.dnsProviderConfig.provider"); - - const { data: availableConnections, isPending } = useListAvailableAppConnections( - AppConnection.AWS, - { - enabled: dnsProvider === AcmeDnsProvider.ROUTE53 - } - ); + const configuration = watch("configuration"); + const dnsProvider = + caType === CaType.ACME && configuration && "dnsProviderConfig" in configuration + ? configuration.dnsProviderConfig.provider + : undefined; + const directoryUrl = + caType === CaType.ACME && configuration && "directoryUrl" in configuration + ? configuration.directoryUrl + : undefined; useEffect(() => { - if (ca) { - if (ca.type !== CaType.INTERNAL && availableConnections?.length) { + const initialType = (popUp?.ca?.data as { type: CaType })?.type; + if (!ca && popUp?.ca?.isOpen) { + if (initialType === CaType.AZURE_AD_CS) { + reset({ + type: CaType.AZURE_AD_CS, + name: "", + status: CaStatus.ACTIVE, + enableDirectIssuance: false, + configuration: { + azureAdcsConnection: { + id: "", + name: "" + } + } + }); + } else { + reset({ + type: CaType.ACME, + name: "", + status: CaStatus.ACTIVE, + enableDirectIssuance: true, + configuration: { + dnsAppConnection: { + id: "", + name: "" + }, + dnsProviderConfig: { + provider: AcmeDnsProvider.ROUTE53, + hostedZoneId: "" + }, + directoryUrl: "", + accountEmail: "", + eabKid: "", + eabHmacKey: "" + } + }); + } + } + }, [popUp?.ca?.isOpen, popUp?.ca?.data, reset, ca]); + + const { data: availableRoute53Connections, isPending: isRoute53Pending } = + useListAvailableAppConnections(AppConnection.AWS, { + enabled: caType === CaType.ACME + }); + + const { data: availableCloudflareConnections, isPending: isCloudflarePending } = + useListAvailableAppConnections(AppConnection.Cloudflare, { + enabled: caType === CaType.ACME + }); + + const { data: availableAzureConnections, isPending: isAzurePending } = + useListAvailableAppConnections(AppConnection.AzureADCS, { + enabled: caType === CaType.AZURE_AD_CS + }); + + const availableConnections: TAvailableAppConnection[] = useMemo(() => { + if (caType === CaType.AZURE_AD_CS) { + return availableAzureConnections || []; + } + return [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])]; + }, [ + caType, + availableRoute53Connections, + availableCloudflareConnections, + availableAzureConnections + ]); + + const isPending = + isRoute53Pending || isCloudflarePending || (isAzurePending && caType === CaType.AZURE_AD_CS); + + const dnsAppConnection = + caType === CaType.ACME && configuration && "dnsAppConnection" in configuration + ? configuration.dnsAppConnection + : { id: "", name: "" }; + + const { data: cloudflareZones = [], isPending: isZonesPending } = + useCloudflareConnectionListZones(dnsAppConnection.id, { + enabled: dnsProvider === AcmeDnsProvider.Cloudflare && !!dnsAppConnection.id + }); + + // Populate form with CA data when editing + useEffect(() => { + if (ca && !isCaLoading) { + if (ca.type === CaType.ACME && availableConnections?.length) { const selectedConnection = availableConnections?.find( - (connection) => connection.id === ca?.configuration.dnsAppConnectionId + (connection) => connection.id === ca.configuration.dnsAppConnectionId ); reset({ @@ -134,54 +263,70 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { hostedZoneId: ca.configuration.dnsProviderConfig.hostedZoneId }, directoryUrl: ca.configuration.directoryUrl, - accountEmail: ca.configuration.accountEmail + accountEmail: ca.configuration.accountEmail, + eabKid: ca.configuration.eabKid, + eabHmacKey: ca.configuration.eabHmacKey + } + }); + } else if (ca.type === CaType.AZURE_AD_CS && availableConnections?.length) { + const selectedConnection = availableConnections?.find( + (connection) => connection.id === ca.configuration.azureAdcsConnectionId + ); + + reset({ + type: ca.type, + name: ca.name, + status: ca.status, + enableDirectIssuance: false, + configuration: { + azureAdcsConnection: { + id: ca.configuration.azureAdcsConnectionId, + name: selectedConnection?.name || "" + } } }); } - } else { - reset({ - type: CaType.ACME, - name: "", - status: CaStatus.ACTIVE, - enableDirectIssuance: true, - configuration: { - dnsAppConnection: { - id: "", - name: "" - }, - dnsProviderConfig: { - provider: AcmeDnsProvider.ROUTE53, - hostedZoneId: "" - }, - directoryUrl: "", - accountEmail: "" - } - }); } - }, [ca, availableConnections]); + }, [ca, availableConnections, reset, isCaLoading]); const onFormSubmit = async ({ type, name, enableDirectIssuance, status, - configuration + configuration: formConfiguration }: FormData) => { try { if (!currentWorkspace?.slug) return; - if (ca && type !== CaType.INTERNAL) { + let configPayload: any; + + if (type === CaType.ACME && "dnsAppConnection" in formConfiguration) { + configPayload = { + dnsProviderConfig: formConfiguration.dnsProviderConfig, + directoryUrl: formConfiguration.directoryUrl, + accountEmail: formConfiguration.accountEmail, + dnsAppConnectionId: formConfiguration.dnsAppConnection.id, + eabKid: formConfiguration.eabKid, + eabHmacKey: formConfiguration.eabHmacKey + }; + } else if (type === CaType.AZURE_AD_CS && "azureAdcsConnection" in formConfiguration) { + configPayload = { + azureAdcsConnectionId: formConfiguration.azureAdcsConnection.id + }; + } else { + throw new Error("Invalid certificate authority configuration"); + } + + if (ca) { await updateMutateAsync({ caName: ca.name, projectId: currentWorkspace.id, name, type, status, - enableDirectIssuance, - configuration: { - ...configuration, - dnsAppConnectionId: configuration.dnsAppConnection.id - } + enableDirectIssuance: type === CaType.AZURE_AD_CS ? false : enableDirectIssuance, + configuration: configPayload }); } else { await createMutateAsync({ @@ -189,11 +334,8 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { name, type, status, - enableDirectIssuance, - configuration: { - ...configuration, - dnsAppConnectionId: configuration.dnsAppConnection.id - } + enableDirectIssuance: type === CaType.AZURE_AD_CS ? false : enableDirectIssuance, + configuration: configPayload }); } @@ -221,7 +363,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { handlePopUpToggle("ca", isOpen); }} > - +
{ca && ( @@ -284,12 +426,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { className="w-full" isDisabled={Boolean(ca)} > - - Route53 - + {Object.values(AcmeDnsProvider).map((provider) => ( + + {ACME_DNS_PROVIDER_NAME_MAP[provider]} + + ))} )} @@ -297,7 +438,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { ( { control={control} name="configuration.dnsAppConnection" /> - ( - - - - )} - /> + {dnsProvider === AcmeDnsProvider.ROUTE53 && ( + ( + + + + )} + /> + )} + {dnsProvider === AcmeDnsProvider.Cloudflare && ( + ( + + zone.id === value)} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + }} + options={cloudflareZones} + placeholder="Select a zone..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + )} { )} /> + ( + + + + )} + /> + ( + + + + )} + /> )} - { - return ( - - field.onChange(value)} - isChecked={field.value} - > -

Enable Direct Issuance

-
+ {caType === CaType.AZURE_AD_CS && ( + ( + + { + onChange(newValue); + }} + isLoading={isPending} + options={availableConnections} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> - ); - }} - /> + )} + control={control} + name="configuration.azureAdcsConnection" + /> + )} + {caType === CaType.ACME && ( + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Enable Direct Issuance

+
+
+ ); + }} + /> + )}