diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..bdb3e536d --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +# Keys +# Required key for platform encryption/decryption ops +# THIS IS A SAMPLE ENCRYPTION KEY AND SHOULD NEVER BE USED FOR PRODUCTION +ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218 + +# JWT +# Required secrets to sign JWT tokens +# THIS IS A SAMPLE AUTH_SECRET KEY AND SHOULD NEVER BE USED FOR PRODUCTION +AUTH_SECRET=5lrMXKKWCVocS/uerPsl7V+TX/aaUaI7iDkgl3tSmLE= + +# Postgres creds +POSTGRES_PASSWORD=infisical +POSTGRES_USER=infisical +POSTGRES_DB=infisical + +# Required +DB_CONNECTION_URI=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + +# Redis +REDIS_URL=redis://redis:6379 + +# Website URL +# Required +SITE_URL=http://localhost:8080 + +# Mail/SMTP +SMTP_HOST= +SMTP_PORT= +SMTP_NAME= +SMTP_USERNAME= +SMTP_PASSWORD= + +# Integration +# Optional only if integration is used +CLIENT_ID_HEROKU= +CLIENT_ID_VERCEL= +CLIENT_ID_NETLIFY= +CLIENT_ID_GITHUB= +CLIENT_ID_GITLAB= +CLIENT_ID_BITBUCKET= +CLIENT_SECRET_HEROKU= +CLIENT_SECRET_VERCEL= +CLIENT_SECRET_NETLIFY= +CLIENT_SECRET_GITHUB= +CLIENT_SECRET_GITLAB= +CLIENT_SECRET_BITBUCKET= +CLIENT_SLUG_VERCEL= + +# Sentry (optional) for monitoring errors +SENTRY_DSN= + +# Infisical Cloud-specific configs +# Ignore - Not applicable for self-hosted version +POSTHOG_HOST= +POSTHOG_PROJECT_API_KEY= + +# SSO-specific variables +CLIENT_ID_GOOGLE_LOGIN= +CLIENT_SECRET_GOOGLE_LOGIN= + +CLIENT_ID_GITHUB_LOGIN= +CLIENT_SECRET_GITHUB_LOGIN= + +CLIENT_ID_GITLAB_LOGIN= +CLIENT_SECRET_GITLAB_LOGIN= diff --git a/.github/workflows/build-staging-and-deploy-aws.yml b/.github/workflows/build-staging-and-deploy-aws.yml index 78a193d30..a9b2046ae 100644 --- a/.github/workflows/build-staging-and-deploy-aws.yml +++ b/.github/workflows/build-staging-and-deploy-aws.yml @@ -74,21 +74,21 @@ jobs: uses: pr-mpt/actions-commit-hash@v2 - name: Download task definition run: | - aws ecs describe-task-definition --task-definition infisical-prod-platform --query taskDefinition > task-definition.json + aws ecs describe-task-definition --task-definition infisical-core-platform --query taskDefinition > task-definition.json - name: Render Amazon ECS task definition id: render-web-container uses: aws-actions/amazon-ecs-render-task-definition@v1 with: task-definition: task-definition.json - container-name: infisical-prod-platform + container-name: infisical-core-platform image: infisical/staging_infisical:${{ steps.commit.outputs.short }} environment-variables: "LOG_LEVEL=info" - name: Deploy to Amazon ECS service uses: aws-actions/amazon-ecs-deploy-task-definition@v1 with: task-definition: ${{ steps.render-web-container.outputs.task-definition }} - service: infisical-prod-platform - cluster: infisical-prod-platform + service: infisical-core-platform + cluster: infisical-core-platform wait-for-service-stability: true production-postgres-deployment: @@ -122,19 +122,19 @@ jobs: uses: pr-mpt/actions-commit-hash@v2 - name: Download task definition run: | - aws ecs describe-task-definition --task-definition infisical-prod-platform --query taskDefinition > task-definition.json + aws ecs describe-task-definition --task-definition infisical-core-platform --query taskDefinition > task-definition.json - name: Render Amazon ECS task definition id: render-web-container uses: aws-actions/amazon-ecs-render-task-definition@v1 with: task-definition: task-definition.json - container-name: infisical-prod-platform + container-name: infisical-core-platform image: infisical/staging_infisical:${{ steps.commit.outputs.short }} environment-variables: "LOG_LEVEL=info" - name: Deploy to Amazon ECS service uses: aws-actions/amazon-ecs-deploy-task-definition@v1 with: task-definition: ${{ steps.render-web-container.outputs.task-definition }} - service: infisical-prod-platform - cluster: infisical-prod-platform + service: infisical-core-platform + cluster: infisical-core-platform wait-for-service-stability: true diff --git a/.github/workflows/check-api-for-breaking-changes.yml b/.github/workflows/check-api-for-breaking-changes.yml index 2086601a8..dadd6c860 100644 --- a/.github/workflows/check-api-for-breaking-changes.yml +++ b/.github/workflows/check-api-for-breaking-changes.yml @@ -40,13 +40,14 @@ jobs: REDIS_URL: redis://172.17.0.1:6379 DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable JWT_AUTH_SECRET: something-random + ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218 - uses: actions/setup-go@v5 with: go-version: '1.21.5' - name: Wait for container to be stable and check logs run: | SECONDS=0 - HEALTHY=0 + r HEALTHY=0 while [ $SECONDS -lt 60 ]; do if docker ps | grep infisical-api | grep -q healthy; then echo "Container is healthy." @@ -73,4 +74,4 @@ jobs: run: | docker-compose -f "docker-compose.dev.yml" down docker stop infisical-api - docker remove infisical-api \ No newline at end of file + docker remove infisical-api diff --git a/.github/workflows/update-be-new-migration-latest-timestamp.yml b/.github/workflows/update-be-new-migration-latest-timestamp.yml index 160828473..684c78654 100644 --- a/.github/workflows/update-be-new-migration-latest-timestamp.yml +++ b/.github/workflows/update-be-new-migration-latest-timestamp.yml @@ -38,6 +38,16 @@ jobs: rm added_files.txt git commit -m "chore: renamed new migration files to latest timestamp (gh-action)" + - name: Get PR details + id: pr_details + run: | + PR_NUMBER=${{ github.event.pull_request.number }} + PR_MERGER=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER" | jq -r '.merged_by.login') + + echo "PR Number: $PR_NUMBER" + echo "PR Merger: $PR_MERGER" + echo "pr_merger=$PR_MERGER" >> $GITHUB_OUTPUT + - name: Create Pull Request if: env.SKIP_RENAME != 'true' uses: peter-evans/create-pull-request@v6 @@ -46,3 +56,4 @@ jobs: commit-message: 'chore: renamed new migration files to latest UTC (gh-action)' title: 'GH Action: rename new migration file timestamp' branch-suffix: timestamp + reviewers: ${{ steps.pr_details.outputs.pr_merger }} diff --git a/.infisicalignore b/.infisicalignore index 348f9e327..855047fe4 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -2,4 +2,6 @@ frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx:generic-api-key:206 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:304 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx:generic-api-key:206 -frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292 \ No newline at end of file +frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292 +docs/self-hosting/configuration/envars.mdx:generic-api-key:106 +frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:451 diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index 737067534..0fb2a6671 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -1,7 +1,6 @@ ARG POSTHOG_HOST=https://app.posthog.com ARG POSTHOG_API_KEY=posthog-api-key ARG INTERCOM_ID=intercom-id -ARG SAML_ORG_SLUG=saml-org-slug-default FROM node:20-alpine AS base @@ -35,9 +34,7 @@ ENV NEXT_PUBLIC_POSTHOG_API_KEY $POSTHOG_API_KEY ARG INTERCOM_ID ENV NEXT_PUBLIC_INTERCOM_ID $INTERCOM_ID ARG INFISICAL_PLATFORM_VERSION -ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION -ARG SAML_ORG_SLUG -ENV NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG +ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION # Build RUN npm run build @@ -55,6 +52,7 @@ VOLUME /app/.next/cache/images COPY --chown=non-root-user:nodejs --chmod=555 frontend/scripts ./scripts COPY --from=frontend-builder /app/public ./public RUN chown non-root-user:nodejs ./public/data + COPY --from=frontend-builder --chown=non-root-user:nodejs /app/.next/standalone ./ COPY --from=frontend-builder --chown=non-root-user:nodejs /app/.next/static ./.next/static @@ -93,9 +91,18 @@ RUN mkdir frontend-build # Production stage FROM base AS production +RUN apk add --upgrade --no-cache ca-certificates RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 non-root-user +# Give non-root-user permission to update SSL certs +RUN chown -R non-root-user /etc/ssl/certs +RUN chown non-root-user /etc/ssl/certs/ca-certificates.crt +RUN chmod -R u+rwx /etc/ssl/certs +RUN chmod u+rw /etc/ssl/certs/ca-certificates.crt +RUN chown non-root-user /usr/sbin/update-ca-certificates +RUN chmod u+rx /usr/sbin/update-ca-certificates + ## set pre baked keys ARG POSTHOG_API_KEY ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \ @@ -103,9 +110,6 @@ ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \ ARG INTERCOM_ID=intercom-id ENV NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID \ BAKED_NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID -ARG SAML_ORG_SLUG -ENV NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG \ - BAKED_NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG WORKDIR / diff --git a/backend/e2e-test/mocks/keystore.ts b/backend/e2e-test/mocks/keystore.ts index c85244129..05753995c 100644 --- a/backend/e2e-test/mocks/keystore.ts +++ b/backend/e2e-test/mocks/keystore.ts @@ -1,4 +1,5 @@ import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { Lock } from "@app/lib/red-lock"; export const mockKeyStore = (): TKeyStoreFactory => { const store: Record = {}; @@ -25,6 +26,12 @@ export const mockKeyStore = (): TKeyStoreFactory => { }, incrementBy: async () => { return 1; - } + }, + acquireLock: () => { + return Promise.resolve({ + release: () => {} + }) as Promise; + }, + waitTillReady: async () => {} }; }; diff --git a/backend/package-lock.json b/backend/package-lock.json index b51573688..b6f9a37c1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -34,11 +34,13 @@ "axios": "^1.6.7", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", - "bullmq": "^5.3.3", + "bullmq": "^5.4.2", "cassandra-driver": "^4.7.2", "dotenv": "^16.4.1", "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", + "google-auth-library": "^9.9.0", + "googleapis": "^137.1.0", "handlebars": "^4.7.8", "ioredis": "^5.3.2", "jmespath": "^0.16.0", @@ -49,7 +51,7 @@ "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", - "mysql2": "^3.9.4", + "mysql2": "^3.9.8", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", "ora": "^7.0.1", @@ -1207,6 +1209,58 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/client-sts": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", + "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" + } + }, "node_modules/@aws-sdk/client-secrets-manager/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -1314,7 +1368,7 @@ "@aws-sdk/credential-provider-node": "^3.504.0" } }, - "node_modules/@aws-sdk/client-sts": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/client-sts": { "version": "3.504.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", @@ -1436,6 +1490,58 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/credential-provider-ini/node_modules/@aws-sdk/client-sts": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", + "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" + } + }, "node_modules/@aws-sdk/credential-provider-node": { "version": "3.504.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.504.0.tgz", @@ -1505,6 +1611,58 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/credential-provider-web-identity/node_modules/@aws-sdk/client-sts": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", + "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" + } + }, "node_modules/@aws-sdk/middleware-host-header": { "version": "3.502.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.502.0.tgz", @@ -2782,6 +2940,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -2794,6 +2953,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "engines": { "node": ">= 8" } @@ -2802,6 +2962,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -3657,60 +3818,60 @@ } }, "node_modules/@smithy/abort-controller": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.1.3.tgz", - "integrity": "sha512-c2aYH2Wu1RVE3rLlVgg2kQOBJGM0WbjReQi5DnPTm2Zb7F0gk7J2aeQeaX2u/lQZoHl6gv8Oac7mt9alU3+f4A==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-wRlta7GuLWpTqtFfGo+nZyOO1vEvewdNR1R4rTxpC8XU6vG/NDyrFBhwLZsqg1NUoR1noVaXJPC/7ZK47QCySw==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/config-resolver": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-2.1.4.tgz", - "integrity": "sha512-AW2WUZmBAzgO3V3ovKtsUbI3aBNMeQKFDumoqkNxaVDWF/xfnxAWqBKDr/NuG7c06N2Rm4xeZLPiJH/d+na0HA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-2.2.0.tgz", + "integrity": "sha512-fsiMgd8toyUba6n1WRmr+qACzXltpdDkPTAaDqc8QqPBUzO+/JKwL6bUBseHVi8tu9l+3JOK+tSf7cay+4B3LA==", "dependencies": { - "@smithy/node-config-provider": "^2.2.4", - "@smithy/types": "^2.10.1", - "@smithy/util-config-provider": "^2.2.1", - "@smithy/util-middleware": "^2.1.3", - "tslib": "^2.5.0" + "@smithy/node-config-provider": "^2.3.0", + "@smithy/types": "^2.12.0", + "@smithy/util-config-provider": "^2.3.0", + "@smithy/util-middleware": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/core": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-1.3.5.tgz", - "integrity": "sha512-Rrc+e2Jj6Gu7Xbn0jvrzZlSiP2CZocIOfZ9aNUA82+1sa6GBnxqL9+iZ9EKHeD9aqD1nU8EK4+oN2EiFpSv7Yw==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-1.4.2.tgz", + "integrity": "sha512-2fek3I0KZHWJlRLvRTqxTEri+qV0GRHrJIoLFuBMZB4EMg4WgeBGfF0X6abnrNYpq55KJ6R4D6x4f0vLnhzinA==", "dependencies": { - "@smithy/middleware-endpoint": "^2.4.4", - "@smithy/middleware-retry": "^2.1.4", - "@smithy/middleware-serde": "^2.1.3", - "@smithy/protocol-http": "^3.2.1", - "@smithy/smithy-client": "^2.4.2", - "@smithy/types": "^2.10.1", - "@smithy/util-middleware": "^2.1.3", - "tslib": "^2.5.0" + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", + "@smithy/middleware-serde": "^2.3.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "@smithy/util-middleware": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/credential-provider-imds": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-2.2.4.tgz", - "integrity": "sha512-DdatjmBZQnhGe1FhI8gO98f7NmvQFSDiZTwC3WMvLTCKQUY+Y1SVkhJqIuLu50Eb7pTheoXQmK+hKYUgpUWsNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-2.3.0.tgz", + "integrity": "sha512-BWB9mIukO1wjEOo1Ojgl6LrG4avcaC7T/ZP6ptmAaW4xluhSIPZhY+/PI5YKzlk+jsm+4sQZB45Bt1OfMeQa3w==", "dependencies": { - "@smithy/node-config-provider": "^2.2.4", - "@smithy/property-provider": "^2.1.3", - "@smithy/types": "^2.10.1", - "@smithy/url-parser": "^2.1.3", - "tslib": "^2.5.0" + "@smithy/node-config-provider": "^2.3.0", + "@smithy/property-provider": "^2.2.0", + "@smithy/types": "^2.12.0", + "@smithy/url-parser": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" @@ -3779,459 +3940,451 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-2.4.3.tgz", - "integrity": "sha512-Fn/KYJFo6L5I4YPG8WQb2hOmExgRmNpVH5IK2zU3JKrY5FKW7y9ar5e0BexiIC9DhSKqKX+HeWq/Y18fq7Dkpw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-2.5.0.tgz", + "integrity": "sha512-BOWEBeppWhLn/no/JxUL/ghTfANTjT7kg3Ww2rPqTUY9R4yHPXxJ9JhMe3Z03LN3aPwiwlpDIUcVw1xDyHqEhw==", "dependencies": { - "@smithy/protocol-http": "^3.2.1", - "@smithy/querystring-builder": "^2.1.3", - "@smithy/types": "^2.10.1", - "@smithy/util-base64": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/protocol-http": "^3.3.0", + "@smithy/querystring-builder": "^2.2.0", + "@smithy/types": "^2.12.0", + "@smithy/util-base64": "^2.3.0", + "tslib": "^2.6.2" } }, "node_modules/@smithy/hash-node": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-2.1.3.tgz", - "integrity": "sha512-FsAPCUj7VNJIdHbSxMd5uiZiF20G2zdSDgrgrDrHqIs/VMxK85Vqk5kMVNNDMCZmMezp6UKnac0B4nAyx7HJ9g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-2.2.0.tgz", + "integrity": "sha512-zLWaC/5aWpMrHKpoDF6nqpNtBhlAYKF/7+9yMN7GpdR8CzohnWfGtMznPybnwSS8saaXBMxIGwJqR4HmRp6b3g==", "dependencies": { - "@smithy/types": "^2.10.1", - "@smithy/util-buffer-from": "^2.1.1", - "@smithy/util-utf8": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "@smithy/util-buffer-from": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/invalid-dependency": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-2.1.3.tgz", - "integrity": "sha512-wkra7d/G4CbngV4xsjYyAYOvdAhahQje/WymuQdVEnXFExJopEu7fbL5AEAlBPgWHXwu94VnCSG00gVzRfExyg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-2.2.0.tgz", + "integrity": "sha512-nEDASdbKFKPXN2O6lOlTgrEEOO9NHIeO+HVvZnkqc8h5U9g3BIhWsvzFo+UcUbliMHvKNPD/zVxDrkP1Sbgp8Q==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" } }, "node_modules/@smithy/is-array-buffer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.1.1.tgz", - "integrity": "sha512-xozSQrcUinPpNPNPds4S7z/FakDTh1MZWtRP/2vQtYB/u3HYrX2UXuZs+VhaKBd6Vc7g2XPr2ZtwGBNDN6fNKQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-content-length": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-2.1.3.tgz", - "integrity": "sha512-aJduhkC+dcXxdnv5ZpM3uMmtGmVFKx412R1gbeykS5HXDmRU6oSsyy2SoHENCkfOGKAQOjVE2WVqDJibC0d21g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-2.2.0.tgz", + "integrity": "sha512-5bl2LG1Ah/7E5cMSC+q+h3IpVHMeOkG0yLRyQT1p2aMJkSrZG7RlXHPuAgb7EyaFeidKEnnd/fNaLLaKlHGzDQ==", "dependencies": { - "@smithy/protocol-http": "^3.2.1", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/protocol-http": "^3.3.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-endpoint": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-2.4.4.tgz", - "integrity": "sha512-4yjHyHK2Jul4JUDBo2sTsWY9UshYUnXeb/TAK/MTaPEb8XQvDmpwSFnfIRDU45RY1a6iC9LCnmJNg/yHyfxqkw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-2.5.1.tgz", + "integrity": "sha512-1/8kFp6Fl4OsSIVTWHnNjLnTL8IqpIb/D3sTSczrKFnrE9VMNWxnrRKNvpUHOJ6zpGD5f62TPm7+17ilTJpiCQ==", "dependencies": { - "@smithy/middleware-serde": "^2.1.3", - "@smithy/node-config-provider": "^2.2.4", - "@smithy/shared-ini-file-loader": "^2.3.4", - "@smithy/types": "^2.10.1", - "@smithy/url-parser": "^2.1.3", - "@smithy/util-middleware": "^2.1.3", - "tslib": "^2.5.0" + "@smithy/middleware-serde": "^2.3.0", + "@smithy/node-config-provider": "^2.3.0", + "@smithy/shared-ini-file-loader": "^2.4.0", + "@smithy/types": "^2.12.0", + "@smithy/url-parser": "^2.2.0", + "@smithy/util-middleware": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-retry": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-2.1.4.tgz", - "integrity": "sha512-Cyolv9YckZTPli1EkkaS39UklonxMd08VskiuMhURDjC0HHa/AD6aK/YoD21CHv9s0QLg0WMLvk9YeLTKkXaFQ==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-2.3.1.tgz", + "integrity": "sha512-P2bGufFpFdYcWvqpyqqmalRtwFUNUA8vHjJR5iGqbfR6mp65qKOLcUd6lTr4S9Gn/enynSrSf3p3FVgVAf6bXA==", "dependencies": { - "@smithy/node-config-provider": "^2.2.4", - "@smithy/protocol-http": "^3.2.1", - "@smithy/service-error-classification": "^2.1.3", - "@smithy/smithy-client": "^2.4.2", - "@smithy/types": "^2.10.1", - "@smithy/util-middleware": "^2.1.3", - "@smithy/util-retry": "^2.1.3", - "tslib": "^2.5.0", - "uuid": "^8.3.2" + "@smithy/node-config-provider": "^2.3.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/service-error-classification": "^2.1.5", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "@smithy/util-middleware": "^2.2.0", + "@smithy/util-retry": "^2.2.0", + "tslib": "^2.6.2", + "uuid": "^9.0.1" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@smithy/middleware-retry/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@smithy/middleware-serde": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-2.1.3.tgz", - "integrity": "sha512-s76LId+TwASrHhUa9QS4k/zeXDUAuNuddKklQzRgumbzge5BftVXHXIqL4wQxKGLocPwfgAOXWx+HdWhQk9hTg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-2.3.0.tgz", + "integrity": "sha512-sIADe7ojwqTyvEQBe1nc/GXB9wdHhi9UwyX0lTyttmUWDJLP655ZYE1WngnNyXREme8I27KCaUhyhZWRXL0q7Q==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-stack": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-2.1.3.tgz", - "integrity": "sha512-opMFufVQgvBSld/b7mD7OOEBxF6STyraVr1xel1j0abVILM8ALJvRoFbqSWHGmaDlRGIiV9Q5cGbWi0sdiEaLQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-2.2.0.tgz", + "integrity": "sha512-Qntc3jrtwwrsAC+X8wms8zhrTr0sFXnyEGhZd9sLtsJ/6gGQKFzNB+wWbOcpJd7BR8ThNCoKt76BuQahfMvpeA==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/node-config-provider": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-2.2.4.tgz", - "integrity": "sha512-nqazHCp8r4KHSFhRQ+T0VEkeqvA0U+RhehBSr1gunUuNW3X7j0uDrWBxB2gE9eutzy6kE3Y7L+Dov/UXT871vg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-2.3.0.tgz", + "integrity": "sha512-0elK5/03a1JPWMDPaS726Iw6LpQg80gFut1tNpPfxFuChEEklo2yL823V94SpTZTxmKlXFtFgsP55uh3dErnIg==", "dependencies": { - "@smithy/property-provider": "^2.1.3", - "@smithy/shared-ini-file-loader": "^2.3.4", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/property-provider": "^2.2.0", + "@smithy/shared-ini-file-loader": "^2.4.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/node-http-handler": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-2.4.1.tgz", - "integrity": "sha512-HCkb94soYhJMxPCa61wGKgmeKpJ3Gftx1XD6bcWEB2wMV1L9/SkQu/6/ysKBnbOzWRE01FGzwrTxucHypZ8rdg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-2.5.0.tgz", + "integrity": "sha512-mVGyPBzkkGQsPoxQUbxlEfRjrj6FPyA3u3u2VXGr9hT8wilsoQdZdvKpMBFMB8Crfhv5dNkKHIW0Yyuc7eABqA==", "dependencies": { - "@smithy/abort-controller": "^2.1.3", - "@smithy/protocol-http": "^3.2.1", - "@smithy/querystring-builder": "^2.1.3", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/abort-controller": "^2.2.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/querystring-builder": "^2.2.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/property-provider": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-2.1.3.tgz", - "integrity": "sha512-bMz3se+ySKWNrgm7eIiQMa2HO/0fl2D0HvLAdg9pTMcpgp4SqOAh6bz7Ik6y7uQqSrk4rLjIKgbQ6yzYgGehCQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-2.2.0.tgz", + "integrity": "sha512-+xiil2lFhtTRzXkx8F053AV46QnIw6e7MV8od5Mi68E1ICOjCeCHw2XfLnDEUHnT9WGUIkwcqavXjfwuJbGlpg==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/protocol-http": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-3.2.1.tgz", - "integrity": "sha512-KLrQkEw4yJCeAmAH7hctE8g9KwA7+H2nSJwxgwIxchbp/L0B5exTdOQi9D5HinPLlothoervGmhpYKelZ6AxIA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-3.3.0.tgz", + "integrity": "sha512-Xy5XK1AFWW2nlY/biWZXu6/krgbaf2dg0q492D8M5qthsnU2H+UgFeZLbM76FnH7s6RO/xhQRkj+T6KBO3JzgQ==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/querystring-builder": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-2.1.3.tgz", - "integrity": "sha512-kFD3PnNqKELe6m9GRHQw/ftFFSZpnSeQD4qvgDB6BQN6hREHELSosVFUMPN4M3MDKN2jAwk35vXHLoDrNfKu0A==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-2.2.0.tgz", + "integrity": "sha512-L1kSeviUWL+emq3CUVSgdogoM/D9QMFaqxL/dd0X7PCNWmPXqt+ExtrBjqT0V7HLN03Vs9SuiLrG3zy3JGnE5A==", "dependencies": { - "@smithy/types": "^2.10.1", - "@smithy/util-uri-escape": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "@smithy/util-uri-escape": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/querystring-parser": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-2.1.3.tgz", - "integrity": "sha512-3+CWJoAqcBMR+yvz6D+Fc5VdoGFtfenW6wqSWATWajrRMGVwJGPT3Vy2eb2bnMktJc4HU4bpjeovFa566P3knQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-2.2.0.tgz", + "integrity": "sha512-BvHCDrKfbG5Yhbpj4vsbuPV2GgcpHiAkLeIlcA1LtfpMz3jrqizP1+OguSNSj1MwBHEiN+jwNisXLGdajGDQJA==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/service-error-classification": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-2.1.3.tgz", - "integrity": "sha512-iUrpSsem97bbXHHT/v3s7vaq8IIeMo6P6cXdeYHrx0wOJpMeBGQF7CB0mbJSiTm3//iq3L55JiEm8rA7CTVI8A==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-2.1.5.tgz", + "integrity": "sha512-uBDTIBBEdAQryvHdc5W8sS5YX7RQzF683XrHePVdFmAgKiMofU15FLSM0/HU03hKTnazdNRFa0YHS7+ArwoUSQ==", "dependencies": { - "@smithy/types": "^2.10.1" + "@smithy/types": "^2.12.0" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-2.3.4.tgz", - "integrity": "sha512-CiZmPg9GeDKbKmJGEFvJBsJcFnh0AQRzOtQAzj1XEa8N/0/uSN/v1LYzgO7ry8hhO8+9KB7+DhSW0weqBra4Aw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-2.4.0.tgz", + "integrity": "sha512-WyujUJL8e1B6Z4PBfAqC/aGY1+C7T0w20Gih3yrvJSk97gpiVfB+y7c46T4Nunk+ZngLq0rOIdeVeIklk0R3OA==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/signature-v4": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.1.3.tgz", - "integrity": "sha512-Jq4iPPdCmJojZTsPePn4r1ULShh6ONkokLuxp1Lnk4Sq7r7rJp4HlA1LbPBq4bD64TIzQezIpr1X+eh5NYkNxw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.3.0.tgz", + "integrity": "sha512-ui/NlpILU+6HAQBfJX8BBsDXuKSNrjTSuOYArRblcrErwKFutjrCNb/OExfVRyj9+26F9J+ZmfWT+fKWuDrH3Q==", "dependencies": { - "@smithy/eventstream-codec": "^2.1.3", - "@smithy/is-array-buffer": "^2.1.1", - "@smithy/types": "^2.10.1", - "@smithy/util-hex-encoding": "^2.1.1", - "@smithy/util-middleware": "^2.1.3", - "@smithy/util-uri-escape": "^2.1.1", - "@smithy/util-utf8": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/is-array-buffer": "^2.2.0", + "@smithy/types": "^2.12.0", + "@smithy/util-hex-encoding": "^2.2.0", + "@smithy/util-middleware": "^2.2.0", + "@smithy/util-uri-escape": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/smithy-client": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-2.4.2.tgz", - "integrity": "sha512-ntAFYN51zu3N3mCd95YFcFi/8rmvm//uX+HnK24CRbI6k5Rjackn0JhgKz5zOx/tbNvOpgQIwhSX+1EvEsBLbA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-2.5.1.tgz", + "integrity": "sha512-jrbSQrYCho0yDaaf92qWgd+7nAeap5LtHTI51KXqmpIFCceKU3K9+vIVTUH72bOJngBMqa4kyu1VJhRcSrk/CQ==", "dependencies": { - "@smithy/middleware-endpoint": "^2.4.4", - "@smithy/middleware-stack": "^2.1.3", - "@smithy/protocol-http": "^3.2.1", - "@smithy/types": "^2.10.1", - "@smithy/util-stream": "^2.1.3", - "tslib": "^2.5.0" + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-stack": "^2.2.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/types": "^2.12.0", + "@smithy/util-stream": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/types": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.10.1.tgz", - "integrity": "sha512-hjQO+4ru4cQ58FluQvKKiyMsFg0A6iRpGm2kqdH8fniyNd2WyanoOsYJfMX/IFLuLxEoW6gnRkNZy1y6fUUhtA==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/url-parser": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-2.1.3.tgz", - "integrity": "sha512-X1NRA4WzK/ihgyzTpeGvI9Wn45y8HmqF4AZ/FazwAv8V203Ex+4lXqcYI70naX9ETqbqKVzFk88W6WJJzCggTQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-2.2.0.tgz", + "integrity": "sha512-hoA4zm61q1mNTpksiSWp2nEl1dt3j726HdRhiNgVJQMj7mLp7dprtF57mOB6JvEk/x9d2bsuL5hlqZbBuHQylQ==", "dependencies": { - "@smithy/querystring-parser": "^2.1.3", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/querystring-parser": "^2.2.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" } }, "node_modules/@smithy/util-base64": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-2.1.1.tgz", - "integrity": "sha512-UfHVpY7qfF/MrgndI5PexSKVTxSZIdz9InghTFa49QOvuu9I52zLPLUHXvHpNuMb1iD2vmc6R+zbv/bdMipR/g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-2.3.0.tgz", + "integrity": "sha512-s3+eVwNeJuXUwuMbusncZNViuhv2LjVJ1nMwTqSA0XAC7gjKhqqxRdJPhR8+YrkoZ9IiIbFk/yK6ACe/xlF+hw==", "dependencies": { - "@smithy/util-buffer-from": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/util-buffer-from": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-body-length-browser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-2.1.1.tgz", - "integrity": "sha512-ekOGBLvs1VS2d1zM2ER4JEeBWAvIOUKeaFch29UjjJsxmZ/f0L3K3x0dEETgh3Q9bkZNHgT+rkdl/J/VUqSRag==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-2.2.0.tgz", + "integrity": "sha512-dtpw9uQP7W+n3vOtx0CfBD5EWd7EPdIdsQnWTDoFf77e3VUf05uA7R7TGipIo8e4WL2kuPdnsr3hMQn9ziYj5w==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" } }, "node_modules/@smithy/util-body-length-node": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-2.2.1.tgz", - "integrity": "sha512-/ggJG+ta3IDtpNVq4ktmEUtOkH1LW64RHB5B0hcr5ZaWBmo96UX2cIOVbjCqqDickTXqBWZ4ZO0APuaPrD7Abg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-2.3.0.tgz", + "integrity": "sha512-ITWT1Wqjubf2CJthb0BuT9+bpzBfXeMokH/AAa5EJQgbv9aPMVfnM76iFIZVFf50hYXGbtiV71BHAthNWd6+dw==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-buffer-from": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.1.1.tgz", - "integrity": "sha512-clhNjbyfqIv9Md2Mg6FffGVrJxw7bgK7s3Iax36xnfVj6cg0fUG7I4RH0XgXJF8bxi+saY5HR21g2UPKSxVCXg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "dependencies": { - "@smithy/is-array-buffer": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-config-provider": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-2.2.1.tgz", - "integrity": "sha512-50VL/tx9oYYcjJn/qKqNy7sCtpD0+s8XEBamIFo4mFFTclKMNp+rsnymD796uybjiIquB7VCB/DeafduL0y2kw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-2.3.0.tgz", + "integrity": "sha512-HZkzrRcuFN1k70RLqlNK4FnPXKOpkik1+4JaBoHNJn+RnJGYqaa3c5/+XtLOXhlKzlRgNvyaLieHTW2VwGN0VQ==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-2.1.4.tgz", - "integrity": "sha512-J6XAVY+/g7jf03QMnvqPyU+8jqGrrtXoKWFVOS+n1sz0Lg8HjHJ1ANqaDN+KTTKZRZlvG8nU5ZrJOUL6VdwgcQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-2.2.1.tgz", + "integrity": "sha512-RtKW+8j8skk17SYowucwRUjeh4mCtnm5odCL0Lm2NtHQBsYKrNW0od9Rhopu9wF1gHMfHeWF7i90NwBz/U22Kw==", "dependencies": { - "@smithy/property-provider": "^2.1.3", - "@smithy/smithy-client": "^2.4.2", - "@smithy/types": "^2.10.1", + "@smithy/property-provider": "^2.2.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", "bowser": "^2.11.0", - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">= 10.0.0" } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-2.2.3.tgz", - "integrity": "sha512-ttUISrv1uVOjTlDa3nznX33f0pthoUlP+4grhTvOzcLhzArx8qHB94/untGACOG3nlf8vU20nI2iWImfzoLkYA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-2.3.1.tgz", + "integrity": "sha512-vkMXHQ0BcLFysBMWgSBLSk3+leMpFSyyFj8zQtv5ZyUBx8/owVh1/pPEkzmW/DR/Gy/5c8vjLDD9gZjXNKbrpA==", "dependencies": { - "@smithy/config-resolver": "^2.1.4", - "@smithy/credential-provider-imds": "^2.2.4", - "@smithy/node-config-provider": "^2.2.4", - "@smithy/property-provider": "^2.1.3", - "@smithy/smithy-client": "^2.4.2", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/config-resolver": "^2.2.0", + "@smithy/credential-provider-imds": "^2.3.0", + "@smithy/node-config-provider": "^2.3.0", + "@smithy/property-provider": "^2.2.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">= 10.0.0" } }, "node_modules/@smithy/util-endpoints": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-1.1.4.tgz", - "integrity": "sha512-/qAeHmK5l4yQ4/bCIJ9p49wDe9rwWtOzhPHblu386fwPNT3pxmodgcs9jDCV52yK9b4rB8o9Sj31P/7Vzka1cg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-1.2.0.tgz", + "integrity": "sha512-BuDHv8zRjsE5zXd3PxFXFknzBG3owCpjq8G3FcsXW3CykYXuEqM3nTSsmLzw5q+T12ZYuDlVUZKBdpNbhVtlrQ==", "dependencies": { - "@smithy/node-config-provider": "^2.2.4", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/node-config-provider": "^2.3.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@smithy/util-hex-encoding": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.1.1.tgz", - "integrity": "sha512-3UNdP2pkYUUBGEXzQI9ODTDK+Tcu1BlCyDBaRHwyxhA+8xLP8agEKQq4MGmpjqb4VQAjq9TwlCQX0kP6XDKYLg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.2.0.tgz", + "integrity": "sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-middleware": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-2.1.3.tgz", - "integrity": "sha512-/+2fm7AZ2ozl5h8wM++ZP0ovE9/tiUUAHIbCfGfb3Zd3+Dyk17WODPKXBeJ/TnK5U+x743QmA0xHzlSm8I/qhw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-2.2.0.tgz", + "integrity": "sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw==", "dependencies": { - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-retry": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-2.1.3.tgz", - "integrity": "sha512-Kbvd+GEMuozbNUU3B89mb99tbufwREcyx2BOX0X2+qHjq6Gvsah8xSDDgxISDwcOHoDqUWO425F0Uc/QIRhYkg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-2.2.0.tgz", + "integrity": "sha512-q9+pAFPTfftHXRytmZ7GzLFFrEGavqapFc06XxzZFcSIGERXMerXxCitjOG1prVDR9QdjqotF40SWvbqcCpf8g==", "dependencies": { - "@smithy/service-error-classification": "^2.1.3", - "@smithy/types": "^2.10.1", - "tslib": "^2.5.0" + "@smithy/service-error-classification": "^2.1.5", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@smithy/util-stream": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-2.1.3.tgz", - "integrity": "sha512-HvpEQbP8raTy9n86ZfXiAkf3ezp1c3qeeO//zGqwZdrfaoOpGKQgF2Sv1IqZp7wjhna7pvczWaGUHjcOPuQwKw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-2.2.0.tgz", + "integrity": "sha512-17faEXbYWIRst1aU9SvPZyMdWmqIrduZjVOqCPMIsWFNxs5yQQgFrJL6b2SdiCzyW9mJoDjFtgi53xx7EH+BXA==", "dependencies": { - "@smithy/fetch-http-handler": "^2.4.3", - "@smithy/node-http-handler": "^2.4.1", - "@smithy/types": "^2.10.1", - "@smithy/util-base64": "^2.1.1", - "@smithy/util-buffer-from": "^2.1.1", - "@smithy/util-hex-encoding": "^2.1.1", - "@smithy/util-utf8": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/fetch-http-handler": "^2.5.0", + "@smithy/node-http-handler": "^2.5.0", + "@smithy/types": "^2.12.0", + "@smithy/util-base64": "^2.3.0", + "@smithy/util-buffer-from": "^2.2.0", + "@smithy/util-hex-encoding": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-uri-escape": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-2.1.1.tgz", - "integrity": "sha512-saVzI1h6iRBUVSqtnlOnc9ssU09ypo7n+shdQ8hBTZno/9rZ3AuRYvoHInV57VF7Qn7B+pFJG7qTzFiHxWlWBw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-2.2.0.tgz", + "integrity": "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-utf8": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.1.1.tgz", - "integrity": "sha512-BqTpzYEcUMDwAKr7/mVRUtHDhs6ZoXDi9NypMvMfOr/+u1NW7JgqodPDECiiLboEm6bobcPcECxzjtQh865e9A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "dependencies": { - "@smithy/util-buffer-from": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" @@ -6035,6 +6188,14 @@ "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -6137,6 +6298,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -6186,15 +6348,13 @@ } }, "node_modules/bullmq": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.3.3.tgz", - "integrity": "sha512-Gc/68HxiCHLMPBiGIqtINxcf8HER/5wvBYMY/6x3tFejlvldUBFaAErMTLDv4TnPsTyzNPrfBKmFCEM58uVnJg==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz", + "integrity": "sha512-dkR/KGUw18miLe3QWtvSlmGvEe08aZF+w1jZyqEHMWFW3RP4162qp6OGud0/QCAOjusiRI8UOxUhbnortPY+rA==", "dependencies": { "cron-parser": "^4.6.0", - "fast-glob": "^3.3.2", "ioredis": "^5.3.2", "lodash": "^4.17.21", - "minimatch": "^9.0.3", "msgpackr": "^1.10.1", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", @@ -6202,28 +6362,6 @@ "uuid": "^9.0.0" } }, - "node_modules/bullmq/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/bullmq/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/bundle-require": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-4.0.2.tgz", @@ -7611,6 +7749,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/extsprintf": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", @@ -7650,6 +7793,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -7801,6 +7945,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -8138,6 +8283,88 @@ "node": ">=8" } }, + "node_modules/gaxios": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.5.0.tgz", + "integrity": "sha512-R9QGdv8j4/dlNoQbX3hSaK/S0rkMijqjVvW3YM06CoBdbU/VdKd159j4hePpng0KuE6Lh6JJ7UdmVGJZFcAG1w==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/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==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gaxios/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -8252,6 +8479,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -8334,6 +8562,69 @@ "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", "dev": true }, + "node_modules/google-auth-library": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.9.0.tgz", + "integrity": "sha512-9l+zO07h1tDJdIHN74SpnWIlNR+OuOemXlWJlLP9pXy6vFtizgpEzMuwJa4lqY9UAdiAv5DVd5ql0Am916I+aA==", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/googleapis": { + "version": "137.1.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-137.1.0.tgz", + "integrity": "sha512-2L7SzN0FLHyQtFmyIxrcXhgust77067pkkduqkbIpDuj9JzVnByxsRrcRfUMFQam3rQkWW2B0f1i40IwKDWIVQ==", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -8356,6 +8647,37 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gtoken/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/gtoken/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -8852,6 +9174,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -8882,6 +9205,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -8916,6 +9240,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "engines": { "node": ">=0.12.0" } @@ -9129,6 +9454,14 @@ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -9744,6 +10077,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "engines": { "node": ">= 8" } @@ -9760,6 +10094,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -9772,6 +10107,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -9954,9 +10290,10 @@ } }, "node_modules/mysql2": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.4.tgz", - "integrity": "sha512-OEESQuwxMza803knC1YSt7NMuc1BrK9j7gZhCSs2WAyxr1vfiI7QLaLOKTh5c9SWGz98qVyQUbK8/WckevNQhg==", + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.8.tgz", + "integrity": "sha512-+5JKNjPuks1FNMoy9TYpl77f+5frbTklz7eb3XDwbpsERRLEeXiW2PDEkakYF50UuKU2qwfGnyXpKYvukv8mGA==", + "license": "MIT", "dependencies": { "denque": "^2.1.0", "generate-function": "^2.3.1", @@ -11401,6 +11738,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -11753,6 +12091,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -12553,6 +12892,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -13555,6 +13895,11 @@ "querystring": "0.2.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==" + }, "node_modules/url/node_modules/punycode": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", diff --git a/backend/package.json b/backend/package.json index 31b9fdb14..3d1937964 100644 --- a/backend/package.json +++ b/backend/package.json @@ -95,11 +95,13 @@ "axios": "^1.6.7", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", - "bullmq": "^5.3.3", + "bullmq": "^5.4.2", "cassandra-driver": "^4.7.2", "dotenv": "^16.4.1", "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", + "google-auth-library": "^9.9.0", + "googleapis": "^137.1.0", "handlebars": "^4.7.8", "ioredis": "^5.3.2", "jmespath": "^0.16.0", @@ -110,7 +112,7 @@ "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", - "mysql2": "^3.9.4", + "mysql2": "^3.9.8", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", "ora": "^7.0.1", diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 8c913991f..43984ecfa 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -35,6 +35,8 @@ const getZodPrimitiveType = (type: string) => { return "z.coerce.number()"; case "text": return "z.string()"; + case "bytea": + return "zodBuffer"; default: throw new Error(`Invalid type: ${type}`); } @@ -96,10 +98,15 @@ const main = async () => { const columnNames = Object.keys(columns); let schema = ""; + const zodImportSet = new Set(); for (let colNum = 0; colNum < columnNames.length; colNum++) { const columnName = columnNames[colNum]; const colInfo = columns[columnName]; let ztype = getZodPrimitiveType(colInfo.type); + if (["zodBuffer"].includes(ztype)) { + zodImportSet.add(ztype); + } + // don't put optional on id if (colInfo.defaultValue && columnName !== "id") { const { defaultValue } = colInfo; @@ -121,6 +128,8 @@ const main = async () => { .split("_") .reduce((prev, curr) => prev + `${curr.at(0)?.toUpperCase()}${curr.slice(1).toLowerCase()}`, ""); + const zodImports = Array.from(zodImportSet); + // the insert and update are changed to zod input type to use default cases writeFileSync( path.join(__dirname, "../src/db/schemas", `${dashcase}.ts`), @@ -131,6 +140,8 @@ const main = async () => { import { z } from "zod"; +${zodImports.length ? `import { ${zodImports.join(",")} } from \"@app/lib/zod\";` : ""} + import { TImmutableDBKeys } from "./models"; export const ${pascalCase}Schema = z.object({${schema}}); diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index a4c3eea7b..3f1ca94e9 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -1,8 +1,11 @@ import "fastify"; import { TUsers } from "@app/db/schemas"; +import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; +import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; +import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; @@ -29,6 +32,10 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; +import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; +import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; +import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; +import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; @@ -45,6 +52,8 @@ import { TSecretServiceFactory } from "@app/services/secret/secret-service"; import { TSecretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service"; import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; +import { TSecretReplicationServiceFactory } from "@app/services/secret-replication/secret-replication-service"; +import { TSecretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service"; import { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service"; import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; @@ -100,6 +109,7 @@ declare module "fastify" { projectKey: TProjectKeyServiceFactory; projectRole: TProjectRoleServiceFactory; secret: TSecretServiceFactory; + secretReplication: TSecretReplicationServiceFactory; secretTag: TSecretTagServiceFactory; secretImport: TSecretImportServiceFactory; projectBot: TProjectBotServiceFactory; @@ -112,6 +122,12 @@ declare module "fastify" { identityAccessToken: TIdentityAccessTokenServiceFactory; identityProject: TIdentityProjectServiceFactory; identityUa: TIdentityUaServiceFactory; + identityKubernetesAuth: TIdentityKubernetesAuthServiceFactory; + identityGcpAuth: TIdentityGcpAuthServiceFactory; + identityAwsAuth: TIdentityAwsAuthServiceFactory; + identityAzureAuth: TIdentityAzureAuthServiceFactory; + accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; + accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; secretApprovalRequest: TSecretApprovalRequestServiceFactory; secretRotation: TSecretRotationServiceFactory; @@ -120,6 +136,7 @@ declare module "fastify" { scim: TScimServiceFactory; ldap: TLdapConfigServiceFactory; auditLog: TAuditLogServiceFactory; + auditLogStream: TAuditLogStreamServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; trustedIp: TTrustedIpServiceFactory; @@ -129,6 +146,7 @@ declare module "fastify" { dynamicSecretLease: TDynamicSecretLeaseServiceFactory; projectUserAdditionalPrivilege: TProjectUserAdditionalPrivilegeServiceFactory; identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory; + secretSharing: TSecretSharingServiceFactory; }; // 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 8845c1d01..117a74e76 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -2,11 +2,26 @@ import { Knex } from "knex"; import { TableName, + TAccessApprovalPolicies, + TAccessApprovalPoliciesApprovers, + TAccessApprovalPoliciesApproversInsert, + TAccessApprovalPoliciesApproversUpdate, + TAccessApprovalPoliciesInsert, + TAccessApprovalPoliciesUpdate, + TAccessApprovalRequests, + TAccessApprovalRequestsInsert, + TAccessApprovalRequestsReviewers, + TAccessApprovalRequestsReviewersInsert, + TAccessApprovalRequestsReviewersUpdate, + TAccessApprovalRequestsUpdate, TApiKeys, TApiKeysInsert, TApiKeysUpdate, TAuditLogs, TAuditLogsInsert, + TAuditLogStreams, + TAuditLogStreamsInsert, + TAuditLogStreamsUpdate, TAuditLogsUpdate, TAuthTokens, TAuthTokenSessions, @@ -44,6 +59,18 @@ import { TIdentityAccessTokens, TIdentityAccessTokensInsert, TIdentityAccessTokensUpdate, + TIdentityAwsAuths, + TIdentityAwsAuthsInsert, + TIdentityAwsAuthsUpdate, + TIdentityAzureAuths, + TIdentityAzureAuthsInsert, + TIdentityAzureAuthsUpdate, + TIdentityGcpAuths, + TIdentityGcpAuthsInsert, + TIdentityGcpAuthsUpdate, + TIdentityKubernetesAuths, + TIdentityKubernetesAuthsInsert, + TIdentityKubernetesAuthsUpdate, TIdentityOrgMemberships, TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsUpdate, @@ -71,6 +98,15 @@ import { TIntegrations, TIntegrationsInsert, TIntegrationsUpdate, + TKmsKeys, + TKmsKeysInsert, + TKmsKeysUpdate, + TKmsKeyVersions, + TKmsKeyVersionsInsert, + TKmsKeyVersionsUpdate, + TKmsRootConfig, + TKmsRootConfigInsert, + TKmsRootConfigUpdate, TLdapConfigs, TLdapConfigsInsert, TLdapConfigsUpdate, @@ -149,6 +185,9 @@ import { TSecretImports, TSecretImportsInsert, TSecretImportsUpdate, + TSecretReferences, + TSecretReferencesInsert, + TSecretReferencesUpdate, TSecretRotationOutputs, TSecretRotationOutputsInsert, TSecretRotationOutputsUpdate, @@ -159,6 +198,9 @@ import { TSecretScanningGitRisks, TSecretScanningGitRisksInsert, TSecretScanningGitRisksUpdate, + TSecretSharing, + TSecretSharingInsert, + TSecretSharingUpdate, TSecretsInsert, TSecretSnapshotFolders, TSecretSnapshotFoldersInsert, @@ -283,6 +325,11 @@ declare module "knex/types/tables" { >; [TableName.ProjectKeys]: Knex.CompositeTableType; [TableName.Secret]: Knex.CompositeTableType; + [TableName.SecretReference]: Knex.CompositeTableType< + TSecretReferences, + TSecretReferencesInsert, + TSecretReferencesUpdate + >; [TableName.SecretBlindIndex]: Knex.CompositeTableType< TSecretBlindIndexes, TSecretBlindIndexesInsert, @@ -295,6 +342,7 @@ declare module "knex/types/tables" { TSecretFolderVersionsInsert, TSecretFolderVersionsUpdate >; + [TableName.SecretSharing]: Knex.CompositeTableType; [TableName.SecretTag]: Knex.CompositeTableType; [TableName.SecretImport]: Knex.CompositeTableType; [TableName.Integration]: Knex.CompositeTableType; @@ -311,6 +359,26 @@ declare module "knex/types/tables" { TIdentityUniversalAuthsInsert, TIdentityUniversalAuthsUpdate >; + [TableName.IdentityKubernetesAuth]: Knex.CompositeTableType< + TIdentityKubernetesAuths, + TIdentityKubernetesAuthsInsert, + TIdentityKubernetesAuthsUpdate + >; + [TableName.IdentityGcpAuth]: Knex.CompositeTableType< + TIdentityGcpAuths, + TIdentityGcpAuthsInsert, + TIdentityGcpAuthsUpdate + >; + [TableName.IdentityAwsAuth]: Knex.CompositeTableType< + TIdentityAwsAuths, + TIdentityAwsAuthsInsert, + TIdentityAwsAuthsUpdate + >; + [TableName.IdentityAzureAuth]: Knex.CompositeTableType< + TIdentityAzureAuths, + TIdentityAzureAuthsInsert, + TIdentityAzureAuthsUpdate + >; [TableName.IdentityUaClientSecret]: Knex.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, @@ -341,6 +409,31 @@ declare module "knex/types/tables" { TIdentityProjectAdditionalPrivilegeInsert, TIdentityProjectAdditionalPrivilegeUpdate >; + + [TableName.AccessApprovalPolicy]: Knex.CompositeTableType< + TAccessApprovalPolicies, + TAccessApprovalPoliciesInsert, + TAccessApprovalPoliciesUpdate + >; + + [TableName.AccessApprovalPolicyApprover]: Knex.CompositeTableType< + TAccessApprovalPoliciesApprovers, + TAccessApprovalPoliciesApproversInsert, + TAccessApprovalPoliciesApproversUpdate + >; + + [TableName.AccessApprovalRequest]: Knex.CompositeTableType< + TAccessApprovalRequests, + TAccessApprovalRequestsInsert, + TAccessApprovalRequestsUpdate + >; + + [TableName.AccessApprovalRequestReviewer]: Knex.CompositeTableType< + TAccessApprovalRequestsReviewers, + TAccessApprovalRequestsReviewersInsert, + TAccessApprovalRequestsReviewersUpdate + >; + [TableName.ScimToken]: Knex.CompositeTableType; [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< TSecretApprovalPolicies, @@ -404,6 +497,11 @@ declare module "knex/types/tables" { [TableName.LdapGroupMap]: Knex.CompositeTableType; [TableName.OrgBot]: Knex.CompositeTableType; [TableName.AuditLog]: Knex.CompositeTableType; + [TableName.AuditLogStream]: Knex.CompositeTableType< + TAuditLogStreams, + TAuditLogStreamsInsert, + TAuditLogStreamsUpdate + >; [TableName.GitAppInstallSession]: Knex.CompositeTableType< TGitAppInstallSessions, TGitAppInstallSessionsInsert, @@ -427,5 +525,13 @@ declare module "knex/types/tables" { TSecretVersionTagJunctionInsert, TSecretVersionTagJunctionUpdate >; + // KMS service + [TableName.KmsServerRootConfig]: Knex.CompositeTableType< + TKmsRootConfig, + TKmsRootConfigInsert, + TKmsRootConfigUpdate + >; + [TableName.KmsKey]: Knex.CompositeTableType; + [TableName.KmsKeyVersion]: Knex.CompositeTableType; } } diff --git a/backend/src/db/migrations/20240429154610_audit-log-index.ts b/backend/src/db/migrations/20240429154610_audit-log-index.ts new file mode 100644 index 000000000..40a1cb24d --- /dev/null +++ b/backend/src/db/migrations/20240429154610_audit-log-index.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesOrgIdExist = await knex.schema.hasColumn(TableName.AuditLog, "orgId"); + const doesProjectIdExist = await knex.schema.hasColumn(TableName.AuditLog, "projectId"); + const doesCreatedAtExist = await knex.schema.hasColumn(TableName.AuditLog, "createdAt"); + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesProjectIdExist && doesCreatedAtExist) t.index(["projectId", "createdAt"]); + if (doesOrgIdExist && doesCreatedAtExist) t.index(["orgId", "createdAt"]); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesOrgIdExist = await knex.schema.hasColumn(TableName.AuditLog, "orgId"); + const doesProjectIdExist = await knex.schema.hasColumn(TableName.AuditLog, "projectId"); + const doesCreatedAtExist = await knex.schema.hasColumn(TableName.AuditLog, "createdAt"); + + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesProjectIdExist && doesCreatedAtExist) t.dropIndex(["projectId", "createdAt"]); + if (doesOrgIdExist && doesCreatedAtExist) t.dropIndex(["orgId", "createdAt"]); + }); + } +} diff --git a/backend/src/db/migrations/20240503101144_audit-log-stream.ts b/backend/src/db/migrations/20240503101144_audit-log-stream.ts new file mode 100644 index 000000000..210ee1bfa --- /dev/null +++ b/backend/src/db/migrations/20240503101144_audit-log-stream.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AuditLogStream))) { + await knex.schema.createTable(TableName.AuditLogStream, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("url").notNullable(); + t.text("encryptedHeadersCiphertext"); + t.text("encryptedHeadersIV"); + t.text("encryptedHeadersTag"); + t.string("encryptedHeadersAlgorithm"); + t.string("encryptedHeadersKeyEncoding"); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.AuditLogStream); +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.AuditLogStream); + await knex.schema.dropTableIfExists(TableName.AuditLogStream); +} diff --git a/backend/src/db/migrations/20240507032811_trusted-saml-ldap-emails.ts b/backend/src/db/migrations/20240507032811_trusted-saml-ldap-emails.ts new file mode 100644 index 000000000..410ee0f00 --- /dev/null +++ b/backend/src/db/migrations/20240507032811_trusted-saml-ldap-emails.ts @@ -0,0 +1,54 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const isUsersTablePresent = await knex.schema.hasTable(TableName.Users); + if (isUsersTablePresent) { + const hasIsEmailVerifiedColumn = await knex.schema.hasColumn(TableName.Users, "isEmailVerified"); + + if (!hasIsEmailVerifiedColumn) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.boolean("isEmailVerified").defaultTo(false); + }); + } + + // Backfilling the isEmailVerified to true where isAccepted is true + await knex(TableName.Users).update({ isEmailVerified: true }).where("isAccepted", true); + } + + const isUserAliasTablePresent = await knex.schema.hasTable(TableName.UserAliases); + if (isUserAliasTablePresent) { + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.string("username").nullable().alter(); + }); + } + + const isSuperAdminTablePresent = await knex.schema.hasTable(TableName.SuperAdmin); + if (isSuperAdminTablePresent) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.boolean("trustSamlEmails").defaultTo(false); + t.boolean("trustLdapEmails").defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Users, "isEmailVerified")) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.dropColumn("isEmailVerified"); + }); + } + + if (await knex.schema.hasColumn(TableName.SuperAdmin, "trustSamlEmails")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("trustSamlEmails"); + }); + } + + if (await knex.schema.hasColumn(TableName.SuperAdmin, "trustLdapEmails")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("trustLdapEmails"); + }); + } +} diff --git a/backend/src/db/migrations/20240507162140_access-approval-policy.ts b/backend/src/db/migrations/20240507162140_access-approval-policy.ts new file mode 100644 index 000000000..feeecd25b --- /dev/null +++ b/backend/src/db/migrations/20240507162140_access-approval-policy.ts @@ -0,0 +1,41 @@ +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.AccessApprovalPolicy))) { + await knex.schema.createTable(TableName.AccessApprovalPolicy, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.integer("approvals").defaultTo(1).notNullable(); + t.string("secretPath"); + + t.uuid("envId").notNullable(); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); + } + + if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicyApprover))) { + await knex.schema.createTable(TableName.AccessApprovalPolicyApprover, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("approverId").notNullable(); + t.foreign("approverId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover); + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy); + + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); +} diff --git a/backend/src/db/migrations/20240507162141_access.ts b/backend/src/db/migrations/20240507162141_access.ts new file mode 100644 index 000000000..901be9a78 --- /dev/null +++ b/backend/src/db/migrations/20240507162141_access.ts @@ -0,0 +1,51 @@ +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.AccessApprovalRequest))) { + await knex.schema.createTable(TableName.AccessApprovalRequest, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); + + t.uuid("privilegeId").nullable(); + t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("CASCADE"); + + t.uuid("requestedBy").notNullable(); + t.foreign("requestedBy").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + + // We use these values to create the actual privilege at a later point in time. + t.boolean("isTemporary").notNullable(); + t.string("temporaryRange").nullable(); + + t.jsonb("permissions").notNullable(); + + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.AccessApprovalRequest); + + if (!(await knex.schema.hasTable(TableName.AccessApprovalRequestReviewer))) { + await knex.schema.createTable(TableName.AccessApprovalRequestReviewer, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("member").notNullable(); + t.foreign("member").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + t.string("status").notNullable(); + t.uuid("requestId").notNullable(); + t.foreign("requestId").references("id").inTable(TableName.AccessApprovalRequest).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.AccessApprovalRequestReviewer); + await knex.schema.dropTableIfExists(TableName.AccessApprovalRequest); + + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest); +} diff --git a/backend/src/db/migrations/20240507210655_identity-aws-auth.ts b/backend/src/db/migrations/20240507210655_identity-aws-auth.ts new file mode 100644 index 000000000..f182425c3 --- /dev/null +++ b/backend/src/db/migrations/20240507210655_identity-aws-auth.ts @@ -0,0 +1,30 @@ +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.IdentityAwsAuth))) { + await knex.schema.createTable(TableName.IdentityAwsAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("type").notNullable(); + t.string("stsEndpoint").notNullable(); + t.string("allowedPrincipalArns").notNullable(); + t.string("allowedAccountIds").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityAwsAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityAwsAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityAwsAuth); +} diff --git a/backend/src/db/migrations/20240514041650_identity-gcp-auth.ts b/backend/src/db/migrations/20240514041650_identity-gcp-auth.ts new file mode 100644 index 000000000..8c80fed84 --- /dev/null +++ b/backend/src/db/migrations/20240514041650_identity-gcp-auth.ts @@ -0,0 +1,30 @@ +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.IdentityGcpAuth))) { + await knex.schema.createTable(TableName.IdentityGcpAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("type").notNullable(); + t.string("allowedServiceAccounts").notNullable(); + t.string("allowedProjects").notNullable(); + t.string("allowedZones").notNullable(); // GCE only (fully qualified zone names) + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityGcpAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityGcpAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityGcpAuth); +} diff --git a/backend/src/db/migrations/20240514141809_inline-secret-reference-sync.ts b/backend/src/db/migrations/20240514141809_inline-secret-reference-sync.ts new file mode 100644 index 000000000..fa6fb4fea --- /dev/null +++ b/backend/src/db/migrations/20240514141809_inline-secret-reference-sync.ts @@ -0,0 +1,24 @@ +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.SecretReference))) { + await knex.schema.createTable(TableName.SecretReference, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("environment").notNullable(); + t.string("secretPath").notNullable(); + t.uuid("secretId").notNullable(); + t.foreign("secretId").references("id").inTable(TableName.Secret).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.SecretReference); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretReference); + await dropOnUpdateTrigger(knex, TableName.SecretReference); +} diff --git a/backend/src/db/migrations/20240518142614_kubernetes-auth.ts b/backend/src/db/migrations/20240518142614_kubernetes-auth.ts new file mode 100644 index 000000000..dd281a3ad --- /dev/null +++ b/backend/src/db/migrations/20240518142614_kubernetes-auth.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.IdentityKubernetesAuth))) { + await knex.schema.createTable(TableName.IdentityKubernetesAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("kubernetesHost").notNullable(); + t.text("encryptedCaCert").notNullable(); + t.string("caCertIV").notNullable(); + t.string("caCertTag").notNullable(); + t.text("encryptedTokenReviewerJwt").notNullable(); + t.string("tokenReviewerJwtIV").notNullable(); + t.string("tokenReviewerJwtTag").notNullable(); + t.string("allowedNamespaces").notNullable(); + t.string("allowedNames").notNullable(); + t.string("allowedAudience").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityKubernetesAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityKubernetesAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityKubernetesAuth); +} diff --git a/backend/src/db/migrations/20240520064127_add-integration-sync-status.ts b/backend/src/db/migrations/20240520064127_add-integration-sync-status.ts new file mode 100644 index 000000000..74b828714 --- /dev/null +++ b/backend/src/db/migrations/20240520064127_add-integration-sync-status.ts @@ -0,0 +1,43 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasIsSyncedColumn = await knex.schema.hasColumn(TableName.Integration, "isSynced"); + const hasSyncMessageColumn = await knex.schema.hasColumn(TableName.Integration, "syncMessage"); + const hasLastSyncJobId = await knex.schema.hasColumn(TableName.Integration, "lastSyncJobId"); + + await knex.schema.alterTable(TableName.Integration, (t) => { + if (!hasIsSyncedColumn) { + t.boolean("isSynced").nullable(); + } + + if (!hasSyncMessageColumn) { + t.text("syncMessage").nullable(); + } + + if (!hasLastSyncJobId) { + t.string("lastSyncJobId").nullable(); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasIsSyncedColumn = await knex.schema.hasColumn(TableName.Integration, "isSynced"); + const hasSyncMessageColumn = await knex.schema.hasColumn(TableName.Integration, "syncMessage"); + const hasLastSyncJobId = await knex.schema.hasColumn(TableName.Integration, "lastSyncJobId"); + + await knex.schema.alterTable(TableName.Integration, (t) => { + if (hasIsSyncedColumn) { + t.dropColumn("isSynced"); + } + + if (hasSyncMessageColumn) { + t.dropColumn("syncMessage"); + } + + if (hasLastSyncJobId) { + t.dropColumn("lastSyncJobId"); + } + }); +} diff --git a/backend/src/db/migrations/20240522193447_index-audit-logs-project-id-org-id.ts b/backend/src/db/migrations/20240522193447_index-audit-logs-project-id-org-id.ts new file mode 100644 index 000000000..7b208f010 --- /dev/null +++ b/backend/src/db/migrations/20240522193447_index-audit-logs-project-id-org-id.ts @@ -0,0 +1,26 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesOrgIdExist = await knex.schema.hasColumn(TableName.AuditLog, "orgId"); + const doesProjectIdExist = await knex.schema.hasColumn(TableName.AuditLog, "projectId"); + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesProjectIdExist) t.index("projectId"); + if (doesOrgIdExist) t.index("orgId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesOrgIdExist = await knex.schema.hasColumn(TableName.AuditLog, "orgId"); + const doesProjectIdExist = await knex.schema.hasColumn(TableName.AuditLog, "projectId"); + + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesProjectIdExist) t.dropIndex("projectId"); + if (doesOrgIdExist) t.dropIndex("orgId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522203425_index-secret-snapshot-secrets-envid.ts b/backend/src/db/migrations/20240522203425_index-secret-snapshot-secrets-envid.ts new file mode 100644 index 000000000..59fe14145 --- /dev/null +++ b/backend/src/db/migrations/20240522203425_index-secret-snapshot-secrets-envid.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesEnvIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "envId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesEnvIdExist) t.index("envId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesEnvIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "envId"); + + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesEnvIdExist) t.dropIndex("envId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522204414_index-secret-version-envId.ts b/backend/src/db/migrations/20240522204414_index-secret-version-envId.ts new file mode 100644 index 000000000..f01c0d3cc --- /dev/null +++ b/backend/src/db/migrations/20240522204414_index-secret-version-envId.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesEnvIdExist = await knex.schema.hasColumn(TableName.SecretVersion, "envId"); + if (await knex.schema.hasTable(TableName.SecretVersion)) { + await knex.schema.alterTable(TableName.SecretVersion, (t) => { + if (doesEnvIdExist) t.index("envId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesEnvIdExist = await knex.schema.hasColumn(TableName.SecretVersion, "envId"); + + if (await knex.schema.hasTable(TableName.SecretVersion)) { + await knex.schema.alterTable(TableName.SecretVersion, (t) => { + if (doesEnvIdExist) t.dropIndex("envId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522212706_secret-snapshot-secrets-index-on-snapshotId.ts b/backend/src/db/migrations/20240522212706_secret-snapshot-secrets-index-on-snapshotId.ts new file mode 100644 index 000000000..7f200ed3e --- /dev/null +++ b/backend/src/db/migrations/20240522212706_secret-snapshot-secrets-index-on-snapshotId.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesSnapshotIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "snapshotId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesSnapshotIdExist) t.index("snapshotId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesSnapshotIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "snapshotId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesSnapshotIdExist) t.dropIndex("snapshotId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522221147_secret-snapshot-folder-index-on-snapshotId.ts b/backend/src/db/migrations/20240522221147_secret-snapshot-folder-index-on-snapshotId.ts new file mode 100644 index 000000000..ffb7c3336 --- /dev/null +++ b/backend/src/db/migrations/20240522221147_secret-snapshot-folder-index-on-snapshotId.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesSnapshotIdExist = await knex.schema.hasColumn(TableName.SnapshotFolder, "snapshotId"); + if (await knex.schema.hasTable(TableName.SnapshotFolder)) { + await knex.schema.alterTable(TableName.SnapshotFolder, (t) => { + if (doesSnapshotIdExist) t.index("snapshotId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesSnapshotIdExist = await knex.schema.hasColumn(TableName.SnapshotFolder, "snapshotId"); + if (await knex.schema.hasTable(TableName.SnapshotFolder)) { + await knex.schema.alterTable(TableName.SnapshotFolder, (t) => { + if (doesSnapshotIdExist) t.dropIndex("snapshotId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522225402_secrets-index-on-folder-id-user-id.ts b/backend/src/db/migrations/20240522225402_secrets-index-on-folder-id-user-id.ts new file mode 100644 index 000000000..f1225e264 --- /dev/null +++ b/backend/src/db/migrations/20240522225402_secrets-index-on-folder-id-user-id.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesFolderIdExist = await knex.schema.hasColumn(TableName.Secret, "folderId"); + const doesUserIdExist = await knex.schema.hasColumn(TableName.Secret, "userId"); + if (await knex.schema.hasTable(TableName.Secret)) { + await knex.schema.alterTable(TableName.Secret, (t) => { + if (doesFolderIdExist && doesUserIdExist) t.index(["folderId", "userId"]); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesFolderIdExist = await knex.schema.hasColumn(TableName.Secret, "folderId"); + const doesUserIdExist = await knex.schema.hasColumn(TableName.Secret, "userId"); + + if (await knex.schema.hasTable(TableName.Secret)) { + await knex.schema.alterTable(TableName.Secret, (t) => { + if (doesUserIdExist && doesFolderIdExist) t.dropIndex(["folderId", "userId"]); + }); + } +} diff --git a/backend/src/db/migrations/20240523003158_audit-log-add-expireAt-index.ts b/backend/src/db/migrations/20240523003158_audit-log-add-expireAt-index.ts new file mode 100644 index 000000000..b6dbf3e74 --- /dev/null +++ b/backend/src/db/migrations/20240523003158_audit-log-add-expireAt-index.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesExpireAtExist = await knex.schema.hasColumn(TableName.AuditLog, "expiresAt"); + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesExpireAtExist) t.index("expiresAt"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesExpireAtExist = await knex.schema.hasColumn(TableName.AuditLog, "expiresAt"); + + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesExpireAtExist) t.dropIndex("expiresAt"); + }); + } +} diff --git a/backend/src/db/migrations/20240527073740_identity-azure-auth.ts b/backend/src/db/migrations/20240527073740_identity-azure-auth.ts new file mode 100644 index 000000000..3d91b2f9c --- /dev/null +++ b/backend/src/db/migrations/20240527073740_identity-azure-auth.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityAzureAuth))) { + await knex.schema.createTable(TableName.IdentityAzureAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("tenantId").notNullable(); + t.string("resource").notNullable(); + t.string("allowedServicePrincipalIds").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityAzureAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityAzureAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityAzureAuth); +} diff --git a/backend/src/db/migrations/20240528153905_add-user-account-mfa-locking.ts b/backend/src/db/migrations/20240528153905_add-user-account-mfa-locking.ts new file mode 100644 index 000000000..2b2ecd783 --- /dev/null +++ b/backend/src/db/migrations/20240528153905_add-user-account-mfa-locking.ts @@ -0,0 +1,43 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasConsecutiveFailedMfaAttempts = await knex.schema.hasColumn(TableName.Users, "consecutiveFailedMfaAttempts"); + const hasIsLocked = await knex.schema.hasColumn(TableName.Users, "isLocked"); + const hasTemporaryLockDateEnd = await knex.schema.hasColumn(TableName.Users, "temporaryLockDateEnd"); + + await knex.schema.alterTable(TableName.Users, (t) => { + if (!hasConsecutiveFailedMfaAttempts) { + t.integer("consecutiveFailedMfaAttempts").defaultTo(0); + } + + if (!hasIsLocked) { + t.boolean("isLocked").defaultTo(false); + } + + if (!hasTemporaryLockDateEnd) { + t.dateTime("temporaryLockDateEnd").nullable(); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasConsecutiveFailedMfaAttempts = await knex.schema.hasColumn(TableName.Users, "consecutiveFailedMfaAttempts"); + const hasIsLocked = await knex.schema.hasColumn(TableName.Users, "isLocked"); + const hasTemporaryLockDateEnd = await knex.schema.hasColumn(TableName.Users, "temporaryLockDateEnd"); + + await knex.schema.alterTable(TableName.Users, (t) => { + if (hasConsecutiveFailedMfaAttempts) { + t.dropColumn("consecutiveFailedMfaAttempts"); + } + + if (hasIsLocked) { + t.dropColumn("isLocked"); + } + + if (hasTemporaryLockDateEnd) { + t.dropColumn("temporaryLockDateEnd"); + } + }); +} diff --git a/backend/src/db/migrations/20240528190137_secret_sharing.ts b/backend/src/db/migrations/20240528190137_secret_sharing.ts new file mode 100644 index 000000000..c1eab2ea6 --- /dev/null +++ b/backend/src/db/migrations/20240528190137_secret_sharing.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretSharing))) { + await knex.schema.createTable(TableName.SecretSharing, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.text("encryptedValue").notNullable(); + t.text("iv").notNullable(); + t.text("tag").notNullable(); + t.text("hashedHex").notNullable(); + t.timestamp("expiresAt").notNullable(); + t.uuid("userId").notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.SecretSharing); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretSharing); +} diff --git a/backend/src/db/migrations/20240529060752_snap-shot-secret-index-secretversionid.ts b/backend/src/db/migrations/20240529060752_snap-shot-secret-index-secretversionid.ts new file mode 100644 index 000000000..8d4322b5a --- /dev/null +++ b/backend/src/db/migrations/20240529060752_snap-shot-secret-index-secretversionid.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesSecretVersionIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "secretVersionId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesSecretVersionIdExist) t.index("secretVersionId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesSecretVersionIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "secretVersionId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesSecretVersionIdExist) t.dropIndex("secretVersionId"); + }); + } +} diff --git a/backend/src/db/migrations/20240529203152_secret_sharing.ts b/backend/src/db/migrations/20240529203152_secret_sharing.ts new file mode 100644 index 000000000..c1eab2ea6 --- /dev/null +++ b/backend/src/db/migrations/20240529203152_secret_sharing.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretSharing))) { + await knex.schema.createTable(TableName.SecretSharing, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.text("encryptedValue").notNullable(); + t.text("iv").notNullable(); + t.text("tag").notNullable(); + t.text("hashedHex").notNullable(); + t.timestamp("expiresAt").notNullable(); + t.uuid("userId").notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.SecretSharing); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretSharing); +} diff --git a/backend/src/db/migrations/20240530044702_universal-text-in-secret-sharing.ts b/backend/src/db/migrations/20240530044702_universal-text-in-secret-sharing.ts new file mode 100644 index 000000000..e23d134db --- /dev/null +++ b/backend/src/db/migrations/20240530044702_universal-text-in-secret-sharing.ts @@ -0,0 +1,33 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews"); + const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name"); + + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + if (!hasExpiresAfterViewsColumn) { + t.integer("expiresAfterViews"); + } + + if (hasSecretNameColumn) { + t.dropColumn("name"); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews"); + const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name"); + + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + if (hasExpiresAfterViewsColumn) { + t.dropColumn("expiresAfterViews"); + } + + if (!hasSecretNameColumn) { + t.string("name").notNullable(); + } + }); +} diff --git a/backend/src/db/migrations/20240531220007_secret-replication.ts b/backend/src/db/migrations/20240531220007_secret-replication.ts new file mode 100644 index 000000000..ddb965df4 --- /dev/null +++ b/backend/src/db/migrations/20240531220007_secret-replication.ts @@ -0,0 +1,85 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesSecretImportIsReplicationExist = await knex.schema.hasColumn(TableName.SecretImport, "isReplication"); + const doesSecretImportIsReplicationSuccessExist = await knex.schema.hasColumn( + TableName.SecretImport, + "isReplicationSuccess" + ); + const doesSecretImportReplicationStatusExist = await knex.schema.hasColumn( + TableName.SecretImport, + "replicationStatus" + ); + const doesSecretImportLastReplicatedExist = await knex.schema.hasColumn(TableName.SecretImport, "lastReplicated"); + const doesSecretImportIsReservedExist = await knex.schema.hasColumn(TableName.SecretImport, "isReserved"); + + if (await knex.schema.hasTable(TableName.SecretImport)) { + await knex.schema.alterTable(TableName.SecretImport, (t) => { + if (!doesSecretImportIsReplicationExist) t.boolean("isReplication").defaultTo(false); + if (!doesSecretImportIsReplicationSuccessExist) t.boolean("isReplicationSuccess").nullable(); + if (!doesSecretImportReplicationStatusExist) t.text("replicationStatus").nullable(); + if (!doesSecretImportLastReplicatedExist) t.datetime("lastReplicated").nullable(); + if (!doesSecretImportIsReservedExist) t.boolean("isReserved").defaultTo(false); + }); + } + + const doesSecretFolderReservedExist = await knex.schema.hasColumn(TableName.SecretFolder, "isReserved"); + if (await knex.schema.hasTable(TableName.SecretFolder)) { + await knex.schema.alterTable(TableName.SecretFolder, (t) => { + if (!doesSecretFolderReservedExist) t.boolean("isReserved").defaultTo(false); + }); + } + + const doesSecretApprovalRequestIsReplicatedExist = await knex.schema.hasColumn( + TableName.SecretApprovalRequest, + "isReplicated" + ); + if (await knex.schema.hasTable(TableName.SecretApprovalRequest)) { + await knex.schema.alterTable(TableName.SecretApprovalRequest, (t) => { + if (!doesSecretApprovalRequestIsReplicatedExist) t.boolean("isReplicated"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesSecretImportIsReplicationExist = await knex.schema.hasColumn(TableName.SecretImport, "isReplication"); + const doesSecretImportIsReplicationSuccessExist = await knex.schema.hasColumn( + TableName.SecretImport, + "isReplicationSuccess" + ); + const doesSecretImportReplicationStatusExist = await knex.schema.hasColumn( + TableName.SecretImport, + "replicationStatus" + ); + const doesSecretImportLastReplicatedExist = await knex.schema.hasColumn(TableName.SecretImport, "lastReplicated"); + const doesSecretImportIsReservedExist = await knex.schema.hasColumn(TableName.SecretImport, "isReserved"); + + if (await knex.schema.hasTable(TableName.SecretImport)) { + await knex.schema.alterTable(TableName.SecretImport, (t) => { + if (doesSecretImportIsReplicationExist) t.dropColumn("isReplication"); + if (doesSecretImportIsReplicationSuccessExist) t.dropColumn("isReplicationSuccess"); + if (doesSecretImportReplicationStatusExist) t.dropColumn("replicationStatus"); + if (doesSecretImportLastReplicatedExist) t.dropColumn("lastReplicated"); + if (doesSecretImportIsReservedExist) t.dropColumn("isReserved"); + }); + } + + const doesSecretFolderReservedExist = await knex.schema.hasColumn(TableName.SecretFolder, "isReserved"); + if (await knex.schema.hasTable(TableName.SecretFolder)) { + await knex.schema.alterTable(TableName.SecretFolder, (t) => { + if (doesSecretFolderReservedExist) t.dropColumn("isReserved"); + }); + } + + const doesSecretApprovalRequestIsReplicatedExist = await knex.schema.hasColumn( + TableName.SecretApprovalRequest, + "isReplicated" + ); + if (await knex.schema.hasTable(TableName.SecretApprovalRequest)) { + await knex.schema.alterTable(TableName.SecretApprovalRequest, (t) => { + if (doesSecretApprovalRequestIsReplicatedExist) t.dropColumn("isReplicated"); + }); + } +} diff --git a/backend/src/db/migrations/20240603075514_kms.ts b/backend/src/db/migrations/20240603075514_kms.ts new file mode 100644 index 000000000..3531682d5 --- /dev/null +++ b/backend/src/db/migrations/20240603075514_kms.ts @@ -0,0 +1,56 @@ +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.KmsServerRootConfig))) { + await knex.schema.createTable(TableName.KmsServerRootConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("encryptedRootKey").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.KmsServerRootConfig); + + if (!(await knex.schema.hasTable(TableName.KmsKey))) { + await knex.schema.createTable(TableName.KmsKey, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("encryptedKey").notNullable(); + t.string("encryptionAlgorithm").notNullable(); + t.integer("version").defaultTo(1).notNullable(); + t.string("description"); + t.boolean("isDisabled").defaultTo(false); + t.boolean("isReserved").defaultTo(true); + t.string("projectId"); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("orgId"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + }); + } + + await createOnUpdateTrigger(knex, TableName.KmsKey); + + if (!(await knex.schema.hasTable(TableName.KmsKeyVersion))) { + await knex.schema.createTable(TableName.KmsKeyVersion, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("encryptedKey").notNullable(); + t.integer("version").notNullable(); + t.uuid("kmsKeyId").notNullable(); + t.foreign("kmsKeyId").references("id").inTable(TableName.KmsKey).onDelete("CASCADE"); + }); + } + + await createOnUpdateTrigger(knex, TableName.KmsKeyVersion); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.KmsServerRootConfig); + await dropOnUpdateTrigger(knex, TableName.KmsServerRootConfig); + + await knex.schema.dropTableIfExists(TableName.KmsKeyVersion); + await dropOnUpdateTrigger(knex, TableName.KmsKeyVersion); + + await knex.schema.dropTableIfExists(TableName.KmsKey); + await dropOnUpdateTrigger(knex, TableName.KmsKey); +} diff --git a/backend/src/db/schemas/access-approval-policies-approvers.ts b/backend/src/db/schemas/access-approval-policies-approvers.ts new file mode 100644 index 000000000..4ebbfa9ae --- /dev/null +++ b/backend/src/db/schemas/access-approval-policies-approvers.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 AccessApprovalPoliciesApproversSchema = z.object({ + id: z.string().uuid(), + approverId: z.string().uuid(), + policyId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalPoliciesApprovers = z.infer; +export type TAccessApprovalPoliciesApproversInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TAccessApprovalPoliciesApproversUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/pg-migrator/src/schemas/secret-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts similarity index 54% rename from pg-migrator/src/schemas/secret-approval-policies.ts rename to backend/src/db/schemas/access-approval-policies.ts index ec859bb4e..69068d23b 100644 --- a/pg-migrator/src/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -7,16 +7,18 @@ import { z } from "zod"; import { TImmutableDBKeys } from "./models"; -export const SecretApprovalPoliciesSchema = z.object({ +export const AccessApprovalPoliciesSchema = z.object({ id: z.string().uuid(), name: z.string(), - secretPath: z.string().nullable().optional(), approvals: z.number().default(1), + secretPath: z.string().nullable().optional(), envId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date(), + updatedAt: z.date() }); -export type TSecretApprovalPolicies = z.infer; -export type TSecretApprovalPoliciesInsert = Omit; -export type TSecretApprovalPoliciesUpdate = Partial>; +export type TAccessApprovalPolicies = z.infer; +export type TAccessApprovalPoliciesInsert = Omit, TImmutableDBKeys>; +export type TAccessApprovalPoliciesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/access-approval-requests-reviewers.ts b/backend/src/db/schemas/access-approval-requests-reviewers.ts new file mode 100644 index 000000000..509fd7425 --- /dev/null +++ b/backend/src/db/schemas/access-approval-requests-reviewers.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AccessApprovalRequestsReviewersSchema = z.object({ + id: z.string().uuid(), + member: z.string().uuid(), + status: z.string(), + requestId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalRequestsReviewers = z.infer; +export type TAccessApprovalRequestsReviewersInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TAccessApprovalRequestsReviewersUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts new file mode 100644 index 000000000..bd598bac6 --- /dev/null +++ b/backend/src/db/schemas/access-approval-requests.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AccessApprovalRequestsSchema = z.object({ + id: z.string().uuid(), + policyId: z.string().uuid(), + privilegeId: z.string().uuid().nullable().optional(), + requestedBy: z.string().uuid(), + isTemporary: z.boolean(), + temporaryRange: z.string().nullable().optional(), + permissions: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalRequests = z.infer; +export type TAccessApprovalRequestsInsert = Omit, TImmutableDBKeys>; +export type TAccessApprovalRequestsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/audit-log-streams.ts b/backend/src/db/schemas/audit-log-streams.ts new file mode 100644 index 000000000..901dd8d27 --- /dev/null +++ b/backend/src/db/schemas/audit-log-streams.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 AuditLogStreamsSchema = z.object({ + id: z.string().uuid(), + url: z.string(), + encryptedHeadersCiphertext: z.string().nullable().optional(), + encryptedHeadersIV: z.string().nullable().optional(), + encryptedHeadersTag: z.string().nullable().optional(), + encryptedHeadersAlgorithm: z.string().nullable().optional(), + encryptedHeadersKeyEncoding: z.string().nullable().optional(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAuditLogStreams = z.infer; +export type TAuditLogStreamsInsert = Omit, TImmutableDBKeys>; +export type TAuditLogStreamsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-aws-auths.ts b/backend/src/db/schemas/identity-aws-auths.ts new file mode 100644 index 000000000..f4444b00f --- /dev/null +++ b/backend/src/db/schemas/identity-aws-auths.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityAwsAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + type: z.string(), + stsEndpoint: z.string(), + allowedPrincipalArns: z.string(), + allowedAccountIds: z.string() +}); + +export type TIdentityAwsAuths = z.infer; +export type TIdentityAwsAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityAwsAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-azure-auths.ts b/backend/src/db/schemas/identity-azure-auths.ts new file mode 100644 index 000000000..856f7b8f1 --- /dev/null +++ b/backend/src/db/schemas/identity-azure-auths.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityAzureAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + tenantId: z.string(), + resource: z.string(), + allowedServicePrincipalIds: z.string() +}); + +export type TIdentityAzureAuths = z.infer; +export type TIdentityAzureAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityAzureAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-gcp-auths.ts b/backend/src/db/schemas/identity-gcp-auths.ts new file mode 100644 index 000000000..65c7db837 --- /dev/null +++ b/backend/src/db/schemas/identity-gcp-auths.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityGcpAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + type: z.string(), + allowedServiceAccounts: z.string(), + allowedProjects: z.string(), + allowedZones: z.string() +}); + +export type TIdentityGcpAuths = z.infer; +export type TIdentityGcpAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityGcpAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-kubernetes-auths.ts b/backend/src/db/schemas/identity-kubernetes-auths.ts new file mode 100644 index 000000000..ed99dec86 --- /dev/null +++ b/backend/src/db/schemas/identity-kubernetes-auths.ts @@ -0,0 +1,35 @@ +// 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 IdentityKubernetesAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + kubernetesHost: z.string(), + encryptedCaCert: z.string(), + caCertIV: z.string(), + caCertTag: z.string(), + encryptedTokenReviewerJwt: z.string(), + tokenReviewerJwtIV: z.string(), + tokenReviewerJwtTag: z.string(), + allowedNamespaces: z.string(), + allowedNames: z.string(), + allowedAudience: z.string() +}); + +export type TIdentityKubernetesAuths = z.infer; +export type TIdentityKubernetesAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityKubernetesAuthsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 30d6208b8..1eaa86c87 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -1,4 +1,9 @@ +export * from "./access-approval-policies"; +export * from "./access-approval-policies-approvers"; +export * from "./access-approval-requests"; +export * from "./access-approval-requests-reviewers"; export * from "./api-keys"; +export * from "./audit-log-streams"; export * from "./audit-logs"; export * from "./auth-token-sessions"; export * from "./auth-tokens"; @@ -12,6 +17,10 @@ export * from "./group-project-memberships"; export * from "./groups"; export * from "./identities"; export * from "./identity-access-tokens"; +export * from "./identity-aws-auths"; +export * from "./identity-azure-auths"; +export * from "./identity-gcp-auths"; +export * from "./identity-kubernetes-auths"; export * from "./identity-org-memberships"; export * from "./identity-project-additional-privilege"; export * from "./identity-project-membership-role"; @@ -21,6 +30,9 @@ export * from "./identity-universal-auths"; export * from "./incident-contacts"; export * from "./integration-auths"; export * from "./integrations"; +export * from "./kms-key-versions"; +export * from "./kms-keys"; +export * from "./kms-root-config"; export * from "./ldap-configs"; export * from "./ldap-group-maps"; export * from "./models"; @@ -48,9 +60,11 @@ export * from "./secret-blind-indexes"; export * from "./secret-folder-versions"; export * from "./secret-folders"; export * from "./secret-imports"; +export * from "./secret-references"; export * from "./secret-rotation-outputs"; export * from "./secret-rotations"; export * from "./secret-scanning-git-risks"; +export * from "./secret-sharing"; export * from "./secret-snapshot-folders"; export * from "./secret-snapshot-secrets"; export * from "./secret-snapshots"; diff --git a/backend/src/db/schemas/integrations.ts b/backend/src/db/schemas/integrations.ts index 203498c85..47cf9e627 100644 --- a/backend/src/db/schemas/integrations.ts +++ b/backend/src/db/schemas/integrations.ts @@ -28,7 +28,10 @@ export const IntegrationsSchema = z.object({ secretPath: z.string().default("/"), createdAt: z.date(), updatedAt: z.date(), - lastUsed: z.date().nullable().optional() + lastUsed: z.date().nullable().optional(), + isSynced: z.boolean().nullable().optional(), + syncMessage: z.string().nullable().optional(), + lastSyncJobId: z.string().nullable().optional() }); export type TIntegrations = z.infer; diff --git a/backend/src/db/schemas/kms-key-versions.ts b/backend/src/db/schemas/kms-key-versions.ts new file mode 100644 index 000000000..52a8069df --- /dev/null +++ b/backend/src/db/schemas/kms-key-versions.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const KmsKeyVersionsSchema = z.object({ + id: z.string().uuid(), + encryptedKey: zodBuffer, + version: z.number(), + kmsKeyId: z.string().uuid() +}); + +export type TKmsKeyVersions = z.infer; +export type TKmsKeyVersionsInsert = Omit, TImmutableDBKeys>; +export type TKmsKeyVersionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/kms-keys.ts b/backend/src/db/schemas/kms-keys.ts new file mode 100644 index 000000000..503c270d9 --- /dev/null +++ b/backend/src/db/schemas/kms-keys.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const KmsKeysSchema = z.object({ + id: z.string().uuid(), + encryptedKey: zodBuffer, + encryptionAlgorithm: z.string(), + version: z.number().default(1), + description: z.string().nullable().optional(), + isDisabled: z.boolean().default(false).nullable().optional(), + isReserved: z.boolean().default(true).nullable().optional(), + projectId: z.string().nullable().optional(), + orgId: z.string().uuid().nullable().optional() +}); + +export type TKmsKeys = z.infer; +export type TKmsKeysInsert = Omit, TImmutableDBKeys>; +export type TKmsKeysUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/kms-root-config.ts b/backend/src/db/schemas/kms-root-config.ts new file mode 100644 index 000000000..d2c0edbc5 --- /dev/null +++ b/backend/src/db/schemas/kms-root-config.ts @@ -0,0 +1,19 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const KmsRootConfigSchema = z.object({ + id: z.string().uuid(), + encryptedRootKey: zodBuffer +}); + +export type TKmsRootConfig = z.infer; +export type TKmsRootConfigInsert = Omit, TImmutableDBKeys>; +export type TKmsRootConfigUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index ea70dccdb..f9c8436df 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -28,6 +28,8 @@ export enum TableName { ProjectUserMembershipRole = "project_user_membership_roles", ProjectKeys = "project_keys", Secret = "secrets", + SecretReference = "secret_references", + SecretSharing = "secret_sharing", SecretBlindIndex = "secret_blind_indexes", SecretVersion = "secret_versions", SecretFolder = "secret_folders", @@ -44,12 +46,20 @@ export enum TableName { Identity = "identities", IdentityAccessToken = "identity_access_tokens", IdentityUniversalAuth = "identity_universal_auths", + IdentityKubernetesAuth = "identity_kubernetes_auths", + IdentityGcpAuth = "identity_gcp_auths", + IdentityAzureAuth = "identity_azure_auths", IdentityUaClientSecret = "identity_ua_client_secrets", + IdentityAwsAuth = "identity_aws_auths", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege", ScimToken = "scim_tokens", + AccessApprovalPolicy = "access_approval_policies", + AccessApprovalPolicyApprover = "access_approval_policies_approvers", + AccessApprovalRequest = "access_approval_requests", + AccessApprovalRequestReviewer = "access_approval_requests_reviewers", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", SecretApprovalRequest = "secret_approval_requests", @@ -62,6 +72,7 @@ export enum TableName { LdapConfig = "ldap_configs", LdapGroupMap = "ldap_group_maps", AuditLog = "audit_logs", + AuditLogStream = "audit_log_streams", GitAppInstallSession = "git_app_install_sessions", GitAppOrg = "git_app_org", SecretScanningGitRisk = "secret_scanning_git_risks", @@ -70,7 +81,11 @@ export enum TableName { DynamicSecretLease = "dynamic_secret_leases", // junction tables with tags JnSecretTag = "secret_tag_junction", - SecretVersionTag = "secret_version_tag_junction" + SecretVersionTag = "secret_version_tag_junction", + // KMS Service + KmsServerRootConfig = "kms_root_config", + KmsKey = "kms_keys", + KmsKeyVersion = "kms_key_versions" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; @@ -137,5 +152,9 @@ export enum ProjectUpgradeStatus { } export enum IdentityAuthMethod { - Univeral = "universal-auth" + Univeral = "universal-auth", + KUBERNETES_AUTH = "kubernetes-auth", + GCP_AUTH = "gcp-auth", + AWS_AUTH = "aws-auth", + AZURE_AUTH = "azure-auth" } diff --git a/backend/src/db/schemas/secret-approval-requests.ts b/backend/src/db/schemas/secret-approval-requests.ts index 6ee97fbb6..77ad370b7 100644 --- a/backend/src/db/schemas/secret-approval-requests.ts +++ b/backend/src/db/schemas/secret-approval-requests.ts @@ -18,7 +18,8 @@ export const SecretApprovalRequestsSchema = z.object({ statusChangeBy: z.string().uuid().nullable().optional(), committerId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + isReplicated: z.boolean().nullable().optional() }); export type TSecretApprovalRequests = z.infer; diff --git a/backend/src/db/schemas/secret-folders.ts b/backend/src/db/schemas/secret-folders.ts index 0f9684d0e..ad43ed1ad 100644 --- a/backend/src/db/schemas/secret-folders.ts +++ b/backend/src/db/schemas/secret-folders.ts @@ -14,7 +14,8 @@ export const SecretFoldersSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), envId: z.string().uuid(), - parentId: z.string().uuid().nullable().optional() + parentId: z.string().uuid().nullable().optional(), + isReserved: z.boolean().default(false).nullable().optional() }); export type TSecretFolders = z.infer; diff --git a/backend/src/db/schemas/secret-imports.ts b/backend/src/db/schemas/secret-imports.ts index 9d42d8da5..4bb1e669d 100644 --- a/backend/src/db/schemas/secret-imports.ts +++ b/backend/src/db/schemas/secret-imports.ts @@ -15,7 +15,12 @@ export const SecretImportsSchema = z.object({ position: z.number(), createdAt: z.date(), updatedAt: z.date(), - folderId: z.string().uuid() + folderId: z.string().uuid(), + isReplication: z.boolean().default(false).nullable().optional(), + isReplicationSuccess: z.boolean().nullable().optional(), + replicationStatus: z.string().nullable().optional(), + lastReplicated: z.date().nullable().optional(), + isReserved: z.boolean().default(false).nullable().optional() }); export type TSecretImports = z.infer; diff --git a/backend/src/db/schemas/secret-references.ts b/backend/src/db/schemas/secret-references.ts new file mode 100644 index 000000000..b3e6a8629 --- /dev/null +++ b/backend/src/db/schemas/secret-references.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretReferencesSchema = z.object({ + id: z.string().uuid(), + environment: z.string(), + secretPath: z.string(), + secretId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretReferences = z.infer; +export type TSecretReferencesInsert = Omit, TImmutableDBKeys>; +export type TSecretReferencesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts new file mode 100644 index 000000000..6fa104ebe --- /dev/null +++ b/backend/src/db/schemas/secret-sharing.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretSharingSchema = z.object({ + id: z.string().uuid(), + encryptedValue: z.string(), + iv: z.string(), + tag: z.string(), + hashedHex: z.string(), + expiresAt: z.date(), + userId: z.string().uuid(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + expiresAfterViews: z.number().nullable().optional() +}); + +export type TSecretSharing = z.infer; +export type TSecretSharingInsert = Omit, TImmutableDBKeys>; +export type TSecretSharingUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 958fed0ab..417d4e05e 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -14,7 +14,9 @@ export const SuperAdminSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), allowedSignUpDomain: z.string().nullable().optional(), - instanceId: z.string().uuid().default("00000000-0000-0000-0000-000000000000") + instanceId: z.string().uuid().default("00000000-0000-0000-0000-000000000000"), + trustSamlEmails: z.boolean().default(false).nullable().optional(), + trustLdapEmails: z.boolean().default(false).nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/db/schemas/user-aliases.ts b/backend/src/db/schemas/user-aliases.ts index d8712fe75..14147abf8 100644 --- a/backend/src/db/schemas/user-aliases.ts +++ b/backend/src/db/schemas/user-aliases.ts @@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models"; export const UserAliasesSchema = z.object({ id: z.string().uuid(), userId: z.string().uuid(), - username: z.string(), + username: z.string().nullable().optional(), aliasType: z.string(), externalId: z.string(), emails: z.string().array().nullable().optional(), diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index 86ee2fb74..9e0b9a3b5 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -21,7 +21,11 @@ export const UsersSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), isGhost: z.boolean().default(false), - username: z.string() + username: z.string(), + isEmailVerified: z.boolean().default(false).nullable().optional(), + consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(), + isLocked: z.boolean().default(false).nullable().optional(), + temporaryLockDateEnd: z.date().nullable().optional() }); export type TUsers = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts new file mode 100644 index 000000000..3b8949d3b --- /dev/null +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -0,0 +1,168 @@ +import { nanoid } from "nanoid"; +import { z } from "zod"; + +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z + .object({ + projectSlug: z.string().trim(), + name: z.string().optional(), + secretPath: z.string().trim().default("/"), + environment: z.string(), + approvers: z.string().array().min(1), + approvals: z.number().min(1).default(1) + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approval = await server.services.accessApprovalPolicy.createAccessApprovalPolicy({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + projectSlug: req.body.projectSlug, + name: req.body.name ?? `${req.body.environment}-${nanoid(3)}` + }); + return { approval }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + projectSlug: z.string().trim() + }), + response: { + 200: z.object({ + approvals: sapPubSchema.extend({ approvers: z.string().array(), secretPath: z.string().optional() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approvals = await server.services.accessApprovalPolicy.getAccessApprovalPolicyByProjectSlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectSlug: req.query.projectSlug + }); + return { approvals }; + } + }); + + server.route({ + url: "/count", + method: "GET", + schema: { + querystring: z.object({ + projectSlug: z.string(), + envSlug: z.string() + }), + response: { + 200: z.object({ + count: z.number() + }) + } + }, + + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { count } = await server.services.accessApprovalPolicy.getAccessPolicyCountByEnvSlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + projectSlug: req.query.projectSlug, + actorOrgId: req.permission.orgId, + envSlug: req.query.envSlug + }); + return { count }; + } + }); + + server.route({ + url: "/:policyId", + method: "PATCH", + schema: { + params: z.object({ + policyId: z.string() + }), + body: z + .object({ + name: z.string().optional(), + secretPath: z + .string() + .trim() + .optional() + .transform((val) => (val === "" ? "/" : val)), + approvers: z.string().array().min(1), + approvals: z.number().min(1).default(1) + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.accessApprovalPolicy.updateAccessApprovalPolicy({ + policyId: req.params.policyId, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + ...req.body + }); + } + }); + + server.route({ + url: "/:policyId", + method: "DELETE", + schema: { + params: z.object({ + policyId: z.string() + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approval = await server.services.accessApprovalPolicy.deleteAccessApprovalPolicy({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + policyId: req.params.policyId + }); + return { approval }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts new file mode 100644 index 000000000..4b173cfa7 --- /dev/null +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -0,0 +1,160 @@ +import { z } from "zod"; + +import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas"; +import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAccessApprovalRequestRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.object({ + permissions: z.any().array(), + isTemporary: z.boolean(), + temporaryRange: z.string().optional() + }), + querystring: z.object({ + projectSlug: z.string().trim() + }), + response: { + 200: z.object({ + approval: AccessApprovalRequestsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { request } = await server.services.accessApprovalRequest.createAccessApprovalRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + permissions: req.body.permissions, + actorOrgId: req.permission.orgId, + projectSlug: req.query.projectSlug, + temporaryRange: req.body.temporaryRange, + isTemporary: req.body.isTemporary + }); + return { approval: request }; + } + }); + + server.route({ + url: "/count", + method: "GET", + schema: { + querystring: z.object({ + projectSlug: z.string().trim() + }), + response: { + 200: z.object({ + pendingCount: z.number(), + finalizedCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { count } = await server.services.accessApprovalRequest.getCount({ + projectSlug: req.query.projectSlug, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return { ...count }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + projectSlug: z.string().trim(), + authorProjectMembershipId: z.string().trim().optional(), + envSlug: z.string().trim().optional() + }), + response: { + 200: z.object({ + requests: AccessApprovalRequestsSchema.extend({ + environmentName: z.string(), + isApproved: z.boolean(), + privilege: z + .object({ + membershipId: z.string(), + isTemporary: z.boolean(), + temporaryMode: z.string().nullish(), + temporaryRange: z.string().nullish(), + temporaryAccessStartTime: z.date().nullish(), + temporaryAccessEndTime: z.date().nullish(), + permissions: z.unknown() + }) + .nullable(), + policy: z.object({ + id: z.string(), + name: z.string(), + approvals: z.number(), + approvers: z.string().array(), + secretPath: z.string().nullish(), + envId: z.string() + }), + reviewers: z + .object({ + member: z.string(), + status: z.string() + }) + .array() + }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { requests } = await server.services.accessApprovalRequest.listApprovalRequests({ + projectSlug: req.query.projectSlug, + authorProjectMembershipId: req.query.authorProjectMembershipId, + envSlug: req.query.envSlug, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return { requests }; + } + }); + + server.route({ + url: "/:requestId/review", + method: "POST", + schema: { + params: z.object({ + requestId: z.string().trim() + }), + body: z.object({ + status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]) + }), + response: { + 200: z.object({ + review: AccessApprovalRequestsReviewersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const review = await server.services.accessApprovalRequest.reviewAccessRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + requestId: req.params.requestId, + status: req.body.status + }); + + return { review }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/audit-log-stream-router.ts b/backend/src/ee/routes/v1/audit-log-stream-router.ts new file mode 100644 index 000000000..17bd9e64b --- /dev/null +++ b/backend/src/ee/routes/v1/audit-log-stream-router.ts @@ -0,0 +1,215 @@ +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/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts index a1a2e36fa..58c6793d7 100644 --- a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -1,16 +1,19 @@ -import { MongoAbility, RawRuleOf } from "@casl/ability"; -import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; +import { packRules } from "@casl/ability/extra"; import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { z } from "zod"; -import { IdentityProjectAdditionalPrivilegeSchema } from "@app/db/schemas"; import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; -import { ProjectPermissionSet } from "@app/ee/services/permission/project-permission"; import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { + ProjectPermissionSchema, + ProjectSpecificPrivilegePermissionSchema, + SanitizedIdentityPrivilegeSchema +} from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { @@ -41,16 +44,33 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + permissions: ProjectPermissionSchema.array() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + .optional(), + privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( + IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.privilegePermission + ).optional() }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const { permissions, privilegePermission } = req.body; + if (!permissions && !privilegePermission) { + throw new BadRequestError({ message: "Permission or privilegePermission must be provided" }); + } + + const permission = privilegePermission + ? privilegePermission.actions.map((action) => ({ + action, + subject: privilegePermission.subject, + conditions: privilegePermission.conditions + })) + : permissions!; const privilege = await server.services.identityProjectAdditionalPrivilege.create({ actorId: req.permission.id, actor: req.permission.type, @@ -59,7 +79,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F ...req.body, slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), isTemporary: false, - permissions: JSON.stringify(packRules(req.body.permissions)) + permissions: JSON.stringify(packRules(permission)) }); return { privilege }; } @@ -92,7 +112,12 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), + permissions: ProjectPermissionSchema.array() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + .optional(), + privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( + IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.privilegePermission + ).optional(), temporaryMode: z .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), @@ -107,12 +132,25 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const { permissions, privilegePermission } = req.body; + if (!permissions && !privilegePermission) { + throw new BadRequestError({ message: "Permission or privilegePermission must be provided" }); + } + + const permission = privilegePermission + ? privilegePermission.actions.map((action) => ({ + action, + subject: privilegePermission.subject, + conditions: privilegePermission.conditions + })) + : permissions!; + const privilege = await server.services.identityProjectAdditionalPrivilege.create({ actorId: req.permission.id, actor: req.permission.type, @@ -121,7 +159,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F ...req.body, slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), isTemporary: true, - permissions: JSON.stringify(packRules(req.body.permissions)) + permissions: JSON.stringify(packRules(permission)) }); return { privilege }; } @@ -157,14 +195,17 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F message: "Slug must be a valid slug" }) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), - permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( + IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.privilegePermission + ).optional(), isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), temporaryMode: z .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode), temporaryRange: z .string() - .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .refine((val) => typeof val === "undefined" || ms(val) > 0, "Temporary range must be a positive number") .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange), temporaryAccessStartTime: z .string() @@ -175,13 +216,24 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const updatedInfo = req.body.privilegeDetails; + const { permissions, privilegePermission, ...updatedInfo } = req.body.privilegeDetails; + if (!permissions && !privilegePermission) { + throw new BadRequestError({ message: "Permission or privilegePermission must be provided" }); + } + + const permission = privilegePermission + ? privilegePermission.actions.map((action) => ({ + action, + subject: privilegePermission.subject, + conditions: privilegePermission.conditions + })) + : permissions!; const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({ actorId: req.permission.id, actor: req.permission.type, @@ -192,7 +244,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F projectSlug: req.body.projectSlug, data: { ...updatedInfo, - permissions: updatedInfo?.permissions ? JSON.stringify(packRules(updatedInfo.permissions)) : undefined + permissions: permission ? JSON.stringify(packRules(permission)) : undefined } }); return { privilege }; @@ -219,7 +271,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, @@ -260,7 +312,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, @@ -293,16 +345,11 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F ], querystring: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.identityId), - projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.projectSlug), - unpacked: z - .enum(["false", "true"]) - .transform((el) => el === "true") - .default("true") - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.unpacked) + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.projectSlug) }), response: { 200: z.object({ - privileges: IdentityProjectAdditionalPrivilegeSchema.array() + privileges: SanitizedIdentityPrivilegeSchema.array() }) } }, @@ -315,15 +362,9 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actorOrgId: req.permission.orgId, ...req.query }); - if (req.query.unpacked) { - return { - privileges: privileges.map(({ permissions, ...el }) => ({ - ...el, - permissions: unpackRules(permissions as PackRule>>[]) - })) - }; - } - return { privileges }; + return { + privileges + }; } }); }; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 6860098fd..16e23eb88 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,3 +1,6 @@ +import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; +import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; +import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerGroupRouter } from "./group-router"; @@ -40,6 +43,9 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { prefix: "/secret-rotation-providers" }); + await server.register(registerAccessApprovalPolicyRouter, { prefix: "/access-approvals/policies" }); + await server.register(registerAccessApprovalRequestRouter, { prefix: "/access-approvals/requests" }); + await server.register( async (dynamicSecretRouter) => { await dynamicSecretRouter.register(registerDynamicSecretRouter); @@ -55,6 +61,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); await server.register(registerGroupRouter, { prefix: "/groups" }); + await server.register(registerAuditLogStreamRouter, { prefix: "/audit-log-streams" }); await server.register( async (privilegeRouter) => { await privilegeRouter.register(registerUserAdditionalPrivilegeRouter, { prefix: "/users" }); diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts index 6730e9101..e146668c2 100644 --- a/backend/src/ee/routes/v1/ldap-router.ts +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -18,6 +18,7 @@ import { LdapConfigsSchema, LdapGroupMapsSchema } from "@app/db/schemas"; import { TLDAPConfig } from "@app/ee/services/ldap-config/ldap-config-types"; import { isValidLdapFilter, searchGroups } from "@app/ee/services/ldap-config/ldap-fns"; import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -52,6 +53,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { // eslint-disable-next-line async (req: IncomingMessage, user, cb) => { try { + if (!user.email) throw new BadRequestError({ message: "Invalid request. Missing email." }); const ldapConfig = (req as unknown as FastifyRequest).ldapConfig as TLDAPConfig; let groups: { dn: string; cn: string }[] | undefined; @@ -74,7 +76,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { username: user.uid, firstName: user.givenName ?? user.cn ?? "", lastName: user.sn ?? "", - emails: user.mail ? [user.mail] : [], + email: user.mail, groups, relayState: ((req as unknown as FastifyRequest).body as { RelayState?: string }).RelayState, orgId: (req as unknown as FastifyRequest).ldapConfig.organization diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 380f61e23..6691032a8 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -23,7 +23,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { .min(1) .trim() .refine( - (val) => !Object.keys(OrgMembershipRole).includes(val), + (val) => !Object.values(OrgMembershipRole).includes(val as OrgMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) .refine((v) => slugify(v) === v, { diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index bb4d2fa8e..69038a057 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -1,146 +1,232 @@ +import { packRules } from "@casl/ability/extra"; +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; +import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { ProjectPermissionSchema, SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:projectId/roles", + url: "/:projectSlug/roles", config: { rateLimit: writeLimit }, schema: { + description: "Create a project role", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug) }), body: z.object({ - slug: z.string().trim(), - name: z.string().trim(), - description: z.string().trim().optional(), - permissions: z.any().array() + slug: z + .string() + .toLowerCase() + .trim() + .min(1) + .refine( + (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid" + }) + .describe(PROJECT_ROLE.CREATE.slug), + name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), + description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), + permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.CREATE.permissions) }), response: { 200: z.object({ - role: ProjectRolesSchema + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.createRole( - req.permission.type, - req.permission.id, - req.params.projectId, - req.body, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.projectRole.createRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + data: { + ...req.body, + permissions: JSON.stringify(packRules(req.body.permissions)) + } + }); return { role }; } }); server.route({ method: "PATCH", - url: "/:projectId/roles/:roleId", + url: "/:projectSlug/roles/:roleId", config: { rateLimit: writeLimit }, schema: { + description: "Update a project role", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim(), - roleId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.UPDATE.projectSlug), + roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: z.string().trim().optional(), - name: z.string().trim().optional(), - description: z.string().trim().optional(), - permissions: z.any().array() + slug: z + .string() + .toLowerCase() + .trim() + .optional() + .describe(PROJECT_ROLE.UPDATE.slug) + .refine( + (val) => + typeof val === "undefined" || + !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ) + .refine((val) => typeof val === "undefined" || slugify(val) === val, { + message: "Slug must be a valid" + }), + name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), + permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions) }), response: { 200: z.object({ - role: ProjectRolesSchema + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.updateRole( - req.permission.type, - req.permission.id, - req.params.projectId, - req.params.roleId, - req.body, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.projectRole.updateRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + roleId: req.params.roleId, + data: { + ...req.body, + permissions: JSON.stringify(packRules(req.body.permissions)) + } + }); return { role }; } }); server.route({ method: "DELETE", - url: "/:projectId/roles/:roleId", + url: "/:projectSlug/roles/:roleId", config: { rateLimit: writeLimit }, schema: { + description: "Delete a project role", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim(), - roleId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.DELETE.projectSlug), + roleId: z.string().trim().describe(PROJECT_ROLE.DELETE.roleId) }), response: { 200: z.object({ - role: ProjectRolesSchema + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.deleteRole( - req.permission.type, - req.permission.id, - req.params.projectId, - req.params.roleId, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.projectRole.deleteRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + roleId: req.params.roleId + }); return { role }; } }); server.route({ method: "GET", - url: "/:projectId/roles", + url: "/:projectSlug/roles", + config: { + rateLimit: readLimit + }, + schema: { + description: "List project role", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECT_ROLE.LIST.projectSlug) + }), + response: { + 200: z.object({ + roles: ProjectRolesSchema.omit({ permissions: true }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const roles = await server.services.projectRole.listRoles({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug + }); + return { roles }; + } + }); + + server.route({ + method: "GET", + url: "/:projectSlug/roles/slug/:slug", config: { rateLimit: readLimit }, schema: { params: z.object({ - projectId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.projectSlug), + slug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.roleSlug) }), response: { 200: z.object({ - data: z.object({ - roles: ProjectRolesSchema.omit({ permissions: true }) - .merge(z.object({ permissions: z.unknown() })) - .array() - }) + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const roles = await server.services.projectRole.listRoles( - req.permission.type, - req.permission.id, - req.params.projectId, - req.permission.authMethod, - req.permission.orgId - ); - return { data: { roles } }; + const role = await server.services.projectRole.getRoleBySlug({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + roleSlug: req.params.slug + }); + return { role }; } }); diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 6cae30f7a..6001b8b6e 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -102,12 +102,12 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { if (!profile) throw new BadRequestError({ message: "Missing profile" }); const email = profile?.email ?? (profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved - if (!profile.email || !profile.firstName) { + if (!email || !profile.firstName) { throw new BadRequestError({ message: "Invalid request. Missing email or first name" }); } const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ - username: profile.nameID ?? email, + externalId: profile.nameID, email, firstName: profile.firstName as string, lastName: profile.lastName as string, diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index dea0e3d70..8965c28f3 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -153,7 +153,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { const users = await req.server.services.scim.listScimUsers({ - offset: req.query.startIndex, + startIndex: req.query.startIndex, limit: req.query.count, filter: req.query.filter, orgId: req.permission.orgId @@ -163,11 +163,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/Users/:userId", + url: "/Users/:orgMembershipId", method: "GET", schema: { params: z.object({ - userId: z.string().trim() + orgMembershipId: z.string().trim() }), response: { 201: z.object({ @@ -193,7 +193,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { const user = await req.server.services.scim.getScimUser({ - userId: req.params.userId, + orgMembershipId: req.params.orgMembershipId, orgId: req.permission.orgId }); return user; @@ -249,7 +249,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { const primaryEmail = req.body.emails?.find((email) => email.primary)?.value; const user = await req.server.services.scim.createScimUser({ - username: req.body.userName, + externalId: req.body.userName, email: primaryEmail, firstName: req.body.name.givenName, lastName: req.body.name.familyName, @@ -261,11 +261,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/Users/:userId", + url: "/Users/:orgMembershipId", method: "DELETE", schema: { params: z.object({ - userId: z.string().trim() + orgMembershipId: z.string().trim() }), response: { 200: z.object({}) @@ -274,7 +274,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { const user = await req.server.services.scim.deleteScimUser({ - userId: req.params.userId, + orgMembershipId: req.params.orgMembershipId, orgId: req.permission.orgId }); @@ -361,7 +361,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const groups = await req.server.services.scim.listScimGroups({ orgId: req.permission.orgId, - offset: req.query.startIndex, + startIndex: req.query.startIndex, limit: req.query.count }); @@ -416,10 +416,10 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { displayName: z.string().trim(), members: z.array( z.object({ - value: z.string(), // infisical userId + value: z.string(), // infisical orgMembershipId display: z.string() }) - ) // note: is this where members are added to group? + ) }), response: { 200: z.object({ @@ -534,11 +534,11 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/Users/:userId", + url: "/Users/:orgMembershipId", method: "PUT", schema: { params: z.object({ - userId: z.string().trim() + orgMembershipId: z.string().trim() }), body: z.object({ schemas: z.array(z.string()), @@ -575,7 +575,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { const user = await req.server.services.scim.replaceScimUser({ - userId: req.params.userId, + orgMembershipId: req.params.orgMembershipId, orgId: req.permission.orgId, active: req.body.active }); 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 2a9cc405d..b7204f72e 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -32,22 +32,20 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv }), response: { 200: z.object({ - approvals: SecretApprovalRequestsSchema.merge( - z.object({ - // secretPath: z.string(), - policy: z.object({ - id: z.string(), - name: z.string(), - approvals: z.number(), - approvers: z.string().array(), - secretPath: z.string().optional().nullable() - }), - commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), - environment: z.string(), - reviewers: z.object({ member: z.string(), status: z.string() }).array(), - approvers: z.string().array() - }) - ).array() + approvals: SecretApprovalRequestsSchema.extend({ + // secretPath: z.string(), + policy: z.object({ + id: z.string(), + name: z.string(), + approvals: z.number(), + approvers: z.string().array(), + secretPath: z.string().optional().nullable() + }), + commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), + environment: z.string(), + reviewers: z.object({ member: z.string(), status: z.string() }).array(), + approvers: z.string().array() + }).array() }) } }, diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts new file mode 100644 index 000000000..e14854d8f --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAccessApprovalPolicyApproverDALFactory = ReturnType; + +export const accessApprovalPolicyApproverDALFactory = (db: TDbClient) => { + const accessApprovalPolicyApproverOrm = ormify(db, TableName.AccessApprovalPolicyApprover); + return { ...accessApprovalPolicyApproverOrm }; +}; 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 new file mode 100644 index 000000000..88e288832 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -0,0 +1,76 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TAccessApprovalPolicies } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, mergeOneToManyRelation, ormify, selectAllTableCols, TFindFilter } from "@app/lib/knex"; + +export type TAccessApprovalPolicyDALFactory = ReturnType; + +export const accessApprovalPolicyDALFactory = (db: TDbClient) => { + const accessApprovalPolicyOrm = ormify(db, TableName.AccessApprovalPolicy); + + const accessApprovalPolicyFindQuery = async (tx: Knex, filter: TFindFilter) => { + const result = await tx(TableName.AccessApprovalPolicy) + // eslint-disable-next-line + .where(buildFindFilter(filter)) + .join(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + .join( + TableName.AccessApprovalPolicyApprover, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyApprover}.policyId` + ) + .select(tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(tx.ref("name").withSchema(TableName.Environment).as("envName")) + .select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug")) + .select(tx.ref("id").withSchema(TableName.Environment).as("envId")) + .select(tx.ref("projectId").withSchema(TableName.Environment)) + .select(selectAllTableCols(TableName.AccessApprovalPolicy)); + + return result; + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const doc = await accessApprovalPolicyFindQuery(tx || db, { + [`${TableName.AccessApprovalPolicy}.id` as "id"]: id + }); + const formatedDoc = mergeOneToManyRelation( + doc, + "id", + ({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({ + ...el, + envId, + environment: { id: envId, name, slug } + }), + ({ approverId }) => approverId, + "approvers" + ); + return formatedDoc?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "FindById" }); + } + }; + + const find = async (filter: TFindFilter, tx?: Knex) => { + try { + const docs = await accessApprovalPolicyFindQuery(tx || db, filter); + const formatedDoc = mergeOneToManyRelation( + docs, + "id", + ({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({ + ...el, + envId, + environment: { id: envId, name, slug } + }), + ({ approverId }) => approverId, + "approvers" + ); + return formatedDoc.map((policy) => ({ ...policy, secretPath: policy.secretPath || undefined })); + } catch (error) { + throw new DatabaseError({ error, name: "Find" }); + } + }; + + return { ...accessApprovalPolicyOrm, find, findById }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts new file mode 100644 index 000000000..7b0a2681f --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts @@ -0,0 +1,36 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { BadRequestError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; + +import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TVerifyApprovers } from "./access-approval-policy-types"; + +export const verifyApprovers = async ({ + userIds, + projectId, + orgId, + envSlug, + actorAuthMethod, + secretPath, + permissionService +}: TVerifyApprovers) => { + for await (const userId of userIds) { + try { + const { permission: approverPermission } = await permissionService.getProjectPermission( + ActorType.USER, + userId, + projectId, + actorAuthMethod, + orgId + ); + + ForbiddenError.from(approverPermission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment: envSlug, secretPath }) + ); + } catch (err) { + throw new BadRequestError({ message: "One or more approvers doesn't have access to be specified secret path" }); + } + } +}; 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 new file mode 100644 index 000000000..51a51abb5 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -0,0 +1,273 @@ +import { ForbiddenError } from "@casl/ability"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; + +import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; +import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; +import { verifyApprovers } from "./access-approval-policy-fns"; +import { + TCreateAccessApprovalPolicy, + TDeleteAccessApprovalPolicy, + TGetAccessPolicyCountByEnvironmentDTO, + TListAccessApprovalPoliciesDTO, + TUpdateAccessApprovalPolicy +} from "./access-approval-policy-types"; + +type TSecretApprovalPolicyServiceFactoryDep = { + projectDAL: TProjectDALFactory; + permissionService: Pick; + accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; + projectEnvDAL: Pick; + accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; + projectMembershipDAL: Pick; +}; + +export type TAccessApprovalPolicyServiceFactory = ReturnType; + +export const accessApprovalPolicyServiceFactory = ({ + accessApprovalPolicyDAL, + accessApprovalPolicyApproverDAL, + permissionService, + projectEnvDAL, + projectDAL, + projectMembershipDAL +}: TSecretApprovalPolicyServiceFactoryDep) => { + const createAccessApprovalPolicy = async ({ + name, + actor, + actorId, + actorOrgId, + secretPath, + actorAuthMethod, + approvals, + approvers, + projectSlug, + environment + }: TCreateAccessApprovalPolicy) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + if (approvals > approvers.length) + throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SecretApproval + ); + const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); + if (!env) throw new BadRequestError({ message: "Environment not found" }); + + const secretApprovers = await projectMembershipDAL.find({ + projectId: project.id, + $in: { id: approvers } + }); + + if (secretApprovers.length !== approvers.length) { + throw new BadRequestError({ message: "Approver not found in project" }); + } + + await verifyApprovers({ + projectId: project.id, + orgId: actorOrgId, + envSlug: environment, + secretPath, + actorAuthMethod, + permissionService, + userIds: secretApprovers.map((approver) => approver.userId) + }); + + const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { + const doc = await accessApprovalPolicyDAL.create( + { + envId: env.id, + approvals, + secretPath, + name + }, + tx + ); + await accessApprovalPolicyApproverDAL.insertMany( + secretApprovers.map(({ id }) => ({ + approverId: id, + policyId: doc.id + })), + tx + ); + return doc; + }); + return { ...accessApproval, environment: env, projectId: project.id }; + }; + + const getAccessApprovalPolicyByProjectSlug = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectSlug + }: TListAccessApprovalPoliciesDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + // Anyone in the project should be able to get the policies. + /* const { permission } = */ await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + // ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + + const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id }); + return accessApprovalPolicies; + }; + + const updateAccessApprovalPolicy = async ({ + policyId, + approvers, + secretPath, + name, + actorId, + actor, + actorOrgId, + actorAuthMethod, + approvals + }: TUpdateAccessApprovalPolicy) => { + const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId); + if (!accessApprovalPolicy) throw new BadRequestError({ message: "Secret approval policy not found" }); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + accessApprovalPolicy.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + + const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { + const doc = await accessApprovalPolicyDAL.updateById( + accessApprovalPolicy.id, + { + approvals, + secretPath, + name + }, + tx + ); + if (approvers) { + // Find the workspace project memberships of the users passed in the approvers array + const secretApprovers = await projectMembershipDAL.find( + { + projectId: accessApprovalPolicy.projectId, + $in: { id: approvers } + }, + { tx } + ); + + await verifyApprovers({ + projectId: accessApprovalPolicy.projectId, + orgId: actorOrgId, + envSlug: accessApprovalPolicy.environment.slug, + secretPath: doc.secretPath!, + actorAuthMethod, + permissionService, + userIds: secretApprovers.map((approver) => approver.userId) + }); + + if (secretApprovers.length !== approvers.length) + throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); + await accessApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx); + await accessApprovalPolicyApproverDAL.insertMany( + secretApprovers.map(({ id }) => ({ + approverId: id, + policyId: doc.id + })), + tx + ); + } + return doc; + }); + return { + ...updatedPolicy, + environment: accessApprovalPolicy.environment, + projectId: accessApprovalPolicy.projectId + }; + }; + + const deleteAccessApprovalPolicy = async ({ + policyId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TDeleteAccessApprovalPolicy) => { + const policy = await accessApprovalPolicyDAL.findById(policyId); + if (!policy) throw new BadRequestError({ message: "Secret approval policy not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + policy.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SecretApproval + ); + + await accessApprovalPolicyDAL.deleteById(policyId); + return policy; + }; + + const getAccessPolicyCountByEnvSlug = async ({ + actor, + actorOrgId, + actorAuthMethod, + projectSlug, + actorId, + envSlug + }: TGetAccessPolicyCountByEnvironmentDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const { membership } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + if (!membership) throw new BadRequestError({ message: "User not found in project" }); + + const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); + if (!environment) throw new BadRequestError({ message: "Environment not found" }); + + const policies = await accessApprovalPolicyDAL.find({ envId: environment.id, projectId: project.id }); + if (!policies) throw new BadRequestError({ message: "No policies found" }); + + return { count: policies.length }; + }; + + return { + getAccessPolicyCountByEnvSlug, + createAccessApprovalPolicy, + deleteAccessApprovalPolicy, + updateAccessApprovalPolicy, + getAccessApprovalPolicyByProjectSlug + }; +}; 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 new file mode 100644 index 000000000..601561b68 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -0,0 +1,44 @@ +import { TProjectPermission } from "@app/lib/types"; +import { ActorAuthMethod } from "@app/services/auth/auth-type"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; + +export type TVerifyApprovers = { + userIds: string[]; + permissionService: Pick; + envSlug: string; + actorAuthMethod: ActorAuthMethod; + secretPath: string; + projectId: string; + orgId: string; +}; + +export type TCreateAccessApprovalPolicy = { + approvals: number; + secretPath: string; + environment: string; + approvers: string[]; + projectSlug: string; + name: string; +} & Omit; + +export type TUpdateAccessApprovalPolicy = { + policyId: string; + approvals?: number; + approvers?: string[]; + secretPath?: string; + name?: string; +} & Omit; + +export type TDeleteAccessApprovalPolicy = { + policyId: string; +} & Omit; + +export type TGetAccessPolicyCountByEnvironmentDTO = { + envSlug: string; + projectSlug: string; +} & Omit; + +export type TListAccessApprovalPoliciesDTO = { + projectSlug: string; +} & Omit; 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 new file mode 100644 index 000000000..c3f4c72a6 --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -0,0 +1,266 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; + +import { ApprovalStatus } from "./access-approval-request-types"; + +export type TAccessApprovalRequestDALFactory = ReturnType; + +export const accessApprovalRequestDALFactory = (db: TDbClient) => { + const accessApprovalRequestOrm = ormify(db, TableName.AccessApprovalRequest); + + const findRequestsWithPrivilegeByPolicyIds = async (policyIds: string[]) => { + try { + const docs = await db(TableName.AccessApprovalRequest) + .whereIn(`${TableName.AccessApprovalRequest}.policyId`, policyIds) + + .leftJoin( + TableName.ProjectUserAdditionalPrivilege, + `${TableName.AccessApprovalRequest}.privilegeId`, + `${TableName.ProjectUserAdditionalPrivilege}.id` + ) + .leftJoin( + TableName.AccessApprovalPolicy, + `${TableName.AccessApprovalRequest}.policyId`, + `${TableName.AccessApprovalPolicy}.id` + ) + + .leftJoin( + TableName.AccessApprovalRequestReviewer, + `${TableName.AccessApprovalRequest}.id`, + `${TableName.AccessApprovalRequestReviewer}.requestId` + ) + .leftJoin( + TableName.AccessApprovalPolicyApprover, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyApprover}.policyId` + ) + + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + + .select(selectAllTableCols(TableName.AccessApprovalRequest)) + .select( + db.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"), + db.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"), + db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), + db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), + db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId") + ) + + .select(db.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover)) + + .select( + db.ref("projectId").withSchema(TableName.Environment), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.Environment).as("envName") + ) + + .select( + db.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"), + db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus") + ) + + .select( + db + .ref("projectMembershipId") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("privilegeMembershipId"), + db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeIsTemporary"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryMode"), + db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("privilegeTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("privilegeTemporaryAccessEndTime"), + + db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegePermissions") + ) + .orderBy(`${TableName.AccessApprovalRequest}.createdAt`, "desc"); + + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (doc) => ({ + ...AccessApprovalRequestsSchema.parse(doc), + projectId: doc.projectId, + environment: doc.envSlug, + environmentName: doc.envName, + policy: { + id: doc.policyId, + name: doc.policyName, + approvals: doc.policyApprovals, + secretPath: doc.policySecretPath, + envId: doc.policyEnvId + }, + privilege: doc.privilegeId + ? { + membershipId: doc.privilegeMembershipId, + isTemporary: doc.privilegeIsTemporary, + temporaryMode: doc.privilegeTemporaryMode, + temporaryRange: doc.privilegeTemporaryRange, + temporaryAccessStartTime: doc.privilegeTemporaryAccessStartTime, + temporaryAccessEndTime: doc.privilegeTemporaryAccessEndTime, + permissions: doc.privilegePermissions + } + : null, + + isApproved: !!doc.privilegeId + }), + childrenMapper: [ + { + key: "reviewerMemberId", + label: "reviewers" as const, + mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) + }, + { key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId } + ] + }); + + if (!formattedDocs) return []; + + return formattedDocs.map((doc) => ({ + ...doc, + policy: { ...doc.policy, approvers: doc.approvers } + })); + } catch (error) { + throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" }); + } + }; + + const findQuery = (filter: TFindFilter, tx: Knex) => + tx(TableName.AccessApprovalRequest) + .where(filter) + .join( + TableName.AccessApprovalPolicy, + `${TableName.AccessApprovalRequest}.policyId`, + `${TableName.AccessApprovalPolicy}.id` + ) + + .join( + TableName.AccessApprovalPolicyApprover, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyApprover}.policyId` + ) + .leftJoin( + TableName.AccessApprovalRequestReviewer, + `${TableName.AccessApprovalRequest}.id`, + `${TableName.AccessApprovalRequestReviewer}.requestId` + ) + + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + .select(selectAllTableCols(TableName.AccessApprovalRequest)) + .select( + tx.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"), + tx.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"), + tx.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"), + tx.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"), + tx.ref("projectId").withSchema(TableName.Environment), + tx.ref("slug").withSchema(TableName.Environment).as("environment"), + tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), + tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), + tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover) + ); + + const findById = async (id: string, tx?: Knex) => { + try { + const sql = findQuery({ [`${TableName.AccessApprovalRequest}.id` as "id"]: id }, tx || db); + const docs = await sql; + const formatedDoc = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => ({ + ...AccessApprovalRequestsSchema.parse(el), + projectId: el.projectId, + environment: el.environment, + policy: { + id: el.policyId, + name: el.policyName, + approvals: el.policyApprovals, + secretPath: el.policySecretPath + } + }), + childrenMapper: [ + { + key: "reviewerMemberId", + label: "reviewers" as const, + mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) + }, + { key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId } + ] + }); + if (!formatedDoc?.[0]) return; + return { + ...formatedDoc[0], + policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers } + }; + } catch (error) { + throw new DatabaseError({ error, name: "FindByIdAccessApprovalRequest" }); + } + }; + + const getCount = async ({ projectId }: { projectId: string }) => { + try { + const accessRequests = await db(TableName.AccessApprovalRequest) + .leftJoin( + TableName.AccessApprovalPolicy, + `${TableName.AccessApprovalRequest}.policyId`, + `${TableName.AccessApprovalPolicy}.id` + ) + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + .leftJoin( + TableName.ProjectUserAdditionalPrivilege, + `${TableName.AccessApprovalRequest}.privilegeId`, + `${TableName.ProjectUserAdditionalPrivilege}.id` + ) + + .leftJoin( + TableName.AccessApprovalRequestReviewer, + `${TableName.AccessApprovalRequest}.id`, + `${TableName.AccessApprovalRequestReviewer}.requestId` + ) + + .where(`${TableName.Environment}.projectId`, projectId) + .select(selectAllTableCols(TableName.AccessApprovalRequest)) + .select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")) + .select(db.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId")); + + const formattedRequests = sqlNestRelationships({ + data: accessRequests, + key: "id", + parentMapper: (doc) => ({ + ...AccessApprovalRequestsSchema.parse(doc) + }), + childrenMapper: [ + { + key: "reviewerMemberId", + label: "reviewers" as const, + mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) + } + ] + }); + + // an approval is pending if there is no reviewer rejections and no privilege ID is set + const pendingApprovals = formattedRequests.filter( + (req) => !req.privilegeId && !req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) + ); + + // an approval is finalized if there are any rejections or a privilege ID is set + const finalizedApprovals = formattedRequests.filter( + (req) => req.privilegeId || req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) + ); + + return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length }; + } catch (error) { + throw new DatabaseError({ error, name: "GetCountAccessApprovalRequest" }); + } + }; + + return { ...accessApprovalRequestOrm, findById, findRequestsWithPrivilegeByPolicyIds, getCount }; +}; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-fns.ts b/backend/src/ee/services/access-approval-request/access-approval-request-fns.ts new file mode 100644 index 000000000..90b42aaf7 --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-fns.ts @@ -0,0 +1,53 @@ +import { PackRule, unpackRules } from "@casl/ability/extra"; + +import { UnauthorizedError } from "@app/lib/errors"; + +import { TVerifyPermission } from "./access-approval-request-types"; + +function filterUnique(value: string, index: number, array: string[]) { + return array.indexOf(value) === index; +} + +export const verifyRequestedPermissions = ({ permissions }: TVerifyPermission) => { + const permission = unpackRules( + permissions as PackRule<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + conditions?: Record; + action: string; + subject: [string]; + }>[] + ); + + if (!permission || !permission.length) { + throw new UnauthorizedError({ message: "No permission provided" }); + } + + const requestedPermissions: string[] = []; + + for (const p of permission) { + if (p.action[0] === "read") requestedPermissions.push("Read Access"); + if (p.action[0] === "create") requestedPermissions.push("Create Access"); + if (p.action[0] === "delete") requestedPermissions.push("Delete Access"); + if (p.action[0] === "edit") requestedPermissions.push("Edit Access"); + } + + const firstPermission = permission[0]; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const permissionSecretPath = firstPermission.conditions?.secretPath?.$glob; + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment + const permissionEnv = firstPermission.conditions?.environment; + + if (!permissionEnv || typeof permissionEnv !== "string") { + throw new UnauthorizedError({ message: "Permission environment is not a string" }); + } + if (!permissionSecretPath || typeof permissionSecretPath !== "string") { + throw new UnauthorizedError({ message: "Permission path is not a string" }); + } + + return { + envSlug: permissionEnv, + secretPath: permissionSecretPath, + accessTypes: requestedPermissions.filter(filterUnique) + }; +}; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts new file mode 100644 index 000000000..251015b22 --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAccessApprovalRequestReviewerDALFactory = ReturnType; + +export const accessApprovalRequestReviewerDALFactory = (db: TDbClient) => { + const secretApprovalRequestReviewerOrm = ormify(db, TableName.AccessApprovalRequestReviewer); + return secretApprovalRequestReviewerOrm; +}; 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 new file mode 100644 index 000000000..becdb78da --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -0,0 +1,369 @@ +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; + +import { ProjectMembershipRole } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal"; +import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; +import { verifyApprovers } from "../access-approval-policy/access-approval-policy-fns"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; +import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; +import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal"; +import { verifyRequestedPermissions } from "./access-approval-request-fns"; +import { TAccessApprovalRequestReviewerDALFactory } from "./access-approval-request-reviewer-dal"; +import { + ApprovalStatus, + TCreateAccessApprovalRequestDTO, + TGetAccessRequestCountDTO, + TListApprovalRequestsDTO, + TReviewAccessRequestDTO +} from "./access-approval-request-types"; + +type TSecretApprovalRequestServiceFactoryDep = { + additionalPrivilegeDAL: Pick; + permissionService: Pick; + accessApprovalPolicyApproverDAL: Pick; + projectEnvDAL: Pick; + projectDAL: Pick; + accessApprovalRequestDAL: Pick< + TAccessApprovalRequestDALFactory, + | "create" + | "find" + | "findRequestsWithPrivilegeByPolicyIds" + | "findById" + | "transaction" + | "updateById" + | "findOne" + | "getCount" + >; + accessApprovalPolicyDAL: Pick; + accessApprovalRequestReviewerDAL: Pick< + TAccessApprovalRequestReviewerDALFactory, + "create" | "find" | "findOne" | "transaction" + >; + projectMembershipDAL: Pick; + smtpService: Pick; + userDAL: Pick; +}; + +export type TAccessApprovalRequestServiceFactory = ReturnType; + +export const accessApprovalRequestServiceFactory = ({ + projectDAL, + projectEnvDAL, + permissionService, + accessApprovalRequestDAL, + accessApprovalRequestReviewerDAL, + projectMembershipDAL, + accessApprovalPolicyDAL, + accessApprovalPolicyApproverDAL, + additionalPrivilegeDAL, + smtpService, + userDAL +}: TSecretApprovalRequestServiceFactoryDep) => { + const createAccessApprovalRequest = async ({ + isTemporary, + temporaryRange, + actorId, + permissions: requestedPermissions, + actor, + actorOrgId, + actorAuthMethod, + projectSlug + }: TCreateAccessApprovalRequestDTO) => { + const cfg = getConfig(); + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new UnauthorizedError({ message: "Project not found" }); + + // Anyone can create an access approval request. + const { membership } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); + + const requestedByUser = await userDAL.findUserByProjectMembershipId(membership.id); + if (!requestedByUser) throw new UnauthorizedError({ message: "User not found" }); + + await projectDAL.checkProjectUpgradeStatus(project.id); + + const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({ permissions: requestedPermissions }); + const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); + + if (!environment) throw new UnauthorizedError({ message: "Environment not found" }); + + const policy = await accessApprovalPolicyDAL.findOne({ + envId: environment.id, + secretPath + }); + if (!policy) throw new UnauthorizedError({ message: "No policy matching criteria was found." }); + + const approvers = await accessApprovalPolicyApproverDAL.find({ + policyId: policy.id + }); + + const approverUsers = await userDAL.findUsersByProjectMembershipIds( + approvers.map((approver) => approver.approverId) + ); + + const duplicateRequests = await accessApprovalRequestDAL.find({ + policyId: policy.id, + requestedBy: membership.id, + permissions: JSON.stringify(requestedPermissions), + isTemporary + }); + + if (duplicateRequests?.length > 0) { + for await (const duplicateRequest of duplicateRequests) { + if (duplicateRequest.privilegeId) { + const privilege = await additionalPrivilegeDAL.findById(duplicateRequest.privilegeId); + + const isExpired = new Date() > new Date(privilege.temporaryAccessEndTime || ("" as string)); + + if (!isExpired || !privilege.isTemporary) { + throw new BadRequestError({ message: "You already have an active privilege with the same criteria" }); + } + } else { + const reviewers = await accessApprovalRequestReviewerDAL.find({ + requestId: duplicateRequest.id + }); + + const isRejected = reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED); + + if (!isRejected) { + throw new BadRequestError({ message: "You already have a pending access request with the same criteria" }); + } + } + } + } + + const approval = await accessApprovalRequestDAL.transaction(async (tx) => { + const approvalRequest = await accessApprovalRequestDAL.create( + { + policyId: policy.id, + requestedBy: membership.id, + temporaryRange: temporaryRange || null, + permissions: JSON.stringify(requestedPermissions), + isTemporary + }, + tx + ); + + await smtpService.sendMail({ + recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!), + subjectLine: "Access Approval Request", + + substitutions: { + projectName: project.name, + requesterFullName: `${requestedByUser.firstName} ${requestedByUser.lastName}`, + requesterEmail: requestedByUser.email, + isTemporary, + ...(isTemporary && { + expiresIn: ms(ms(temporaryRange || ""), { long: true }) + }), + secretPath, + environment: envSlug, + permissions: accessTypes, + approvalUrl: `${cfg.SITE_URL}/project/${project.id}/approval` + }, + template: SmtpTemplates.AccessApprovalRequest + }); + + return approvalRequest; + }); + + return { request: approval }; + }; + + const listApprovalRequests = async ({ + projectSlug, + authorProjectMembershipId, + envSlug, + actor, + actorOrgId, + actorId, + actorAuthMethod + }: TListApprovalRequestsDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new UnauthorizedError({ message: "Project not found" }); + + const { membership } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); + + const policies = await accessApprovalPolicyDAL.find({ projectId: project.id }); + let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id)); + + if (authorProjectMembershipId) { + requests = requests.filter((request) => request.requestedBy === authorProjectMembershipId); + } + + if (envSlug) { + requests = requests.filter((request) => request.environment === envSlug); + } + + return { requests }; + }; + + const reviewAccessRequest = async ({ + requestId, + actor, + status, + actorId, + actorAuthMethod, + actorOrgId + }: TReviewAccessRequestDTO) => { + const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId); + if (!accessApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); + + const { policy } = accessApprovalRequest; + const { membership, hasRole } = await permissionService.getProjectPermission( + actor, + actorId, + accessApprovalRequest.projectId, + actorAuthMethod, + actorOrgId + ); + + if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); + + if ( + !hasRole(ProjectMembershipRole.Admin) && + accessApprovalRequest.requestedBy !== membership.id && // The request wasn't made by the current user + !policy.approvers.find((approverId) => approverId === membership.id) // The request isn't performed by an assigned approver + ) { + throw new UnauthorizedError({ message: "You are not authorized to approve this request" }); + } + + const reviewerProjectMembership = await projectMembershipDAL.findById(membership.id); + + await verifyApprovers({ + projectId: accessApprovalRequest.projectId, + orgId: actorOrgId, + envSlug: accessApprovalRequest.environment, + secretPath: accessApprovalRequest.policy.secretPath!, + actorAuthMethod, + permissionService, + userIds: [reviewerProjectMembership.userId] + }); + + const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id }); + if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) { + throw new BadRequestError({ message: "The request has already been rejected by another reviewer" }); + } + + const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => { + const review = await accessApprovalRequestReviewerDAL.findOne( + { + requestId: accessApprovalRequest.id, + member: membership.id + }, + tx + ); + if (!review) { + const newReview = await accessApprovalRequestReviewerDAL.create( + { + status, + requestId: accessApprovalRequest.id, + member: membership.id + }, + tx + ); + + const allReviews = [...existingReviews, newReview]; + + const approvedReviews = allReviews.filter((r) => r.status === ApprovalStatus.APPROVED); + + // approvals is the required number of approvals. If the number of approved reviews is equal to the number of required approvals, then the request is approved. + if (approvedReviews.length === policy.approvals) { + if (accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { + throw new BadRequestError({ message: "Temporary range is required for temporary access" }); + } + + let privilegeId: string | null = null; + + if (!accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { + // Permanent access + const privilege = await additionalPrivilegeDAL.create( + { + projectMembershipId: accessApprovalRequest.requestedBy, + slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, + permissions: JSON.stringify(accessApprovalRequest.permissions) + }, + tx + ); + privilegeId = privilege.id; + } else { + // Temporary access + const relativeTempAllocatedTimeInMs = ms(accessApprovalRequest.temporaryRange!); + const startTime = new Date(); + + const privilege = await additionalPrivilegeDAL.create( + { + projectMembershipId: accessApprovalRequest.requestedBy, + slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, + permissions: JSON.stringify(accessApprovalRequest.permissions), + isTemporary: true, + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, + temporaryRange: accessApprovalRequest.temporaryRange!, + temporaryAccessStartTime: startTime, + temporaryAccessEndTime: new Date(new Date(startTime).getTime() + relativeTempAllocatedTimeInMs) + }, + tx + ); + privilegeId = privilege.id; + } + + await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId }, tx); + } + + return newReview; + } + throw new BadRequestError({ message: "You have already reviewed this request" }); + }); + + return reviewStatus; + }; + + const getCount = async ({ projectSlug, actor, actorAuthMethod, actorId, actorOrgId }: TGetAccessRequestCountDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new UnauthorizedError({ message: "Project not found" }); + + const { membership } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + if (!membership) throw new BadRequestError({ message: "User not found in project" }); + + const count = await accessApprovalRequestDAL.getCount({ projectId: project.id }); + + return { count }; + }; + + return { + createAccessApprovalRequest, + 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 new file mode 100644 index 000000000..e11ca58d5 --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -0,0 +1,33 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum ApprovalStatus { + PENDING = "pending", + APPROVED = "approved", + REJECTED = "rejected" +} + +export type TVerifyPermission = { + permissions: unknown; +}; + +export type TGetAccessRequestCountDTO = { + projectSlug: string; +} & Omit; + +export type TReviewAccessRequestDTO = { + requestId: string; + status: ApprovalStatus; +} & Omit; + +export type TCreateAccessApprovalRequestDTO = { + projectSlug: string; + permissions: unknown; + isTemporary: boolean; + temporaryRange?: string; +} & Omit; + +export type TListApprovalRequestsDTO = { + projectSlug: string; + authorProjectMembershipId?: string; + envSlug?: string; +} & Omit; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-dal.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-dal.ts new file mode 100644 index 000000000..436821ae9 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAuditLogStreamDALFactory = ReturnType; + +export const auditLogStreamDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.AuditLogStream); + + return orm; +}; 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 new file mode 100644 index 000000000..0e313b59b --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts @@ -0,0 +1,233 @@ +import { ForbiddenError } from "@casl/ability"; +import { RawAxiosRequestHeaders } from "axios"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { request } from "@app/lib/config/request"; +import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; +import { validateLocalIps } from "@app/lib/validator"; + +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"; +import { TAuditLogStreamDALFactory } from "./audit-log-stream-dal"; +import { + LogStreamHeaders, + TCreateAuditLogStreamDTO, + TDeleteAuditLogStreamDTO, + TGetDetailsAuditLogStreamDTO, + TListAuditLogStreamDTO, + TUpdateAuditLogStreamDTO +} from "./audit-log-stream-types"; + +type TAuditLogStreamServiceFactoryDep = { + auditLogStreamDAL: TAuditLogStreamDALFactory; + permissionService: Pick; + licenseService: Pick; +}; + +export type TAuditLogStreamServiceFactory = ReturnType; + +export const auditLogStreamServiceFactory = ({ + auditLogStreamDAL, + permissionService, + licenseService +}: TAuditLogStreamServiceFactoryDep) => { + const create = async ({ + url, + actor, + headers = [], + actorId, + actorOrgId, + actorAuthMethod + }: TCreateAuditLogStreamDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Missing org id from token" }); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.auditLogStreams) + throw new BadRequestError({ + message: "Failed to create audit log streams due to plan restriction. Upgrade plan to create group." + }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + + validateLocalIps(url); + + const totalStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); + 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." + }); + } + + // testing connection first + 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, + // 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}`); + }); + const encryptedHeaders = headers ? infisicalSymmetricEncypt(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 + } + : {}) + }); + return logStream; + }; + + const updateById = async ({ + id, + url, + actor, + headers = [], + actorId, + actorOrgId, + actorAuthMethod + }: TUpdateAuditLogStreamDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Missing org id from token" }); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.auditLogStreams) + throw new BadRequestError({ + message: "Failed to update audit log streams due to plan restriction. Upgrade plan to create group." + }); + + const logStream = await auditLogStreamDAL.findById(id); + if (!logStream) throw new BadRequestError({ message: "Audit log stream not found" }); + + const { orgId } = logStream; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + + if (url) validateLocalIps(url); + + // testing connection first + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + if (headers.length) + headers.forEach(({ key, value }) => { + streamHeaders[key] = value; + }); + + await request + .post( + 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}`); + }); + + const encryptedHeaders = headers ? infisicalSymmetricEncypt(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 + } + : {}) + }); + return updatedLogStream; + }; + + const deleteById = async ({ id, actor, actorId, actorOrgId, actorAuthMethod }: TDeleteAuditLogStreamDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Missing org id from token" }); + + const logStream = await auditLogStreamDAL.findById(id); + if (!logStream) throw new BadRequestError({ message: "Audit log stream not found" }); + + 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; + }; + + const getById = async ({ id, actor, actorId, actorOrgId, actorAuthMethod }: TGetDetailsAuditLogStreamDTO) => { + const logStream = await auditLogStreamDAL.findById(id); + if (!logStream) throw new BadRequestError({ message: "Audit log stream not found" }); + + const { orgId } = logStream; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const headers = + logStream?.encryptedHeadersCiphertext && logStream?.encryptedHeadersIV && logStream?.encryptedHeadersTag + ? (JSON.parse( + infisicalSymmetricDecrypt({ + tag: logStream.encryptedHeadersTag, + iv: logStream.encryptedHeadersIV, + ciphertext: logStream.encryptedHeadersCiphertext, + keyEncoding: logStream.encryptedHeadersKeyEncoding as SecretKeyEncoding + }) + ) as LogStreamHeaders[]) + : undefined; + + return { ...logStream, headers }; + }; + + const list = async ({ actor, actorId, actorOrgId, actorAuthMethod }: TListAuditLogStreamDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const logStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); + return logStreams; + }; + + return { + create, + updateById, + deleteById, + getById, + list + }; +}; 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 new file mode 100644 index 000000000..3c22251d7 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts @@ -0,0 +1,27 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type LogStreamHeaders = { + key: string; + value: string; +}; + +export type TCreateAuditLogStreamDTO = Omit & { + url: string; + headers?: LogStreamHeaders[]; +}; + +export type TUpdateAuditLogStreamDTO = Omit & { + id: string; + url?: string; + headers?: LogStreamHeaders[]; +}; + +export type TDeleteAuditLogStreamDTO = Omit & { + id: string; +}; + +export type TListAuditLogStreamDTO = Omit; + +export type TGetDetailsAuditLogStreamDTO = Omit & { + id: string; +}; 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 afffd463d..f93b391a5 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -1,13 +1,20 @@ -import { logger } from "@app/lib/logger"; +import { RawAxiosRequestHeaders } from "axios"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { request } from "@app/lib/config/request"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; 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 { 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; queueService: TQueueServiceFactory; projectDAL: Pick; licenseService: Pick; @@ -15,11 +22,15 @@ type TAuditLogQueueServiceFactoryDep = { export type TAuditLogQueueServiceFactory = ReturnType; +// keep this timeout 5s it must be fast because else the queue will take time to finish +// audit log is a crowded queue thus needs to be fast +export const AUDIT_LOG_STREAM_TIMEOUT = 5 * 1000; export const auditLogQueueServiceFactory = ({ auditLogDAL, queueService, projectDAL, - licenseService + licenseService, + auditLogStreamDAL }: TAuditLogQueueServiceFactoryDep) => { const pushToLog = async (data: TCreateAuditLogDTO) => { await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { @@ -47,7 +58,7 @@ export const auditLogQueueServiceFactory = ({ // skip inserting if audit log retention is 0 meaning its not supported if (ttl === 0) return; - await auditLogDAL.create({ + const auditLog = await auditLogDAL.create({ actor: actor.type, actorMetadata: actor.metadata, userAgent, @@ -59,37 +70,49 @@ export const auditLogQueueServiceFactory = ({ eventMetadata: event.metadata, userAgentType }); - }); - queueService.start(QueueName.AuditLogPrune, async () => { - logger.info(`${QueueName.AuditLogPrune}: queue task started`); - await auditLogDAL.pruneAuditLog(); - logger.info(`${QueueName.AuditLogPrune}: queue task completed`); - }); + 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( + infisicalSymmetricDecrypt({ + keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, + iv: encryptedHeadersIV, + tag: encryptedHeadersTag, + ciphertext: encryptedHeadersCiphertext + }) + ) as LogStreamHeaders[]) + : []; - // we do a repeat cron job in utc timezone at 12 Midnight each day - const startAuditLogPruneJob = async () => { - // clear previous job - await queueService.stopRepeatableJob( - QueueName.AuditLogPrune, - QueueJobs.AuditLogPrune, - { pattern: "0 0 * * *", utc: true }, - QueueName.AuditLogPrune // just a job id + const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + + if (streamHeaders.length) + streamHeaders.forEach(({ key, value }) => { + headers[key] = value; + }); + + return request.post(url, auditLog, { + headers, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + } + ) ); - - await queueService.queue(QueueName.AuditLogPrune, QueueJobs.AuditLogPrune, undefined, { - delay: 5000, - jobId: QueueName.AuditLogPrune, - repeat: { pattern: "0 0 * * *", utc: true } - }); - }; - - queueService.listen(QueueName.AuditLogPrune, "failed", (err) => { - logger.error(err?.failedReason, `${QueueName.AuditLogPrune}: log pruning failed`); }); return { - pushToLog, - startAuditLogPruneJob + pushToLog }; }; 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 220c25002..415814998 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -51,6 +51,7 @@ export enum EventType { UNAUTHORIZE_INTEGRATION = "unauthorize-integration", CREATE_INTEGRATION = "create-integration", DELETE_INTEGRATION = "delete-integration", + MANUAL_SYNC_INTEGRATION = "manual-sync-integration", ADD_TRUSTED_IP = "add-trusted-ip", UPDATE_TRUSTED_IP = "update-trusted-ip", DELETE_TRUSTED_IP = "delete-trusted-ip", @@ -63,9 +64,25 @@ export enum EventType { ADD_IDENTITY_UNIVERSAL_AUTH = "add-identity-universal-auth", UPDATE_IDENTITY_UNIVERSAL_AUTH = "update-identity-universal-auth", GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", + LOGIN_IDENTITY_KUBERNETES_AUTH = "login-identity-kubernetes-auth", + ADD_IDENTITY_KUBERNETES_AUTH = "add-identity-kubernetes-auth", + UPDATE_IDENTITY_KUBENETES_AUTH = "update-identity-kubernetes-auth", + GET_IDENTITY_KUBERNETES_AUTH = "get-identity-kubernetes-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", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", + LOGIN_IDENTITY_GCP_AUTH = "login-identity-gcp-auth", + ADD_IDENTITY_GCP_AUTH = "add-identity-gcp-auth", + UPDATE_IDENTITY_GCP_AUTH = "update-identity-gcp-auth", + GET_IDENTITY_GCP_AUTH = "get-identity-gcp-auth", + LOGIN_IDENTITY_AWS_AUTH = "login-identity-aws-auth", + ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth", + UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth", + GET_IDENTITY_AWS_AUTH = "get-identity-aws-auth", + LOGIN_IDENTITY_AZURE_AUTH = "login-identity-azure-auth", + ADD_IDENTITY_AZURE_AUTH = "add-identity-azure-auth", + UPDATE_IDENTITY_AZURE_AUTH = "update-identity-azure-auth", + GET_IDENTITY_AZURE_AUTH = "get-identity-azure-auth", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", @@ -269,6 +286,25 @@ interface DeleteIntegrationEvent { }; } +interface ManualSyncIntegrationEvent { + type: EventType.MANUAL_SYNC_INTEGRATION; + metadata: { + integrationId: string; + integration: string; + environment: string; + secretPath: string; + url?: string; + app?: string; + appId?: string; + targetEnvironment?: string; + targetEnvironmentId?: string; + targetService?: string; + targetServiceId?: string; + path?: string; + region?: string; + }; +} + interface AddTrustedIPEvent { type: EventType.ADD_TRUSTED_IP; metadata: { @@ -383,6 +419,50 @@ interface GetIdentityUniversalAuthEvent { }; } +interface LoginIdentityKubernetesAuthEvent { + type: EventType.LOGIN_IDENTITY_KUBERNETES_AUTH; + metadata: { + identityId: string; + identityKubernetesAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityKubernetesAuthEvent { + type: EventType.ADD_IDENTITY_KUBERNETES_AUTH; + metadata: { + identityId: string; + kubernetesHost: string; + allowedNamespaces: string; + allowedNames: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityKubernetesAuthEvent { + type: EventType.UPDATE_IDENTITY_KUBENETES_AUTH; + metadata: { + identityId: string; + kubernetesHost?: string; + allowedNamespaces?: string; + allowedNames?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityKubernetesAuthEvent { + type: EventType.GET_IDENTITY_KUBERNETES_AUTH; + metadata: { + identityId: string; + }; +} + interface CreateIdentityUniversalAuthClientSecretEvent { type: EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET; metadata: { @@ -406,6 +486,138 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } +interface LoginIdentityGcpAuthEvent { + type: EventType.LOGIN_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + identityGcpAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityGcpAuthEvent { + type: EventType.ADD_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + type: string; + allowedServiceAccounts: string; + allowedProjects: string; + allowedZones: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityGcpAuthEvent { + type: EventType.UPDATE_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + type?: string; + allowedServiceAccounts?: string; + allowedProjects?: string; + allowedZones?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityGcpAuthEvent { + type: EventType.GET_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + }; +} + +interface LoginIdentityAwsAuthEvent { + type: EventType.LOGIN_IDENTITY_AWS_AUTH; + metadata: { + identityId: string; + identityAwsAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityAwsAuthEvent { + type: EventType.ADD_IDENTITY_AWS_AUTH; + metadata: { + identityId: string; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityAwsAuthEvent { + type: EventType.UPDATE_IDENTITY_AWS_AUTH; + metadata: { + identityId: string; + stsEndpoint?: string; + allowedPrincipalArns?: string; + allowedAccountIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityAwsAuthEvent { + type: EventType.GET_IDENTITY_AWS_AUTH; + metadata: { + identityId: string; + }; +} + +interface LoginIdentityAzureAuthEvent { + type: EventType.LOGIN_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + identityAzureAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityAzureAuthEvent { + type: EventType.ADD_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + tenantId: string; + resource: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityAzureAuthEvent { + type: EventType.UPDATE_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + tenantId?: string; + resource?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityAzureAuthEvent { + type: EventType.GET_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -645,6 +857,7 @@ export type Event = | UnauthorizeIntegrationEvent | CreateIntegrationEvent | DeleteIntegrationEvent + | ManualSyncIntegrationEvent | AddTrustedIPEvent | UpdateTrustedIPEvent | DeleteTrustedIPEvent @@ -657,9 +870,25 @@ export type Event = | AddIdentityUniversalAuthEvent | UpdateIdentityUniversalAuthEvent | GetIdentityUniversalAuthEvent + | LoginIdentityKubernetesAuthEvent + | AddIdentityKubernetesAuthEvent + | UpdateIdentityKubernetesAuthEvent + | GetIdentityKubernetesAuthEvent | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent | RevokeIdentityUniversalAuthClientSecretEvent + | LoginIdentityGcpAuthEvent + | AddIdentityGcpAuthEvent + | UpdateIdentityGcpAuthEvent + | GetIdentityGcpAuthEvent + | LoginIdentityAwsAuthEvent + | AddIdentityAwsAuthEvent + | UpdateIdentityAwsAuthEvent + | GetIdentityAwsAuthEvent + | LoginIdentityAzureAuthEvent + | AddIdentityAzureAuthEvent + | UpdateIdentityAzureAuthEvent + | GetIdentityAzureAuthEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts new file mode 100644 index 000000000..3feafa534 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -0,0 +1,194 @@ +import { + AddUserToGroupCommand, + AttachUserPolicyCommand, + CreateAccessKeyCommand, + CreateUserCommand, + DeleteAccessKeyCommand, + DeleteUserCommand, + DeleteUserPolicyCommand, + DetachUserPolicyCommand, + GetUserCommand, + IAMClient, + ListAccessKeysCommand, + ListAttachedUserPoliciesCommand, + ListGroupsForUserCommand, + ListUserPoliciesCommand, + PutUserPolicyCommand, + RemoveUserFromGroupCommand +} from "@aws-sdk/client-iam"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; + +const generateUsername = () => { + return alphaNumericNanoId(32); +}; + +export const AwsIamProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretAwsIamSchema.parseAsync(inputs); + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer) => { + const client = new IAMClient({ + region: providerInputs.region, + credentials: { + accessKeyId: providerInputs.accessKey, + secretAccessKey: providerInputs.secretAccessKey + } + }); + + return client; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const isConnected = await client.send(new GetUserCommand({})).then(() => true); + return isConnected; + }; + + const create = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = generateUsername(); + const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; + const createUserRes = await client.send( + new CreateUserCommand({ + Path: awsPath, + PermissionsBoundary: permissionBoundaryPolicyArn || undefined, + Tags: [{ Key: "createdBy", Value: "infisical-dynamic-secret" }], + 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 + } + }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = entityId; + + // remove user from groups + const userGroups = await client.send(new ListGroupsForUserCommand({ UserName: username })); + await Promise.all( + (userGroups.Groups || []).map(({ GroupName }) => + client.send( + new RemoveUserFromGroupCommand({ + GroupName, + UserName: username + }) + ) + ) + ); + + // remove user access keys + const userAccessKeys = await client.send(new ListAccessKeysCommand({ UserName: username })); + await Promise.all( + (userAccessKeys.AccessKeyMetadata || []).map(({ AccessKeyId }) => + client.send( + new DeleteAccessKeyCommand({ + AccessKeyId, + UserName: username + }) + ) + ) + ); + + // remove user inline policies + const userInlinePolicies = await client.send(new ListUserPoliciesCommand({ UserName: username })); + await Promise.all( + (userInlinePolicies.PolicyNames || []).map((policyName) => + client.send( + new DeleteUserPolicyCommand({ + PolicyName: policyName, + UserName: username + }) + ) + ) + ); + + // remove user attached policies + const userAttachedPolicies = await client.send(new ListAttachedUserPoliciesCommand({ UserName: username })); + await Promise.all( + (userAttachedPolicies.AttachedPolicies || []).map((policy) => + client.send( + new DetachUserPolicyCommand({ + PolicyArn: policy.PolicyArn, + UserName: username + }) + ) + ) + ); + + await client.send(new DeleteUserCommand({ UserName: username })); + return { entityId: username }; + }; + + const renew = async (_inputs: unknown, entityId: string) => { + // do nothing + const username = entityId; + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 34c049553..beb6c428e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -1,8 +1,10 @@ +import { AwsIamProvider } from "./aws-iam"; import { CassandraProvider } from "./cassandra"; import { DynamicSecretProviders } from "./models"; import { SqlDatabaseProvider } from "./sql-database"; export const buildDynamicSecretProviders = () => ({ [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(), - [DynamicSecretProviders.Cassandra]: CassandraProvider() + [DynamicSecretProviders.Cassandra]: CassandraProvider(), + [DynamicSecretProviders.AwsIam]: AwsIamProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index edb60d4b2..c11f6ddfb 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -8,38 +8,51 @@ export enum SqlProviders { export const DynamicSecretSqlDBSchema = z.object({ client: z.nativeEnum(SqlProviders), - host: z.string().toLowerCase(), + host: z.string().trim().toLowerCase(), port: z.number(), - database: z.string(), - username: z.string(), - password: z.string(), - creationStatement: z.string(), - revocationStatement: z.string(), - renewStatement: z.string().optional(), + database: z.string().trim(), + username: z.string().trim(), + password: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + renewStatement: z.string().trim().optional(), ca: z.string().optional() }); export const DynamicSecretCassandraSchema = z.object({ - host: z.string().toLowerCase(), + host: z.string().trim().toLowerCase(), port: z.number(), - localDataCenter: z.string().min(1), - keyspace: z.string().optional(), - username: z.string(), - password: z.string(), - creationStatement: z.string(), - revocationStatement: z.string(), - renewStatement: z.string().optional(), + localDataCenter: z.string().trim().min(1), + keyspace: z.string().trim().optional(), + username: z.string().trim(), + password: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + renewStatement: z.string().trim().optional(), ca: z.string().optional() }); +export const DynamicSecretAwsIamSchema = z.object({ + accessKey: z.string().trim().min(1), + secretAccessKey: z.string().trim().min(1), + region: z.string().trim().min(1), + awsPath: z.string().trim().optional(), + permissionBoundaryPolicyArn: z.string().trim().optional(), + policyDocument: z.string().trim().optional(), + userGroups: z.string().trim().optional(), + policyArns: z.string().trim().optional() +}); + export enum DynamicSecretProviders { SqlDatabase = "sql-database", - Cassandra = "cassandra" + Cassandra = "cassandra", + AwsIam = "aws-iam" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }), - z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }) + z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }), + z.object({ type: z.literal(DynamicSecretProviders.AwsIam), inputs: DynamicSecretAwsIamSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/ee/services/group/group-fns.ts b/backend/src/ee/services/group/group-fns.ts index e308891f9..4f96ddbf0 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, TUsers } from "@app/db/schemas"; +import { SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; import { decryptAsymmetric, encryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ScimRequestError } from "@app/lib/errors"; @@ -188,9 +188,9 @@ export const addUsersToGroupByUserIds = async ({ // check if all user(s) are part of the organization const existingUserOrgMemberships = await orgDAL.findMembership( { - orgId: group.orgId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: group.orgId, $in: { - userId: userIds + [`${TableName.OrgMembership}.userId` as "userId"]: userIds } }, { tx } diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts index 81dc11a00..70753ee09 100644 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts @@ -1,5 +1,7 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; +import { PackRule, unpackRules } from "@casl/ability/extra"; import ms from "ms"; +import { z } from "zod"; import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; @@ -8,7 +10,7 @@ import { TIdentityProjectDALFactory } from "@app/services/identity-project/ident import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { ProjectPermissionActions, ProjectPermissionSet, ProjectPermissionSub } from "../permission/project-permission"; import { TIdentityProjectAdditionalPrivilegeDALFactory } from "./identity-project-additional-privilege-dal"; import { IdentityProjectAdditionalPrivilegeTemporaryMode, @@ -30,6 +32,27 @@ export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType< typeof identityProjectAdditionalPrivilegeServiceFactory >; +// TODO(akhilmhdh): move this to more centralized +export const UnpackedPermissionSchema = z.object({ + subject: z.union([z.string().min(1), z.string().array()]).optional(), + action: z.union([z.string().min(1), z.string().array()]), + conditions: z + .object({ + environment: z.string().optional(), + secretPath: z + .object({ + $glob: z.string().min(1) + }) + .optional() + }) + .optional() +}); + +const unpackPermissions = (permissions: unknown) => + UnpackedPermissionSchema.array().parse( + unpackRules((permissions || []) as PackRule>>[]) + ); + export const identityProjectAdditionalPrivilegeServiceFactory = ({ identityProjectAdditionalPrivilegeDAL, identityProjectDAL, @@ -86,7 +109,10 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ slug, permissions: customPermission }); - return additionalPrivilege; + return { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + }; } const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); @@ -100,7 +126,10 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) }); - return additionalPrivilege; + return { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + }; }; const updateBySlug = async ({ @@ -163,7 +192,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) }); - return additionalPrivilege; + return { + ...additionalPrivilege, + + permissions: unpackPermissions(additionalPrivilege.permissions) + }; } const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { @@ -174,7 +207,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ temporaryRange: null, temporaryMode: null }); - return additionalPrivilege; + return { + ...additionalPrivilege, + + permissions: unpackPermissions(additionalPrivilege.permissions) + }; }; const deleteBySlug = async ({ @@ -220,7 +257,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); - return deletedPrivilege; + return { + ...deletedPrivilege, + + permissions: unpackPermissions(deletedPrivilege.permissions) + }; }; const getPrivilegeDetailsBySlug = async ({ @@ -254,7 +295,10 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ }); if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); - return identityPrivilege; + return { + ...identityPrivilege, + permissions: unpackPermissions(identityPrivilege.permissions) + }; }; const listIdentityProjectPrivileges = async ({ @@ -284,7 +328,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({ projectMembershipId: identityProjectMembership.id }); - return identityPrivileges; + return identityPrivileges.map((el) => ({ + ...el, + + permissions: unpackPermissions(el.permissions) + })); }; return { 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 85c537684..6773c9486 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -1,7 +1,14 @@ import { ForbiddenError } from "@casl/ability"; import jwt from "jsonwebtoken"; -import { OrgMembershipRole, OrgMembershipStatus, SecretKeyEncoding, TLdapConfigsUpdate } from "@app/db/schemas"; +import { + OrgMembershipRole, + OrgMembershipStatus, + SecretKeyEncoding, + TableName, + TLdapConfigsUpdate, + TUsers +} from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; @@ -19,12 +26,15 @@ import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { normalizeUsername } from "@app/services/user/user-fns"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -46,6 +56,7 @@ import { TLdapGroupMapDALFactory } from "./ldap-group-map-dal"; type TLdapConfigServiceFactoryDep = { ldapConfigDAL: Pick; ldapGroupMapDAL: Pick; + orgMembershipDAL: Pick; orgDAL: Pick< TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" @@ -75,6 +86,7 @@ export const ldapConfigServiceFactory = ({ ldapConfigDAL, ldapGroupMapDAL, orgDAL, + orgMembershipDAL, orgBotDAL, groupDAL, groupProjectDAL, @@ -379,16 +391,17 @@ export const ldapConfigServiceFactory = ({ username, firstName, lastName, - emails, + email, groups, orgId, relayState }: TLdapLoginDTO) => { const appCfg = getConfig(); + const serverCfg = await getServerCfg(); let userAlias = await userAliasDAL.findOne({ externalId, orgId, - aliasType: AuthMethod.LDAP + aliasType: UserAliasType.LDAP }); const organization = await orgDAL.findOrgById(orgId); @@ -396,7 +409,13 @@ export const ldapConfigServiceFactory = ({ if (userAlias) { await userDAL.transaction(async (tx) => { - const [orgMembership] = await orgDAL.findMembership({ userId: userAlias.userId }, { tx }); + const [orgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: userAlias.userId, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); if (!orgMembership) { await orgDAL.createMembership( { @@ -419,40 +438,75 @@ export const ldapConfigServiceFactory = ({ }); } else { userAlias = await userDAL.transaction(async (tx) => { - const uniqueUsername = await normalizeUsername(username, userDAL); - const newUser = await userDAL.create( - { - username: uniqueUsername, - email: emails[0], - firstName, - lastName, - authMethods: [AuthMethod.LDAP], - isGhost: false - }, - tx - ); + let newUser: TUsers | undefined; + if (serverCfg.trustSamlEmails) { + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + + if (!newUser) { + const uniqueUsername = await normalizeUsername(username, userDAL); + newUser = await userDAL.create( + { + username: serverCfg.trustLdapEmails ? email : uniqueUsername, + email, + isEmailVerified: serverCfg.trustLdapEmails, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + const newUserAlias = await userAliasDAL.create( { userId: newUser.id, username, - aliasType: AuthMethod.LDAP, + aliasType: UserAliasType.LDAP, externalId, - emails, + emails: [email], orgId }, tx ); - await orgDAL.createMembership( + const [orgMembership] = await orgDAL.findMembership( { - userId: newUser.id, - orgId, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited + [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId }, - tx + { tx } ); + if (!orgMembership) { + await orgMembershipDAL.create( + { + userId: userAlias.userId, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + return newUserAlias; }); } @@ -543,11 +597,14 @@ export const ldapConfigServiceFactory = ({ authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, + ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), firstName, lastName, organizationName: organization.name, organizationId: organization.id, + organizationSlug: organization.slug, authMethod: AuthMethod.LDAP, + authType: UserAliasType.LDAP, isUserCompleted, ...(relayState ? { 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 b7e9feb7b..aa4aa8da7 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-types.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-types.ts @@ -51,7 +51,7 @@ export type TLdapLoginDTO = { username: string; firstName: string; lastName: string; - emails: string[]; + email: string; orgId: string; groups?: { dn: string; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 8a4de57f1..189a3c4e0 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -24,6 +24,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ customAlerts: false, auditLogs: false, auditLogsRetentionDays: 0, + auditLogStreams: false, + auditLogStreamLimit: 3, samlSSO: false, scim: false, ldap: false, diff --git a/backend/src/ee/services/license/license-dal.ts b/backend/src/ee/services/license/license-dal.ts index 4e70dfb5a..cf7048801 100644 --- a/backend/src/ee/services/license/license-dal.ts +++ b/backend/src/ee/services/license/license-dal.ts @@ -16,6 +16,8 @@ export const licenseDALFactory = (db: TDbClient) => { void bd.where({ orgId }); } }) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .where(`${TableName.Users}.isGhost`, false) .count(); return doc?.[0].count; } catch (error) { diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index e81f6dc12..47b46d010 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -121,8 +121,8 @@ export const licenseServiceFactory = ({ if (isValidOfflineLicense) { onPremFeatures = contents.license.features; - instanceType = InstanceType.EnterpriseOnPrem; - logger.info(`Instance type: ${InstanceType.EnterpriseOnPrem}`); + instanceType = InstanceType.EnterpriseOnPremOffline; + logger.info(`Instance type: ${InstanceType.EnterpriseOnPremOffline}`); isValidLicense = true; return; } diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 1cea39a83..0c8fdc197 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -3,6 +3,7 @@ import { TOrgPermission } from "@app/lib/types"; export enum InstanceType { OnPrem = "self-hosted", EnterpriseOnPrem = "enterprise-self-hosted", + EnterpriseOnPremOffline = "enterprise-self-hosted-offline", Cloud = "cloud" } @@ -40,6 +41,8 @@ export type TFeatureSet = { customAlerts: false; auditLogs: false; auditLogsRetentionDays: 0; + auditLogStreams: false; + auditLogStreamLimit: 3; samlSSO: false; scim: false; ldap: false; 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 ff5f4bc3f..7dfd211e1 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -7,7 +7,8 @@ import { SecretKeyEncoding, TableName, TSamlConfigs, - TSamlConfigsUpdate + TSamlConfigsUpdate, + TUsers } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { @@ -19,10 +20,18 @@ import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; -import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; +import { AuthTokenType } from "@app/services/auth/auth-type"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { normalizeUsername } from "@app/services/user/user-fns"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -31,15 +40,19 @@ import { TSamlConfigDALFactory } from "./saml-config-dal"; import { TCreateSamlCfgDTO, TGetSamlCfgDTO, TSamlLoginDTO, TUpdateSamlCfgDTO } from "./saml-config-types"; type TSamlConfigServiceFactoryDep = { - samlConfigDAL: TSamlConfigDALFactory; - userDAL: Pick; + samlConfigDAL: Pick; + userDAL: Pick; + userAliasDAL: Pick; orgDAL: Pick< TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" >; + orgMembershipDAL: Pick; orgBotDAL: Pick; permissionService: Pick; licenseService: Pick; + tokenService: Pick; + smtpService: Pick; }; export type TSamlConfigServiceFactory = ReturnType; @@ -48,9 +61,13 @@ export const samlConfigServiceFactory = ({ samlConfigDAL, orgBotDAL, orgDAL, + orgMembershipDAL, userDAL, + userAliasDAL, permissionService, - licenseService + licenseService, + tokenService, + smtpService }: TSamlConfigServiceFactoryDep) => { const createSamlCfg = async ({ cert, @@ -305,7 +322,7 @@ export const samlConfigServiceFactory = ({ }; const samlLogin = async ({ - username, + externalId, email, firstName, lastName, @@ -314,38 +331,40 @@ export const samlConfigServiceFactory = ({ relayState }: TSamlLoginDTO) => { const appCfg = getConfig(); - let user = await userDAL.findOne({ username }); + const serverCfg = await getServerCfg(); + const userAlias = await userAliasDAL.findOne({ + externalId, + orgId, + aliasType: UserAliasType.SAML + }); const organization = await orgDAL.findOrgById(orgId); if (!organization) throw new BadRequestError({ message: "Org not found" }); - // TODO(dangtony98): remove this after aliases update - if (authProvider === AuthMethod.KEYCLOAK_SAML && appCfg.LICENSE_SERVER_KEY) { - throw new BadRequestError({ message: "Keycloak SAML is not yet available on Infisical Cloud" }); - } - - if (user) { - await userDAL.transaction(async (tx) => { + let user: TUsers; + if (userAlias) { + user = await userDAL.transaction(async (tx) => { + const foundUser = await userDAL.findById(userAlias.userId, tx); const [orgMembership] = await orgDAL.findMembership( { - userId: user.id, + [`${TableName.OrgMembership}.userId` as "userId"]: foundUser.id, [`${TableName.OrgMembership}.orgId` as "id"]: orgId }, { tx } ); if (!orgMembership) { - await orgDAL.createMembership( + await orgMembershipDAL.create( { - userId: user.id, - orgId, + userId: userAlias.userId, inviteEmail: email, + orgId, role: OrgMembershipRole.Member, - status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later }, tx ); // Only update the membership to Accepted if the user account is already completed. - } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { + } else if (orgMembership.status === OrgMembershipStatus.Invited && foundUser.isAccepted) { await orgDAL.updateMembershipById( orgMembership.id, { @@ -354,40 +373,97 @@ export const samlConfigServiceFactory = ({ tx ); } + + return foundUser; }); } else { user = await userDAL.transaction(async (tx) => { - const newUser = await userDAL.create( + let newUser: TUsers | undefined; + if (serverCfg.trustSamlEmails) { + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + + if (!newUser) { + const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); + newUser = await userDAL.create( + { + username: serverCfg.trustSamlEmails ? email : uniqueUsername, + email, + isEmailVerified: serverCfg.trustSamlEmails, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + + await userAliasDAL.create( { - username, - email, - firstName, - lastName, - authMethods: [AuthMethod.EMAIL], - isGhost: false + userId: newUser.id, + aliasType: UserAliasType.SAML, + externalId, + emails: email ? [email] : [], + orgId }, tx ); - await orgDAL.createMembership({ - inviteEmail: email, - orgId, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited - }); + + const [orgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); + + if (!orgMembership) { + await orgMembershipDAL.create( + { + userId: newUser.id, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + return newUser; }); } + const isUserCompleted = Boolean(user.isAccepted); const providerAuthToken = jwt.sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, + ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), firstName, lastName, organizationName: organization.name, organizationId: organization.id, + organizationSlug: organization.slug, authMethod: authProvider, + authType: UserAliasType.SAML, isUserCompleted, ...(relayState ? { @@ -403,6 +479,22 @@ export const samlConfigServiceFactory = ({ await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); + if (user.email && !user.isEmailVerified) { + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.EmailVerification, + subjectLine: "Infisical confirmation code", + recipients: [user.email], + substitutions: { + code: token + } + }); + } + return { isUserCompleted, providerAuthToken }; }; diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index df7694920..92ee32b5c 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -45,8 +45,8 @@ export type TGetSamlCfgDTO = }; export type TSamlLoginDTO = { - username: string; - email?: string; + externalId: string; + email: string; firstName: string; lastName?: string; authProvider: string; diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index e816cffcf..ec54a4d1f 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -2,31 +2,31 @@ import { TListScimGroups, TListScimUsers, TScimGroup, TScimUser } from "./scim-t export const buildScimUserList = ({ scimUsers, - offset, + startIndex, limit }: { scimUsers: TScimUser[]; - offset: number; + startIndex: number; limit: number; }): TListScimUsers => { return { Resources: scimUsers, itemsPerPage: limit, schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - startIndex: offset, + startIndex, totalResults: scimUsers.length }; }; export const buildScimUser = ({ - userId, + orgMembershipId, username, email, firstName, lastName, active }: { - userId: string; + orgMembershipId: string; username: string; email?: string | null; firstName: string; @@ -35,7 +35,7 @@ export const buildScimUser = ({ }): TScimUser => { const scimUser = { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], - id: userId, + id: orgMembershipId, userName: username, displayName: `${firstName} ${lastName}`, name: { @@ -65,18 +65,18 @@ export const buildScimUser = ({ export const buildScimGroupList = ({ scimGroups, - offset, + startIndex, limit }: { scimGroups: TScimGroup[]; - offset: number; + startIndex: number; limit: number; }): TListScimGroups => { return { Resources: scimGroups, itemsPerPage: limit, schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - startIndex: offset, + startIndex, totalResults: scimGroups.length }; }; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index d56c00a0c..9a084c6d7 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import jwt from "jsonwebtoken"; -import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups, TOrgMemberships, TUsers } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; @@ -11,16 +11,21 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TOrgPermission } from "@app/lib/types"; -import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; +import { AuthTokenType } from "@app/services/auth/auth-type"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; -import { deleteOrgMembership } from "@app/services/org/org-fns"; +import { deleteOrgMembershipFn } from "@app/services/org/org-fns"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { normalizeUsername } from "@app/services/user/user-fns"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -47,24 +52,32 @@ import { type TScimServiceFactoryDep = { scimDAL: Pick; - userDAL: Pick; + userDAL: Pick< + TUserDALFactory, + "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" + >; + userAliasDAL: Pick; orgDAL: Pick< TOrgDALFactory, - "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" + "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" | "updateMembershipById" >; + orgMembershipDAL: Pick; projectDAL: Pick; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick; groupDAL: Pick< TGroupDALFactory, "create" | "findOne" | "findAllGroupMembers" | "update" | "delete" | "findGroups" | "transaction" >; groupProjectDAL: Pick; - userGroupMembershipDAL: TUserGroupMembershipDALFactory; // TODO: Pick + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "find" | "transaction" | "insertMany" | "filterProjectsByUserMembership" | "delete" + >; projectKeyDAL: Pick; projectBotDAL: Pick; - licenseService: Pick; + licenseService: Pick; permissionService: Pick; - smtpService: TSmtpService; + smtpService: Pick; }; export type TScimServiceFactory = ReturnType; @@ -73,7 +86,9 @@ export const scimServiceFactory = ({ licenseService, scimDAL, userDAL, + userAliasDAL, orgDAL, + orgMembershipDAL, projectDAL, projectMembershipDAL, groupDAL, @@ -160,7 +175,7 @@ export const scimServiceFactory = ({ }; // SCIM server endpoints - const listScimUsers = async ({ offset, limit, filter, orgId }: TListScimUsersDTO): Promise => { + const listScimUsers = async ({ startIndex, limit, filter, orgId }: TListScimUsersDTO): Promise => { const org = await orgDAL.findById(orgId); if (!org.scimEnabled) @@ -178,11 +193,11 @@ export const scimServiceFactory = ({ attributeName = "email"; } - return { [attributeName]: parsedValue }; + return { [attributeName]: parsedValue.replace(/"/g, "") }; }; const findOpts = { - ...(offset && { offset }), + ...(startIndex && { offset: startIndex - 1 }), ...(limit && { limit }) }; @@ -194,10 +209,10 @@ export const scimServiceFactory = ({ findOpts ); - const scimUsers = users.map(({ userId, username, firstName, lastName, email }) => + const scimUsers = users.map(({ id, externalId, username, firstName, lastName, email }) => buildScimUser({ - userId: userId ?? "", - username, + orgMembershipId: id ?? "", + username: externalId ?? username, firstName: firstName ?? "", lastName: lastName ?? "", email, @@ -207,16 +222,16 @@ export const scimServiceFactory = ({ return buildScimUserList({ scimUsers, - offset, + startIndex, limit }); }; - const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => { + const getScimUser = async ({ orgMembershipId, orgId }: TGetScimUserDTO) => { const [membership] = await orgDAL .findMembership({ - userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }) .catch(() => { throw new ScimRequestError({ @@ -238,8 +253,8 @@ export const scimServiceFactory = ({ }); return buildScimUser({ - userId: membership.userId as string, - username: membership.username, + orgMembershipId: membership.id, + username: membership.externalId ?? membership.username, email: membership.email ?? "", firstName: membership.firstName as string, lastName: membership.lastName as string, @@ -247,7 +262,9 @@ export const scimServiceFactory = ({ }); }; - const createScimUser = async ({ username, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { + const createScimUser = async ({ externalId, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { + if (!email) throw new ScimRequestError({ detail: "Invalid request. Missing email.", status: 400 }); + const org = await orgDAL.findById(orgId); if (!org) @@ -262,67 +279,121 @@ export const scimServiceFactory = ({ status: 403 }); - let user = await userDAL.findOne({ - username + const appCfg = getConfig(); + const serverCfg = await getServerCfg(); + + const userAlias = await userAliasDAL.findOne({ + externalId, + orgId, + aliasType: UserAliasType.SAML }); - if (user) { - await userDAL.transaction(async (tx) => { - const [orgMembership] = await orgDAL.findMembership( + const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => { + let user: TUsers | undefined; + let orgMembership: TOrgMemberships; + if (userAlias) { + user = await userDAL.findById(userAlias.userId, tx); + orgMembership = await orgMembershipDAL.findOne( { userId: user.id, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + orgId }, - { tx } + tx ); - if (orgMembership) - throw new ScimRequestError({ - detail: "User already exists in the database", - status: 409 - }); if (!orgMembership) { - await orgDAL.createMembership( + orgMembership = await orgMembershipDAL.create( { - userId: user.id, - orgId, + userId: userAlias.userId, inviteEmail: email, + orgId, role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited + status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { + orgMembership = await orgMembershipDAL.updateById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted }, tx ); } - }); - } else { - user = await userDAL.transaction(async (tx) => { - const newUser = await userDAL.create( + } else { + if (serverCfg.trustSamlEmails) { + user = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + + if (!user) { + const uniqueUsername = await normalizeUsername(`${firstName}-${lastName}`, userDAL); + user = await userDAL.create( + { + username: serverCfg.trustSamlEmails ? email : uniqueUsername, + email, + isEmailVerified: serverCfg.trustSamlEmails, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + + await userAliasDAL.create( { - username, - email, - firstName, - lastName, - authMethods: [AuthMethod.EMAIL], - isGhost: false + userId: user.id, + aliasType: UserAliasType.SAML, + externalId, + emails: email ? [email] : [], + orgId }, tx ); - await orgDAL.createMembership( + const [foundOrgMembership] = await orgDAL.findMembership( { - inviteEmail: email, - orgId, - userId: newUser.id, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId }, - tx + { tx } ); - return newUser; - }); - } - const appCfg = getConfig(); + orgMembership = foundOrgMembership; + + if (!orgMembership) { + orgMembership = await orgMembershipDAL.create( + { + userId: user.id, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { + orgMembership = await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + } + + return { user, orgMembership }; + }); if (email) { await smtpService.sendMail({ @@ -337,20 +408,20 @@ export const scimServiceFactory = ({ } return buildScimUser({ - userId: user.id, - username: user.username, - firstName: user.firstName as string, - lastName: user.lastName as string, - email: user.email ?? "", + orgMembershipId: createdOrgMembership.id, + username: externalId, + firstName: createdUser.firstName as string, + lastName: createdUser.lastName as string, + email: createdUser.email ?? "", active: true }); }; - const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => { + const updateScimUser = async ({ orgMembershipId, orgId, operations }: TUpdateScimUserDTO) => { const [membership] = await orgDAL .findMembership({ - userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }) .catch(() => { throw new ScimRequestError({ @@ -386,18 +457,20 @@ export const scimServiceFactory = ({ }); if (!active) { - await deleteOrgMembership({ + await deleteOrgMembershipFn({ orgMembershipId: membership.id, orgId: membership.orgId, orgDAL, - projectDAL, - projectMembershipDAL + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }); } return buildScimUser({ - userId: membership.userId as string, - username: membership.username, + orgMembershipId: membership.id, + username: membership.externalId ?? membership.username, email: membership.email, firstName: membership.firstName as string, lastName: membership.lastName as string, @@ -405,11 +478,11 @@ export const scimServiceFactory = ({ }); }; - const replaceScimUser = async ({ userId, active, orgId }: TReplaceScimUserDTO) => { + const replaceScimUser = async ({ orgMembershipId, active, orgId }: TReplaceScimUserDTO) => { const [membership] = await orgDAL .findMembership({ - userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }) .catch(() => { throw new ScimRequestError({ @@ -431,19 +504,20 @@ export const scimServiceFactory = ({ }); if (!active) { - // tx - await deleteOrgMembership({ + await deleteOrgMembershipFn({ orgMembershipId: membership.id, orgId: membership.orgId, orgDAL, - projectDAL, - projectMembershipDAL + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }); } return buildScimUser({ - userId: membership.userId as string, - username: membership.username, + orgMembershipId: membership.id, + username: membership.externalId ?? membership.username, email: membership.email, firstName: membership.firstName as string, lastName: membership.lastName as string, @@ -451,18 +525,11 @@ export const scimServiceFactory = ({ }); }; - const deleteScimUser = async ({ userId, orgId }: TDeleteScimUserDTO) => { - const [membership] = await orgDAL - .findMembership({ - userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId - }) - .catch(() => { - throw new ScimRequestError({ - detail: "User not found", - status: 404 - }); - }); + const deleteScimUser = async ({ orgMembershipId, orgId }: TDeleteScimUserDTO) => { + const [membership] = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId + }); if (!membership) throw new ScimRequestError({ @@ -477,18 +544,20 @@ export const scimServiceFactory = ({ }); } - await deleteOrgMembership({ + await deleteOrgMembershipFn({ orgMembershipId: membership.id, orgId: membership.orgId, orgDAL, - projectDAL, - projectMembershipDAL + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }); return {}; // intentionally return empty object upon success }; - const listScimGroups = async ({ orgId, offset, limit }: TListScimGroupsDTO) => { + const listScimGroups = async ({ orgId, startIndex, limit }: TListScimGroupsDTO) => { const plan = await licenseService.getPlan(orgId); if (!plan.groups) throw new BadRequestError({ @@ -509,21 +578,27 @@ export const scimServiceFactory = ({ status: 403 }); - const groups = await groupDAL.findGroups({ - orgId - }); + const groups = await groupDAL.findGroups( + { + orgId + }, + { + offset: startIndex - 1, + limit + } + ); const scimGroups = groups.map((group) => buildScimGroup({ groupId: group.id, name: group.name, - members: [] + members: [] // does this need to be populated? }) ); return buildScimGroupList({ scimGroups, - offset, + startIndex, limit }); }; @@ -562,9 +637,15 @@ export const scimServiceFactory = ({ ); if (members && members.length) { + const orgMemberships = await orgMembershipDAL.find({ + $in: { + id: members.map((member) => member.value) + } + }); + const newMembers = await addUsersToGroupByUserIds({ group, - userIds: members.map((member) => member.value), + userIds: orgMemberships.map((membership) => membership.userId as string), userDAL, userGroupMembershipDAL, orgDAL, @@ -581,12 +662,19 @@ export const scimServiceFactory = ({ return { group, newMembers: [] }; }); + const orgMemberships = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + $in: { + [`${TableName.OrgMembership}.userId` as "userId"]: newGroup.newMembers.map((member) => member.id) + } + }); + return buildScimGroup({ groupId: newGroup.group.id, name: newGroup.group.name, - members: newGroup.newMembers.map((member) => ({ - value: member.id, - display: `${member.firstName} ${member.lastName}` + members: orgMemberships.map(({ id, firstName, lastName }) => ({ + value: id, + display: `${firstName} ${lastName}` })) }); }; @@ -615,15 +703,22 @@ export const scimServiceFactory = ({ groupId: group.id }); + const orgMemberships = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + $in: { + [`${TableName.OrgMembership}.userId` as "userId"]: users + .filter((user) => user.isPartOfGroup) + .map((user) => user.id) + } + }); + return buildScimGroup({ groupId: group.id, name: group.name, - members: users - .filter((user) => user.isPartOfGroup) - .map((user) => ({ - value: user.id, - display: `${user.firstName} ${user.lastName}` - })) + members: orgMemberships.map(({ id, firstName, lastName }) => ({ + value: id, + display: `${firstName} ${lastName}` + })) }); }; @@ -667,7 +762,13 @@ export const scimServiceFactory = ({ } if (members) { - const membersIdsSet = new Set(members.map((member) => member.value)); + const orgMemberships = await orgMembershipDAL.find({ + $in: { + id: members.map((member) => member.value) + } + }); + + const membersIdsSet = new Set(orgMemberships.map((orgMembership) => orgMembership.userId)); const directMemberUserIds = ( await userGroupMembershipDAL.find({ @@ -686,13 +787,13 @@ export const scimServiceFactory = ({ const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds); const allMembersUserIdsSet = new Set(allMembersUserIds); - const toAddUserIds = members.filter((member) => !allMembersUserIdsSet.has(member.value)); + const toAddUserIds = orgMemberships.filter((member) => !allMembersUserIdsSet.has(member.userId as string)); const toRemoveUserIds = allMembersUserIds.filter((userId) => !membersIdsSet.has(userId)); if (toAddUserIds.length) { await addUsersToGroupByUserIds({ group, - userIds: toAddUserIds.map((member) => member.value), + userIds: toAddUserIds.map((member) => member.userId as string), userDAL, userGroupMembershipDAL, orgDAL, diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index 73d0ebe78..46ab90b8f 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -12,7 +12,7 @@ export type TDeleteScimTokenDTO = { // SCIM server endpoint types export type TListScimUsersDTO = { - offset: number; + startIndex: number; limit: number; filter?: string; orgId: string; @@ -27,12 +27,12 @@ export type TListScimUsers = { }; export type TGetScimUserDTO = { - userId: string; + orgMembershipId: string; orgId: string; }; export type TCreateScimUserDTO = { - username: string; + externalId: string; email?: string; firstName: string; lastName: string; @@ -40,7 +40,7 @@ export type TCreateScimUserDTO = { }; export type TUpdateScimUserDTO = { - userId: string; + orgMembershipId: string; orgId: string; operations: { op: string; @@ -54,18 +54,18 @@ export type TUpdateScimUserDTO = { }; export type TReplaceScimUserDTO = { - userId: string; + orgMembershipId: string; active: boolean; orgId: string; }; export type TDeleteScimUserDTO = { - userId: string; + orgMembershipId: string; orgId: string; }; export type TListScimGroupsDTO = { - offset: number; + startIndex: number; limit: number; orgId: string; }; 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 2e66ab2ce..5d0977134 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 @@ -7,14 +7,24 @@ import { SecretType, TSecretApprovalRequestsSecretsInsert } from "@app/db/schemas"; +import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { groupBy, pick, unique } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { ActorType } from "@app/services/auth/auth-type"; import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TSecretDALFactory } from "@app/services/secret/secret-dal"; +import { + fnSecretBlindIndexCheck, + fnSecretBlindIndexCheckV2, + fnSecretBulkDelete, + fnSecretBulkInsert, + fnSecretBulkUpdate, + getAllNestedSecretReferences +} from "@app/services/secret/secret-fns"; import { TSecretQueueFactory } from "@app/services/secret/secret-queue"; -import { TSecretServiceFactory } from "@app/services/secret/secret-service"; +import { SecretOperations } from "@app/services/secret/secret-types"; import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; @@ -29,7 +39,6 @@ import { TSecretApprovalRequestReviewerDALFactory } from "./secret-approval-requ import { TSecretApprovalRequestSecretDALFactory } from "./secret-approval-request-secret-dal"; import { ApprovalStatus, - CommitType, RequestState, TApprovalRequestCountDTO, TGenerateSecretApprovalRequestDTO, @@ -42,10 +51,11 @@ import { type TSecretApprovalRequestServiceFactoryDep = { permissionService: Pick; + projectBotService: Pick; secretApprovalRequestDAL: TSecretApprovalRequestDALFactory; secretApprovalRequestSecretDAL: TSecretApprovalRequestSecretDALFactory; secretApprovalRequestReviewerDAL: TSecretApprovalRequestReviewerDALFactory; - folderDAL: Pick; + folderDAL: Pick; secretDAL: TSecretDALFactory; secretTagDAL: Pick; secretBlindIndexDAL: Pick; @@ -53,15 +63,7 @@ type TSecretApprovalRequestServiceFactoryDep = { secretVersionDAL: Pick; secretVersionTagDAL: Pick; projectDAL: Pick; - secretService: Pick< - TSecretServiceFactory, - | "fnSecretBulkInsert" - | "fnSecretBulkUpdate" - | "fnSecretBlindIndexCheck" - | "fnSecretBulkDelete" - | "fnSecretBlindIndexCheckV2" - >; - secretQueueService: Pick; + secretQueueService: Pick; }; export type TSecretApprovalRequestServiceFactory = ReturnType; @@ -78,9 +80,9 @@ export const secretApprovalRequestServiceFactory = ({ projectDAL, permissionService, snapshotService, - secretService, secretVersionDAL, - secretQueueService + secretQueueService, + projectBotService }: TSecretApprovalRequestServiceFactoryDep) => { const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); @@ -297,11 +299,12 @@ export const secretApprovalRequestServiceFactory = ({ const secretApprovalSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); if (!secretApprovalSecrets) throw new BadRequestError({ message: "No secrets found" }); - const conflicts: Array<{ secretId: string; op: CommitType }> = []; - let secretCreationCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Create); + const conflicts: Array<{ secretId: string; op: SecretOperations }> = []; + let secretCreationCommits = secretApprovalSecrets.filter(({ op }) => op === SecretOperations.Create); if (secretCreationCommits.length) { - const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await secretService.fnSecretBlindIndexCheckV2({ + const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await fnSecretBlindIndexCheckV2({ folderId, + secretDAL, inputSecrets: secretCreationCommits.map(({ secretBlindIndex }) => { if (!secretBlindIndex) { throw new BadRequestError({ @@ -314,17 +317,19 @@ export const secretApprovalRequestServiceFactory = ({ secretCreationCommits .filter(({ secretBlindIndex }) => conflictGroupByBlindIndex[secretBlindIndex || ""]) .forEach((el) => { - conflicts.push({ op: CommitType.Create, secretId: el.id }); + conflicts.push({ op: SecretOperations.Create, secretId: el.id }); }); secretCreationCommits = secretCreationCommits.filter( ({ secretBlindIndex }) => !conflictGroupByBlindIndex[secretBlindIndex || ""] ); } - let secretUpdationCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Update); + let secretUpdationCommits = secretApprovalSecrets.filter(({ op }) => op === SecretOperations.Update); if (secretUpdationCommits.length) { - const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await secretService.fnSecretBlindIndexCheckV2({ + const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await fnSecretBlindIndexCheckV2({ folderId, + secretDAL, + userId: "", inputSecrets: secretUpdationCommits .filter(({ secretBlindIndex, secret }) => secret && secret.secretBlindIndex !== secretBlindIndex) .map(({ secretBlindIndex }) => { @@ -342,7 +347,7 @@ export const secretApprovalRequestServiceFactory = ({ (secretBlindIndex && conflictGroupByBlindIndex[secretBlindIndex]) || !secretId ) .forEach((el) => { - conflicts.push({ op: CommitType.Update, secretId: el.id }); + conflicts.push({ op: SecretOperations.Update, secretId: el.id }); }); secretUpdationCommits = secretUpdationCommits.filter( @@ -351,11 +356,11 @@ export const secretApprovalRequestServiceFactory = ({ ); } - const secretDeletionCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Delete); - + const secretDeletionCommits = secretApprovalSecrets.filter(({ op }) => op === SecretOperations.Delete); + const botKey = await projectBotService.getBotKey(projectId).catch(() => null); const mergeStatus = await secretApprovalRequestDAL.transaction(async (tx) => { const newSecrets = secretCreationCommits.length - ? await secretService.fnSecretBulkInsert({ + ? await fnSecretBulkInsert({ tx, folderId, inputSecrets: secretCreationCommits.map((el) => ({ @@ -379,7 +384,17 @@ export const secretApprovalRequestServiceFactory = ({ ]), tags: el?.tags.map(({ id }) => id), version: 1, - type: SecretType.Shared + type: SecretType.Shared, + references: botKey + ? getAllNestedSecretReferences( + decryptSymmetric128BitHexKeyUTF8({ + ciphertext: el.secretValueCiphertext, + iv: el.secretValueIV, + tag: el.secretValueTag, + key: botKey + }) + ) + : undefined })), secretDAL, secretVersionDAL, @@ -388,7 +403,7 @@ export const secretApprovalRequestServiceFactory = ({ }) : []; const updatedSecrets = secretUpdationCommits.length - ? await secretService.fnSecretBulkUpdate({ + ? await fnSecretBulkUpdate({ folderId, projectId, tx, @@ -414,7 +429,17 @@ export const secretApprovalRequestServiceFactory = ({ "secretReminderNote", "secretReminderRepeatDays", "secretBlindIndex" - ]) + ]), + references: botKey + ? getAllNestedSecretReferences( + decryptSymmetric128BitHexKeyUTF8({ + ciphertext: el.secretValueCiphertext, + iv: el.secretValueIV, + tag: el.secretValueTag, + key: botKey + }) + ) + : undefined } })), secretDAL, @@ -424,11 +449,13 @@ export const secretApprovalRequestServiceFactory = ({ }) : []; const deletedSecret = secretDeletionCommits.length - ? await secretService.fnSecretBulkDelete({ + ? await fnSecretBulkDelete({ projectId, folderId, tx, actorId: "", + secretDAL, + secretQueueService, inputSecrets: secretDeletionCommits.map(({ secretBlindIndex }) => { if (!secretBlindIndex) { throw new BadRequestError({ @@ -455,12 +482,14 @@ export const secretApprovalRequestServiceFactory = ({ }; }); await snapshotService.performSnapshot(folderId); - const folder = await folderDAL.findById(folderId); - // TODO(akhilmhdh-pg): change query to do secret path from folder + const [folder] = await folderDAL.findSecretPathByFolderIds(projectId, [folderId]); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); await secretQueueService.syncSecrets({ projectId, - secretPath: "/", - environment: folder?.environment.envSlug as string + secretPath: folder.path, + environmentSlug: folder.environmentSlug, + actorId, + actor }); return mergeStatus; }; @@ -508,9 +537,9 @@ export const secretApprovalRequestServiceFactory = ({ const commits: Omit[] = []; const commitTagIds: Record = {}; // for created secret approval change - const createdSecrets = data[CommitType.Create]; + const createdSecrets = data[SecretOperations.Create]; if (createdSecrets && createdSecrets?.length) { - const { keyName2BlindIndex } = await secretService.fnSecretBlindIndexCheck({ + const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({ inputSecrets: createdSecrets, folderId, isNew: true, @@ -521,7 +550,7 @@ export const secretApprovalRequestServiceFactory = ({ commits.push( ...createdSecrets.map(({ secretName, ...el }) => ({ ...el, - op: CommitType.Create as const, + op: SecretOperations.Create as const, version: 1, secretBlindIndex: keyName2BlindIndex[secretName], algorithm: SecretEncryptionAlgo.AES_256_GCM, @@ -533,12 +562,12 @@ export const secretApprovalRequestServiceFactory = ({ }); } // not secret approval for update operations - const updatedSecrets = data[CommitType.Update]; + const updatedSecrets = data[SecretOperations.Update]; if (updatedSecrets && updatedSecrets?.length) { // get all blind index // Find all those secrets // if not throw not found - const { keyName2BlindIndex, secrets: secretsToBeUpdated } = await secretService.fnSecretBlindIndexCheck({ + const { keyName2BlindIndex, secrets: secretsToBeUpdated } = await fnSecretBlindIndexCheck({ inputSecrets: updatedSecrets, folderId, isNew: false, @@ -549,8 +578,8 @@ export const secretApprovalRequestServiceFactory = ({ // now find any secret that needs to update its name // same process as above const nameUpdatedSecrets = updatedSecrets.filter(({ newSecretName }) => Boolean(newSecretName)); - const { keyName2BlindIndex: newKeyName2BlindIndex } = await secretService.fnSecretBlindIndexCheck({ - inputSecrets: nameUpdatedSecrets, + const { keyName2BlindIndex: newKeyName2BlindIndex } = await fnSecretBlindIndexCheck({ + inputSecrets: nameUpdatedSecrets.map(({ newSecretName }) => ({ secretName: newSecretName as string })), folderId, isNew: true, blindIndexCfg, @@ -567,14 +596,14 @@ export const secretApprovalRequestServiceFactory = ({ const secretId = secsGroupedByBlindIndex[keyName2BlindIndex[secretName]][0].id; const secretBlindIndex = newSecretName && newKeyName2BlindIndex[newSecretName] - ? newKeyName2BlindIndex?.[secretName] + ? newKeyName2BlindIndex?.[newSecretName] : keyName2BlindIndex[secretName]; // add tags if (tagIds?.length) commitTagIds[keyName2BlindIndex[secretName]] = tagIds; return { ...latestSecretVersions[secretId], ...el, - op: CommitType.Update as const, + op: SecretOperations.Update as const, secret: secretId, secretVersion: latestSecretVersions[secretId].id, secretBlindIndex, @@ -584,12 +613,12 @@ export const secretApprovalRequestServiceFactory = ({ ); } // deleted secrets - const deletedSecrets = data[CommitType.Delete]; + const deletedSecrets = data[SecretOperations.Delete]; if (deletedSecrets && deletedSecrets.length) { // get all blind index // Find all those secrets // if not throw not found - const { keyName2BlindIndex, secrets } = await secretService.fnSecretBlindIndexCheck({ + const { keyName2BlindIndex, secrets } = await fnSecretBlindIndexCheck({ inputSecrets: deletedSecrets, folderId, isNew: false, @@ -610,7 +639,7 @@ export const secretApprovalRequestServiceFactory = ({ if (!latestSecretVersions[secretId].secretBlindIndex) throw new BadRequestError({ message: "Failed to find secret blind index" }); return { - op: CommitType.Delete as const, + op: SecretOperations.Delete as const, ...latestSecretVersions[secretId], secretBlindIndex: latestSecretVersions[secretId].secretBlindIndex as string, secret: secretId, diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts index 008b977e6..1fbb75418 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts @@ -1,11 +1,6 @@ import { TImmutableDBKeys, TSecretApprovalPolicies, TSecretApprovalRequestsSecrets } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; - -export enum CommitType { - Create = "create", - Update = "update", - Delete = "delete" -} +import { SecretOperations } from "@app/services/secret/secret-types"; export enum RequestState { Open = "open", @@ -18,14 +13,14 @@ export enum ApprovalStatus { REJECTED = "rejected" } -type TApprovalCreateSecret = Omit< +export type TApprovalCreateSecret = Omit< TSecretApprovalRequestsSecrets, TImmutableDBKeys | "version" | "algorithm" | "keyEncoding" | "requestId" | "op" | "secretVersion" | "secretBlindIndex" > & { secretName: string; tagIds?: string[]; }; -type TApprovalUpdateSecret = Partial & { +export type TApprovalUpdateSecret = Partial & { secretName: string; newSecretName?: string; tagIds?: string[]; @@ -36,9 +31,9 @@ export type TGenerateSecretApprovalRequestDTO = { secretPath: string; policy: TSecretApprovalPolicies; data: { - [CommitType.Create]?: TApprovalCreateSecret[]; - [CommitType.Update]?: TApprovalUpdateSecret[]; - [CommitType.Delete]?: { secretName: string }[]; + [SecretOperations.Create]?: TApprovalCreateSecret[]; + [SecretOperations.Update]?: TApprovalUpdateSecret[]; + [SecretOperations.Delete]?: { secretName: string }[]; }; } & TProjectPermission; diff --git a/backend/src/ee/services/secret-replication/secret-replication-constants.ts b/backend/src/ee/services/secret-replication/secret-replication-constants.ts new file mode 100644 index 000000000..88c9ee166 --- /dev/null +++ b/backend/src/ee/services/secret-replication/secret-replication-constants.ts @@ -0,0 +1 @@ +export const MAX_REPLICATION_DEPTH = 5; diff --git a/backend/src/ee/services/secret-replication/secret-replication-dal.ts b/backend/src/ee/services/secret-replication/secret-replication-dal.ts new file mode 100644 index 000000000..3c4c021fd --- /dev/null +++ b/backend/src/ee/services/secret-replication/secret-replication-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSecretReplicationDALFactory = ReturnType; + +export const secretReplicationDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.SecretVersion); + return orm; +}; diff --git a/backend/src/ee/services/secret-replication/secret-replication-service.ts b/backend/src/ee/services/secret-replication/secret-replication-service.ts new file mode 100644 index 000000000..fd2f7cc1a --- /dev/null +++ b/backend/src/ee/services/secret-replication/secret-replication-service.ts @@ -0,0 +1,485 @@ +import { SecretType, TSecrets } from "@app/db/schemas"; +import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; +import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; +import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; +import { groupBy, unique } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { QueueName, TQueueServiceFactory } from "@app/queue"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { TSecretDALFactory } from "@app/services/secret/secret-dal"; +import { fnSecretBulkInsert, fnSecretBulkUpdate } from "@app/services/secret/secret-fns"; +import { TSecretQueueFactory, uniqueSecretQueueKey } from "@app/services/secret/secret-queue"; +import { SecretOperations } from "@app/services/secret/secret-types"; +import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; +import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { ReservedFolders } from "@app/services/secret-folder/secret-folder-types"; +import { TSecretImportDALFactory } from "@app/services/secret-import/secret-import-dal"; +import { fnSecretsFromImports } from "@app/services/secret-import/secret-import-fns"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; + +import { MAX_REPLICATION_DEPTH } from "./secret-replication-constants"; + +type TSecretReplicationServiceFactoryDep = { + secretDAL: Pick< + TSecretDALFactory, + "find" | "findByBlindIndexes" | "insertMany" | "bulkUpdate" | "delete" | "upsertSecretReferences" | "transaction" + >; + secretVersionDAL: Pick; + secretImportDAL: Pick; + folderDAL: Pick< + TSecretFolderDALFactory, + "findSecretPathByFolderIds" | "findBySecretPath" | "create" | "findOne" | "findByManySecretPath" + >; + secretVersionTagDAL: Pick; + secretQueueService: Pick; + queueService: Pick; + secretApprovalPolicyService: Pick; + keyStore: Pick; + secretBlindIndexDAL: Pick; + secretTagDAL: Pick; + secretApprovalRequestDAL: Pick; + projectMembershipDAL: Pick; + secretApprovalRequestSecretDAL: Pick< + TSecretApprovalRequestSecretDALFactory, + "insertMany" | "insertApprovalSecretTags" + >; + projectBotService: Pick; +}; + +export type TSecretReplicationServiceFactory = ReturnType; +const SECRET_IMPORT_SUCCESS_LOCK = 10; + +const keystoreReplicationSuccessKey = (jobId: string, secretImportId: string) => `${jobId}-${secretImportId}`; +const getReplicationKeyLockPrefix = (projectId: string, environmentSlug: string, secretPath: string) => + `REPLICATION_SECRET_${projectId}-${environmentSlug}-${secretPath}`; +export const getReplicationFolderName = (importId: string) => `${ReservedFolders.SecretReplication}${importId}`; + +const getDecryptedKeyValue = (key: string, secret: TSecrets) => { + const secretKey = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key + }); + + const secretValue = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key + }); + return { key: secretKey, value: secretValue }; +}; + +export const secretReplicationServiceFactory = ({ + secretDAL, + queueService, + secretVersionDAL, + secretImportDAL, + keyStore, + secretVersionTagDAL, + secretTagDAL, + folderDAL, + secretApprovalPolicyService, + secretApprovalRequestSecretDAL, + secretApprovalRequestDAL, + secretQueueService, + projectMembershipDAL, + projectBotService +}: TSecretReplicationServiceFactoryDep) => { + const getReplicatedSecrets = ( + botKey: string, + localSecrets: TSecrets[], + importedSecrets: { secrets: TSecrets[] }[] + ) => { + const deDupe = new Set(); + const secrets = localSecrets + .filter(({ secretBlindIndex }) => Boolean(secretBlindIndex)) + .map((el) => { + const decryptedSecret = getDecryptedKeyValue(botKey, el); + deDupe.add(decryptedSecret.key); + return { ...el, secretKey: decryptedSecret.key, secretValue: decryptedSecret.value }; + }); + + for (let i = importedSecrets.length - 1; i >= 0; i = -1) { + importedSecrets[i].secrets.forEach((el) => { + const decryptedSecret = getDecryptedKeyValue(botKey, el); + if (deDupe.has(decryptedSecret.key) || !el.secretBlindIndex) { + return; + } + deDupe.add(decryptedSecret.key); + secrets.push({ ...el, secretKey: decryptedSecret.key, secretValue: decryptedSecret.value }); + }); + } + return secrets; + }; + + // IMPORTANT NOTE BEFORE READING THE FUNCTION + // SOURCE - Where secrets are copied from + // DESTINATION - Where the replicated imports that points to SOURCE from Destination + queueService.start(QueueName.SecretReplication, async (job) => { + logger.info(job.data, "Replication started"); + const { + secretPath, + environmentSlug, + projectId, + actorId, + actor, + pickOnlyImportIds, + _deDupeReplicationQueue: deDupeReplicationQueue, + _deDupeQueue: deDupeQueue, + _depth: depth = 0 + } = job.data; + if (depth > MAX_REPLICATION_DEPTH) return; + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, secretPath); + if (!folder) return; + + // the the replicated imports made to the source. These are the destinations + const destinationSecretImports = await secretImportDAL.find({ + importPath: secretPath, + importEnv: folder.envId + }); + + // CASE: normal mode <- link import <- replicated import + const nonReplicatedDestinationImports = destinationSecretImports.filter(({ isReplication }) => !isReplication); + if (nonReplicatedDestinationImports.length) { + // keep calling sync secret for all the imports made + const importedFolderIds = unique(nonReplicatedDestinationImports, (i) => i.folderId).map( + ({ folderId }) => folderId + ); + const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds); + const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string); + await Promise.all( + nonReplicatedDestinationImports + .filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string)) + // filter out already synced ones + .filter( + ({ folderId }) => + !deDupeQueue?.[ + uniqueSecretQueueKey( + foldersGroupedById[folderId][0]?.environmentSlug as string, + foldersGroupedById[folderId][0]?.path as string + ) + ] + ) + .map(({ folderId }) => + secretQueueService.replicateSecrets({ + projectId, + secretPath: foldersGroupedById[folderId][0]?.path as string, + environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, + actorId, + actor, + _depth: depth + 1, + _deDupeReplicationQueue: deDupeReplicationQueue, + _deDupeQueue: deDupeQueue + }) + ) + ); + } + + let destinationReplicatedSecretImports = destinationSecretImports.filter(({ isReplication }) => + Boolean(isReplication) + ); + destinationReplicatedSecretImports = pickOnlyImportIds + ? destinationReplicatedSecretImports.filter(({ id }) => pickOnlyImportIds?.includes(id)) + : destinationReplicatedSecretImports; + if (!destinationReplicatedSecretImports.length) return; + + const botKey = await projectBotService.getBotKey(projectId); + + // these are the secrets to be added in replicated folders + const sourceLocalSecrets = await secretDAL.find({ folderId: folder.id, type: SecretType.Shared }); + const sourceSecretImports = await secretImportDAL.find({ folderId: folder.id }); + const sourceImportedSecrets = await fnSecretsFromImports({ + allowedImports: sourceSecretImports, + secretDAL, + folderDAL, + secretImportDAL + }); + // secrets that gets replicated across imports + const sourceSecrets = getReplicatedSecrets(botKey, sourceLocalSecrets, sourceImportedSecrets); + const sourceSecretsGroupByBlindIndex = groupBy(sourceSecrets, (i) => i.secretBlindIndex as string); + + const lock = await keyStore.acquireLock( + [getReplicationKeyLockPrefix(projectId, environmentSlug, secretPath)], + 5000 + ); + + try { + /* eslint-disable no-await-in-loop */ + for (const destinationSecretImport of destinationReplicatedSecretImports) { + try { + const hasJobCompleted = await keyStore.getItem( + keystoreReplicationSuccessKey(job.id as string, destinationSecretImport.id), + KeyStorePrefixes.SecretReplication + ); + if (hasJobCompleted) { + logger.info( + { jobId: job.id, importId: destinationSecretImport.id }, + "Skipping this job as this has been successfully replicated." + ); + // eslint-disable-next-line + continue; + } + + const [destinationFolder] = await folderDAL.findSecretPathByFolderIds(projectId, [ + destinationSecretImport.folderId + ]); + if (!destinationFolder) throw new BadRequestError({ message: "Imported folder not found" }); + + let destinationReplicationFolder = await folderDAL.findOne({ + parentId: destinationFolder.id, + name: getReplicationFolderName(destinationSecretImport.id), + isReserved: true + }); + if (!destinationReplicationFolder) { + destinationReplicationFolder = await folderDAL.create({ + parentId: destinationFolder.id, + name: getReplicationFolderName(destinationSecretImport.id), + envId: destinationFolder.envId, + isReserved: true + }); + } + const destinationReplicationFolderId = destinationReplicationFolder.id; + + const destinationLocalSecretsFromDB = await secretDAL.find({ + folderId: destinationReplicationFolderId + }); + const destinationLocalSecrets = destinationLocalSecretsFromDB.map((el) => { + const decryptedSecret = getDecryptedKeyValue(botKey, el); + return { ...el, secretKey: decryptedSecret.key, secretValue: decryptedSecret.value }; + }); + + const destinationLocalSecretsGroupedByBlindIndex = groupBy( + destinationLocalSecrets.filter(({ secretBlindIndex }) => Boolean(secretBlindIndex)), + (i) => i.secretBlindIndex as string + ); + + const locallyCreatedSecrets = sourceSecrets + .filter( + ({ secretBlindIndex }) => !destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0] + ) + .map((el) => ({ ...el, operation: SecretOperations.Create })); // rewrite update ops to create + + const locallyUpdatedSecrets = sourceSecrets + .filter( + ({ secretBlindIndex, secretKey, secretValue }) => + destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0] && + // if key or value changed + (destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretKey !== secretKey || + destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretValue !== + secretValue) + ) + .map((el) => ({ ...el, operation: SecretOperations.Update })); // rewrite update ops to create + + const locallyDeletedSecrets = destinationLocalSecrets + .filter(({ secretBlindIndex }) => !sourceSecretsGroupByBlindIndex[secretBlindIndex as string]?.[0]) + .map((el) => ({ ...el, operation: SecretOperations.Delete })); + + const isEmtpy = + locallyCreatedSecrets.length + locallyUpdatedSecrets.length + locallyDeletedSecrets.length === 0; + // eslint-disable-next-line + if (isEmtpy) continue; + + const policy = await secretApprovalPolicyService.getSecretApprovalPolicy( + projectId, + destinationFolder.environmentSlug, + destinationFolder.path + ); + // this means it should be a approval request rather than direct replication + if (policy && actor === ActorType.USER) { + const membership = await projectMembershipDAL.findOne({ projectId, userId: actorId }); + if (!membership) { + logger.error("Project membership not found in %s for user %s", projectId, actorId); + return; + } + + const localSecretsLatestVersions = destinationLocalSecrets.map(({ id }) => id); + const latestSecretVersions = await secretVersionDAL.findLatestVersionMany( + destinationReplicationFolderId, + localSecretsLatestVersions + ); + await secretApprovalRequestDAL.transaction(async (tx) => { + const approvalRequestDoc = await secretApprovalRequestDAL.create( + { + folderId: destinationReplicationFolderId, + slug: alphaNumericNanoId(), + policyId: policy.id, + status: "open", + hasMerged: false, + committerId: membership.id, + isReplicated: true + }, + tx + ); + const commits = locallyCreatedSecrets + .concat(locallyUpdatedSecrets) + .concat(locallyDeletedSecrets) + .map((doc) => { + const { operation } = doc; + const localSecret = destinationLocalSecretsGroupedByBlindIndex[doc.secretBlindIndex as string]?.[0]; + + return { + op: operation, + keyEncoding: doc.keyEncoding, + algorithm: doc.algorithm, + requestId: approvalRequestDoc.id, + metadata: doc.metadata, + secretKeyIV: doc.secretKeyIV, + secretKeyTag: doc.secretKeyTag, + secretKeyCiphertext: doc.secretKeyCiphertext, + secretValueIV: doc.secretValueIV, + secretValueTag: doc.secretValueTag, + secretValueCiphertext: doc.secretValueCiphertext, + secretBlindIndex: doc.secretBlindIndex, + secretCommentIV: doc.secretCommentIV, + secretCommentTag: doc.secretCommentTag, + secretCommentCiphertext: doc.secretCommentCiphertext, + skipMultilineEncoding: doc.skipMultilineEncoding, + // except create operation other two needs the secret id and version id + ...(operation !== SecretOperations.Create + ? { secretId: localSecret.id, secretVersion: latestSecretVersions[localSecret.id].id } + : {}) + }; + }); + const approvalCommits = await secretApprovalRequestSecretDAL.insertMany(commits, tx); + + return { ...approvalRequestDoc, commits: approvalCommits }; + }); + } else { + await secretDAL.transaction(async (tx) => { + if (locallyCreatedSecrets.length) { + await fnSecretBulkInsert({ + folderId: destinationReplicationFolderId, + secretVersionDAL, + secretDAL, + tx, + secretTagDAL, + secretVersionTagDAL, + inputSecrets: locallyCreatedSecrets.map((doc) => { + return { + keyEncoding: doc.keyEncoding, + algorithm: doc.algorithm, + type: doc.type, + metadata: doc.metadata, + secretKeyIV: doc.secretKeyIV, + secretKeyTag: doc.secretKeyTag, + secretKeyCiphertext: doc.secretKeyCiphertext, + secretValueIV: doc.secretValueIV, + secretValueTag: doc.secretValueTag, + secretValueCiphertext: doc.secretValueCiphertext, + secretBlindIndex: doc.secretBlindIndex, + secretCommentIV: doc.secretCommentIV, + secretCommentTag: doc.secretCommentTag, + secretCommentCiphertext: doc.secretCommentCiphertext, + skipMultilineEncoding: doc.skipMultilineEncoding + }; + }) + }); + } + if (locallyUpdatedSecrets.length) { + await fnSecretBulkUpdate({ + projectId, + folderId: destinationReplicationFolderId, + secretVersionDAL, + secretDAL, + tx, + secretTagDAL, + secretVersionTagDAL, + inputSecrets: locallyUpdatedSecrets.map((doc) => { + return { + filter: { + folderId: destinationReplicationFolderId, + id: destinationLocalSecretsGroupedByBlindIndex[doc.secretBlindIndex as string][0].id + }, + data: { + keyEncoding: doc.keyEncoding, + algorithm: doc.algorithm, + type: doc.type, + metadata: doc.metadata, + secretKeyIV: doc.secretKeyIV, + secretKeyTag: doc.secretKeyTag, + secretKeyCiphertext: doc.secretKeyCiphertext, + secretValueIV: doc.secretValueIV, + secretValueTag: doc.secretValueTag, + secretValueCiphertext: doc.secretValueCiphertext, + secretBlindIndex: doc.secretBlindIndex, + secretCommentIV: doc.secretCommentIV, + secretCommentTag: doc.secretCommentTag, + secretCommentCiphertext: doc.secretCommentCiphertext, + skipMultilineEncoding: doc.skipMultilineEncoding + } + }; + }) + }); + } + if (locallyDeletedSecrets.length) { + await secretDAL.delete( + { + $in: { + id: locallyDeletedSecrets.map(({ id }) => id) + }, + folderId: destinationReplicationFolderId + }, + tx + ); + } + }); + + await secretQueueService.syncSecrets({ + projectId, + secretPath: destinationFolder.path, + environmentSlug: destinationFolder.environmentSlug, + actorId, + actor, + _depth: depth + 1, + _deDupeReplicationQueue: deDupeReplicationQueue, + _deDupeQueue: deDupeQueue + }); + } + + // this is used to avoid multiple times generating secret approval by failed one + await keyStore.setItemWithExpiry( + keystoreReplicationSuccessKey(job.id as string, destinationSecretImport.id), + SECRET_IMPORT_SUCCESS_LOCK, + 1, + KeyStorePrefixes.SecretReplication + ); + + await secretImportDAL.updateById(destinationSecretImport.id, { + lastReplicated: new Date(), + replicationStatus: null, + isReplicationSuccess: true + }); + } catch (err) { + logger.error( + err, + `Failed to replicate secret with import id=[${destinationSecretImport.id}] env=[${destinationSecretImport.importEnv.slug}] path=[${destinationSecretImport.importPath}]` + ); + await secretImportDAL.updateById(destinationSecretImport.id, { + lastReplicated: new Date(), + replicationStatus: (err as Error)?.message.slice(0, 500), + isReplicationSuccess: false + }); + } + } + /* eslint-enable no-await-in-loop */ + } finally { + await lock.release(); + logger.info(job.data, "Replication finished"); + } + }); + + queueService.listen(QueueName.SecretReplication, "failed", (job, err) => { + logger.error(err, "Failed to replicate secret", job?.data); + }); +}; diff --git a/backend/src/ee/services/secret-replication/secret-replication-types.ts b/backend/src/ee/services/secret-replication/secret-replication-types.ts new file mode 100644 index 000000000..1b32f1f4a --- /dev/null +++ b/backend/src/ee/services/secret-replication/secret-replication-types.ts @@ -0,0 +1,3 @@ +export type TSyncSecretReplicationDTO = { + id: string; +}; diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-service.ts b/backend/src/ee/services/secret-scanning/secret-scanning-service.ts index 9b78da3af..ef511deb8 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-service.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-service.ts @@ -90,15 +90,17 @@ export const secretScanningServiceFactory = ({ const { data: { repositories } } = await octokit.apps.listReposAccessibleToInstallation(); - await Promise.all( - repositories.map(({ id, full_name }) => - secretScanningQueue.startFullRepoScan({ - organizationId: session.orgId, - installationId, - repository: { id, fullName: full_name } - }) - ) - ); + if (!appCfg.DISABLE_SECRET_SCANNING) { + await Promise.all( + repositories.map(({ id, full_name }) => + secretScanningQueue.startFullRepoScan({ + organizationId: session.orgId, + installationId, + repository: { id, fullName: full_name } + }) + ) + ); + } return { installatedApp }; }; @@ -151,6 +153,7 @@ export const secretScanningServiceFactory = ({ }; const handleRepoPushEvent = async (payload: WebhookEventMap["push"]) => { + const appCfg = getConfig(); const { commits, repository, installation, pusher } = payload; if (!commits || !repository || !installation || !pusher) { return; @@ -161,13 +164,15 @@ export const secretScanningServiceFactory = ({ }); if (!installationLink) return; - await secretScanningQueue.startPushEventScan({ - commits, - pusher: { name: pusher.name, email: pusher.email }, - repository: { fullName: repository.full_name, id: repository.id }, - organizationId: installationLink.orgId, - installationId: String(installation?.id) - }); + if (!appCfg.DISABLE_SECRET_SCANNING) { + await secretScanningQueue.startPushEventScan({ + commits, + pusher: { name: pusher.name, email: pusher.email }, + repository: { fullName: repository.full_name, id: repository.id }, + organizationId: installationLink.orgId, + installationId: String(installation?.id) + }); + } }; const handleRepoDeleteEvent = async (installationId: string, repositoryIds: string[]) => { diff --git a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts index 0e71ad126..bd8750577 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -220,7 +220,7 @@ export const secretSnapshotServiceFactory = ({ const deletedTopLevelSecsGroupById = groupBy(deletedTopLevelSecs, (item) => item.id); // this will remove all secrets and folders on child // due to sql foreign key and link list connection removing the folders removes everything below too - const deletedFolders = await folderDAL.delete({ parentId: snapshot.folderId }, tx); + const deletedFolders = await folderDAL.delete({ parentId: snapshot.folderId, isReserved: false }, tx); const deletedTopLevelFolders = groupBy( deletedFolders.filter(({ parentId }) => parentId === snapshot.folderId), (item) => item.id diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 5e2c3aab3..ce752a1e5 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -1,20 +1,75 @@ import { Redis } from "ioredis"; +import { Redlock, Settings } from "@app/lib/red-lock"; + export type TKeyStoreFactory = ReturnType; +// all the key prefixes used must be set here to avoid conflict +export enum KeyStorePrefixes { + SecretReplication = "secret-replication-import-lock" +} + +type TWaitTillReady = { + key: string; + waitingCb?: () => void; + keyCheckCb: (val: string | null) => boolean; + waitIteration?: number; + delay?: number; + jitter?: number; +}; + export const keyStoreFactory = (redisUrl: string) => { const redis = new Redis(redisUrl); + const redisLock = new Redlock([redis], { retryCount: 2, retryDelay: 200 }); - const setItem = async (key: string, value: string | number | Buffer) => redis.set(key, value); + const setItem = async (key: string, value: string | number | Buffer, prefix?: string) => + redis.set(prefix ? `${prefix}:${key}` : key, value); - const getItem = async (key: string) => redis.get(key); + const getItem = async (key: string, prefix?: string) => redis.get(prefix ? `${prefix}:${key}` : key); - const setItemWithExpiry = async (key: string, exp: number | string, value: string | number | Buffer) => - redis.setex(key, exp, value); + const setItemWithExpiry = async ( + key: string, + exp: number | string, + value: string | number | Buffer, + prefix?: string + ) => redis.setex(prefix ? `${prefix}:${key}` : key, exp, value); const deleteItem = async (key: string) => redis.del(key); const incrementBy = async (key: string, value: number) => redis.incrby(key, value); - return { setItem, getItem, setItemWithExpiry, deleteItem, incrementBy }; + const waitTillReady = async ({ + key, + waitingCb, + keyCheckCb, + waitIteration = 10, + delay = 1000, + jitter = 200 + }: TWaitTillReady) => { + let attempts = 0; + let isReady = keyCheckCb(await getItem(key)); + while (!isReady) { + if (attempts > waitIteration) return; + // eslint-disable-next-line + await new Promise((resolve) => { + waitingCb?.(); + setTimeout(resolve, Math.max(0, delay + Math.floor((Math.random() * 2 - 1) * jitter))); + }); + attempts += 1; + // eslint-disable-next-line + isReady = keyCheckCb(await getItem(key, "wait_till_ready")); + } + }; + + return { + setItem, + getItem, + setItemWithExpiry, + deleteItem, + incrementBy, + acquireLock(resources: string[], duration: number, settings?: Partial) { + return redisLock.acquire(resources, duration, settings); + }, + waitTillReady + }; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 04b7509ca..da82016f1 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -89,6 +89,21 @@ export const UNIVERSAL_AUTH = { }, RENEW_ACCESS_TOKEN: { accessToken: "The access token to renew." + }, + REVOKE_ACCESS_TOKEN: { + accessToken: "The access token to revoke." + } +} as const; + +export const AWS_AUTH = { + LOGIN: { + identityId: "The ID of the identity to login.", + iamHttpRequestMethod: "The HTTP request method used in the signed request.", + iamRequestUrl: + "The base64-encoded HTTP URL used in the signed request. Most likely, the base64-encoding of https://sts.amazonaws.com/", + iamRequestBody: + "The base64-encoded body of the signed request. Most likely, the base64-encoding of Action=GetCallerIdentity&Version=2011-06-15.", + iamRequestHeaders: "The base64-encoded headers of the sts:GetCallerIdentity signed request." } } as const; @@ -133,36 +148,6 @@ export const PROJECTS = { name: "The new name of the project.", autoCapitalization: "Disable or enable auto-capitalization for the project." }, - INVITE_MEMBER: { - projectId: "The ID of the project to invite the member to.", - emails: "A list of organization member emails to invite to the project.", - usernames: "A list of usernames to invite to the project." - }, - REMOVE_MEMBER: { - projectId: "The ID of the project to remove the member from.", - emails: "A list of organization member emails to remove from the project.", - usernames: "A list of usernames to remove from the project." - }, - GET_USER_MEMBERSHIPS: { - workspaceId: "The ID of the project to get memberships from." - }, - UPDATE_USER_MEMBERSHIP: { - workspaceId: "The ID of the project to update the membership for.", - membershipId: "The ID of the membership to update.", - roles: "A list of roles to update the membership to." - }, - LIST_IDENTITY_MEMBERSHIPS: { - projectId: "The ID of the project to get identity memberships from." - }, - UPDATE_IDENTITY_MEMBERSHIP: { - projectId: "The ID of the project to update the identity membership for.", - identityId: "The ID of the identity to update the membership for.", - roles: "A list of roles to update the membership to." - }, - DELETE_IDENTITY_MEMBERSHIP: { - projectId: "The ID of the project to delete the identity membership from.", - identityId: "The ID of the identity to delete the membership from." - }, GET_KEY: { workspaceId: "The ID of the project to get the key from." }, @@ -201,6 +186,72 @@ export const PROJECTS = { } } as const; +export const PROJECT_USERS = { + INVITE_MEMBER: { + projectId: "The ID of the project to invite the member to.", + emails: "A list of organization member emails to invite to the project.", + usernames: "A list of usernames to invite to the project." + }, + REMOVE_MEMBER: { + projectId: "The ID of the project to remove the member from.", + emails: "A list of organization member emails to remove from the project.", + usernames: "A list of usernames to remove from the project." + }, + GET_USER_MEMBERSHIPS: { + workspaceId: "The ID of the project to get memberships from." + }, + GET_USER_MEMBERSHIP: { + workspaceId: "The ID of the project to get memberships from.", + username: "The username to get project membership of. Email is the default username." + }, + UPDATE_USER_MEMBERSHIP: { + workspaceId: "The ID of the project to update the membership for.", + membershipId: "The ID of the membership to update.", + roles: "A list of roles to update the membership to." + } +}; + +export const PROJECT_IDENTITIES = { + LIST_IDENTITY_MEMBERSHIPS: { + projectId: "The ID of the project to get identity memberships from." + }, + GET_IDENTITY_MEMBERSHIP_BY_ID: { + identityId: "The ID of the identity to get the membership for.", + projectId: "The ID of the project to get the identity membership for." + }, + UPDATE_IDENTITY_MEMBERSHIP: { + projectId: "The ID of the project to update the identity membership for.", + identityId: "The ID of the identity to update the membership for.", + roles: { + description: "A list of role slugs to assign to the identity project membership.", + role: "The role slug to assign to the newly created identity project membership.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s,2m,3h", + temporaryAccessStartTime: "Time to which the temporary access starts" + } + }, + DELETE_IDENTITY_MEMBERSHIP: { + projectId: "The ID of the project to delete the identity membership from.", + identityId: "The ID of the identity to delete the membership from." + }, + CREATE_IDENTITY_MEMBERSHIP: { + projectId: "The ID of the project to create the identity membership from.", + identityId: "The ID of the identity to create the membership from.", + role: "The role slug to assign to the newly created identity project membership.", + roles: { + description: "A list of role slugs to assign to the newly created identity project membership.", + role: "The role slug to assign to the newly created identity project membership.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s,2m,3h", + temporaryAccessStartTime: "Time to which the temporary access starts" + } + } +}; + export const ENVIRONMENTS = { CREATE: { workspaceId: "The ID of the project to create the environment in.", @@ -240,6 +291,7 @@ export const FOLDERS = { name: "The new name of the folder.", path: "The path of the folder to update.", directory: "The new directory of the folder to update. (Deprecated in favor of path)", + projectSlug: "The slug of the project where the folder is located.", workspaceId: "The ID of the project where the folder is located." }, DELETE: { @@ -272,10 +324,12 @@ export const SECRETS = { export const RAW_SECRETS = { LIST: { + expand: "Whether or not to expand secret references", recursive: "Whether or not to fetch all secrets from the specified base path, and all of its subdirectories. Note, the max depth is 20 deep.", workspaceId: "The ID of the project to list secrets from.", - workspaceSlug: "The slug of the project to list secrets from. This parameter is only usable by machine identities.", + workspaceSlug: + "The slug of the project to list secrets from. This parameter is only applicable by machine identities.", environment: "The slug of the environment to list secrets from.", secretPath: "The secret path to list secrets from.", includeImports: "Weather to include imported secrets or not." @@ -294,6 +348,7 @@ export const RAW_SECRETS = { GET: { secretName: "The name of the secret to get.", workspaceId: "The ID of the project to get the secret from.", + workspaceSlug: "The slug of the project to get the secret from.", environment: "The slug of the environment to get the secret from.", secretPath: "The path of the secret to get.", version: "The version of the secret to get.", @@ -464,13 +519,24 @@ export const SECRET_TAGS = { export const IDENTITY_ADDITIONAL_PRIVILEGE = { CREATE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to delete.", + identityId: "The ID of the identity to create.", slug: "The slug of the privilege to create.", - permissions: `The permission object for the privilege. -1. [["read", "secrets", {environment: "dev", secretPath: {$glob: "/"}}]] -2. [["read", "secrets", {environment: "dev"}], ["create", "secrets", {environment: "dev"}]] -2. [["read", "secrets", {environment: "dev"}]] + permissions: `@deprecated - use privilegePermission +The permission object for the privilege. +- Read secrets +\`\`\` +{ "permissions": [{"action": "read", "subject": "secrets"]} +\`\`\` +- Read and Write secrets +\`\`\` +{ "permissions": [{"action": "read", "subject": "secrets"], {"action": "write", "subject": "secrets"]} +\`\`\` +- Read secrets scoped to an environment and secret path +\`\`\` +- { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] } +\`\`\` `, + privilegePermission: "The permission object for the privilege.", isPackPermission: "Whether the server should pack(compact) the permission object.", isTemporary: "Whether the privilege is temporary.", temporaryMode: "Type of temporary access given. Types: relative", @@ -482,12 +548,22 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { identityId: "The ID of the identity to update.", slug: "The slug of the privilege to update.", newSlug: "The new slug of the privilege to update.", - permissions: `The permission object for the privilege. -1. [["read", "secrets", {environment: "dev", secretPath: {$glob: "/"}}]] -2. [["read", "secrets", {environment: "dev"}], ["create", "secrets", {environment: "dev"}]] -2. [["read", "secrets", {environment: "dev"}]] + permissions: `@deprecated - use privilegePermission +The permission object for the privilege. +- Read secrets +\`\`\` +{ "permissions": [{"action": "read", "subject": "secrets"]} +\`\`\` +- Read and Write secrets +\`\`\` +{ "permissions": [{"action": "read", "subject": "secrets"], {"action": "write", "subject": "secrets"]} +\`\`\` +- Read secrets scoped to an environment and secret path +\`\`\` +- { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] } +\`\`\` `, - isPackPermission: "Whether the server should pack(compact) the permission object.", + privilegePermission: "The permission object for the privilege.", isTemporary: "Whether the privilege is temporary.", temporaryMode: "Type of temporary access given. Types: relative", temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", @@ -585,6 +661,7 @@ export const INTEGRATION = { targetServiceId: "The service based grouping identifier ID of the external provider. Used in Terraform cloud, Checkly, Railway and NorthFlank", owner: "External integration providers service entity owner. Used in Github.", + url: "The self-hosted URL of the platform to integrate with", path: "Path to save the synced secrets. Used by Gitlab, AWS Parameter Store, Vault", region: "AWS region to sync secrets to.", scope: "Scope of the provider. Used by Github, Qovery", @@ -592,10 +669,12 @@ export const INTEGRATION = { secretPrefix: "The prefix for the saved secret. Used by GCP.", secretSuffix: "The suffix for the saved secret. Used by GCP.", initialSyncBehavoir: "Type of syncing behavoir with the integration.", + mappingBehavior: "The mapping behavior of the integration.", shouldAutoRedeploy: "Used by Render to trigger auto deploy.", secretGCPLabel: "The label for GCP secrets.", secretAWSTag: "The tags for AWS secrets.", - kmsKeyId: "The ID of the encryption key from AWS KMS." + kmsKeyId: "The ID of the encryption key from AWS KMS.", + shouldDisableDelete: "The flag to disable deletion of secrets in AWS Parameter Store." } }, UPDATE: { @@ -612,5 +691,63 @@ export const INTEGRATION = { }, DELETE: { integrationId: "The ID of the integration object." + }, + SYNC: { + integrationId: "The ID of the integration object to manually sync" + } +}; + +export const AUDIT_LOG_STREAMS = { + CREATE: { + url: "The HTTP URL to push logs to.", + headers: { + desc: "The HTTP headers attached for the external prrovider requests.", + key: "The HTTP header key name.", + value: "The HTTP header value." + } + }, + UPDATE: { + id: "The ID of the audit log stream to update.", + url: "The HTTP URL to push logs to.", + headers: { + desc: "The HTTP headers attached for the external prrovider requests.", + key: "The HTTP header key name.", + value: "The HTTP header value." + } + }, + DELETE: { + id: "The ID of the audit log stream to delete." + }, + GET_BY_ID: { + id: "The ID of the audit log stream to get details." + } +}; + +export const PROJECT_ROLE = { + CREATE: { + projectSlug: "Slug of the project to create the role for.", + slug: "The slug of the role.", + name: "The name of the role.", + description: "The description for the role.", + permissions: "The permissions assigned to the role." + }, + UPDATE: { + projectSlug: "Slug of the project to update the role for.", + roleId: "The ID of the role to update", + slug: "The slug of the role.", + name: "The name of the role.", + description: "The description for the role.", + permissions: "The permissions assigned to the role." + }, + DELETE: { + projectSlug: "Slug of the project to delete this role for.", + roleId: "The ID of the role to update" + }, + GET_ROLE_BY_SLUG: { + projectSlug: "The slug of the project.", + roleSlug: "The slug of the role to get details" + }, + LIST: { + projectSlug: "The slug of the project to list the roles of." } }; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 4d3d55ffd..2caae9ec5 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -13,6 +13,10 @@ const zodStrBool = z const envSchema = z .object({ PORT: z.coerce.number().default(4000), + DISABLE_SECRET_SCANNING: z + .enum(["true", "false"]) + .default("false") + .transform((el) => el === "true"), REDIS_URL: zpStr(z.string()), HOST: zpStr(z.string().default("localhost")), DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default( @@ -71,6 +75,7 @@ const envSchema = z .optional() .default(process.env.URL_GITLAB_LOGIN ?? GITLAB_URL) ), // fallback since URL_GITLAB_LOGIN has been renamed + DEFAULT_SAML_ORG_SLUG: zpStr(z.string().optional()).default(process.env.NEXT_PUBLIC_SAML_ORG_SLUG), // integration client secrets // heroku CLIENT_ID_HEROKU: zpStr(z.string().optional()), @@ -119,6 +124,7 @@ const envSchema = z }) .transform((data) => ({ ...data, + isCloud: Boolean(data.LICENSE_SERVER_KEY), isSmtpConfigured: Boolean(data.SMTP_HOST), isRedisConfigured: Boolean(data.REDIS_URL), isDevelopmentMode: data.NODE_ENV === "development", @@ -126,7 +132,8 @@ const envSchema = z isSecretScanningConfigured: Boolean(data.SECRET_SCANNING_GIT_APP_ID) && Boolean(data.SECRET_SCANNING_PRIVATE_KEY) && - Boolean(data.SECRET_SCANNING_WEBHOOK_SECRET) + Boolean(data.SECRET_SCANNING_WEBHOOK_SECRET), + samlDefaultOrgSlug: data.DEFAULT_SAML_ORG_SLUG })); let envCfg: Readonly>; diff --git a/backend/src/lib/crypto/cipher/cipher.ts b/backend/src/lib/crypto/cipher/cipher.ts new file mode 100644 index 000000000..7bc16b470 --- /dev/null +++ b/backend/src/lib/crypto/cipher/cipher.ts @@ -0,0 +1,49 @@ +import crypto from "crypto"; + +import { SymmetricEncryption, TSymmetricEncryptionFns } from "./types"; + +const getIvLength = () => { + return 12; +}; + +const getTagLength = () => { + return 16; +}; + +export const symmetricCipherService = (type: SymmetricEncryption): TSymmetricEncryptionFns => { + const IV_LENGTH = getIvLength(); + const TAG_LENGTH = getTagLength(); + + const encrypt = (text: Buffer, key: Buffer) => { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(type, key, iv); + + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + + // Get the authentication tag + const tag = cipher.getAuthTag(); + + // Concatenate IV, encrypted text, and tag into a single buffer + const ciphertextBlob = Buffer.concat([iv, encrypted, tag]); + return ciphertextBlob; + }; + + const decrypt = (ciphertextBlob: Buffer, key: Buffer) => { + // Extract the IV, encrypted text, and tag from the buffer + const iv = ciphertextBlob.subarray(0, IV_LENGTH); + const tag = ciphertextBlob.subarray(-TAG_LENGTH); + const encrypted = ciphertextBlob.subarray(IV_LENGTH, -TAG_LENGTH); + + const decipher = crypto.createDecipheriv(type, key, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted; + }; + + return { + encrypt, + decrypt + }; +}; diff --git a/backend/src/lib/crypto/cipher/index.ts b/backend/src/lib/crypto/cipher/index.ts new file mode 100644 index 000000000..41dbcf639 --- /dev/null +++ b/backend/src/lib/crypto/cipher/index.ts @@ -0,0 +1,2 @@ +export { symmetricCipherService } from "./cipher"; +export { SymmetricEncryption } from "./types"; diff --git a/backend/src/lib/crypto/cipher/types.ts b/backend/src/lib/crypto/cipher/types.ts new file mode 100644 index 000000000..f490d6a66 --- /dev/null +++ b/backend/src/lib/crypto/cipher/types.ts @@ -0,0 +1,9 @@ +export enum SymmetricEncryption { + AES_GCM_256 = "aes-256-gcm", + AES_GCM_128 = "aes-128-gcm" +} + +export type TSymmetricEncryptionFns = { + encrypt: (text: Buffer, key: Buffer) => Buffer; + decrypt: (blob: Buffer, key: Buffer) => Buffer; +}; diff --git a/backend/src/lib/crypto/encryption.ts b/backend/src/lib/crypto/encryption.ts index 16a7f42e7..6af20862b 100644 --- a/backend/src/lib/crypto/encryption.ts +++ b/backend/src/lib/crypto/encryption.ts @@ -11,6 +11,8 @@ import { getConfig } from "../config/env"; export const decodeBase64 = (s: string) => naclUtils.decodeBase64(s); export const encodeBase64 = (u: Uint8Array) => naclUtils.encodeBase64(u); +export const randomSecureBytes = (length = 32) => crypto.randomBytes(length); + export type TDecryptSymmetricInput = { ciphertext: string; iv: string; diff --git a/backend/src/lib/crypto/index.ts b/backend/src/lib/crypto/index.ts index db3d91fc8..cc6acfb80 100644 --- a/backend/src/lib/crypto/index.ts +++ b/backend/src/lib/crypto/index.ts @@ -9,7 +9,8 @@ export { encryptAsymmetric, encryptSymmetric, encryptSymmetric128BitHexKeyUTF8, - generateAsymmetricKeyPair + generateAsymmetricKeyPair, + randomSecureBytes } from "./encryption"; export { decryptIntegrationAuths, diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index d78020809..0faeba290 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -104,24 +104,68 @@ export const ormify = (db: Kne throw new DatabaseError({ error, name: "Create" }); } }, - updateById: async (id: string, data: Tables[Tname]["update"], tx?: Knex) => { + updateById: async ( + id: string, + { + $incr, + $decr, + ...data + }: Tables[Tname]["update"] & { + $incr?: { [x in keyof Partial]: number }; + $decr?: { [x in keyof Partial]: number }; + }, + tx?: Knex + ) => { try { - const [res] = await (tx || db)(tableName) + const query = (tx || db)(tableName) .where({ id } as never) .update(data as never) .returning("*"); - return res; + if ($incr) { + Object.entries($incr).forEach(([incrementField, incrementValue]) => { + void query.increment(incrementField, incrementValue); + }); + } + if ($decr) { + Object.entries($decr).forEach(([incrementField, incrementValue]) => { + void query.decrement(incrementField, incrementValue); + }); + } + const [docs] = await query; + return docs; } catch (error) { throw new DatabaseError({ error, name: "Update by id" }); } }, - update: async (filter: TFindFilter, data: Tables[Tname]["update"], tx?: Knex) => { + update: async ( + filter: TFindFilter, + { + $incr, + $decr, + ...data + }: Tables[Tname]["update"] & { + $incr?: { [x in keyof Partial]: number }; + $decr?: { [x in keyof Partial]: number }; + }, + tx?: Knex + ) => { try { - const res = await (tx || db)(tableName) + const query = (tx || db)(tableName) .where(buildFindFilter(filter)) .update(data as never) .returning("*"); - return res; + // increment and decrement operation in update + if ($incr) { + Object.entries($incr).forEach(([incrementField, incrementValue]) => { + void query.increment(incrementField, incrementValue); + }); + } + if ($decr) { + Object.entries($decr).forEach(([incrementField, incrementValue]) => { + void query.increment(incrementField, incrementValue); + }); + } + return await query; } catch (error) { throw new DatabaseError({ error, name: "Update" }); } diff --git a/backend/src/lib/logger/logger.ts b/backend/src/lib/logger/logger.ts index c124fcf4c..5d1a63fc8 100644 --- a/backend/src/lib/logger/logger.ts +++ b/backend/src/lib/logger/logger.ts @@ -30,6 +30,37 @@ const loggerConfig = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("production") }); +const redactedKeys = [ + "accessToken", + "authToken", + "serviceToken", + "identityAccessToken", + "token", + "privateKey", + "serverPrivateKey", + "plainPrivateKey", + "plainProjectKey", + "encryptedPrivateKey", + "userPrivateKey", + "protectedKey", + "decryptKey", + "encryptedProjectKey", + "encryptedSymmetricKey", + "encryptedPrivateKey", + "backupPrivateKey", + "secretKey", + "SecretKey", + "botPrivateKey", + "encryptedKey", + "plaintextProjectKey", + "accessKey", + "botKey", + "decryptedSecret", + "secrets", + "key", + "password" +]; + export const initLogger = async () => { const cfg = loggerConfig.parse(process.env); const targets: pino.TransportMultiOptions["targets"][number][] = [ @@ -74,7 +105,9 @@ export const initLogger = async () => { hostname: bindings.hostname // node_version: process.version }) - } + }, + // redact until depth of three + redact: [...redactedKeys, ...redactedKeys.map((key) => `*.${key}`), ...redactedKeys.map((key) => `*.*.${key}`)] }, // eslint-disable-next-line @typescript-eslint/no-unsafe-argument transport diff --git a/backend/src/lib/red-lock/index.ts b/backend/src/lib/red-lock/index.ts new file mode 100644 index 000000000..e1cc4f587 --- /dev/null +++ b/backend/src/lib/red-lock/index.ts @@ -0,0 +1,682 @@ +/* eslint-disable */ +// Source code credits: https://github.com/mike-marcacci/node-redlock +// Taken to avoid external dependency +import { randomBytes, createHash } from "crypto"; +import { EventEmitter } from "events"; + +// AbortController became available as a global in node version 16. Once version +// 14 reaches its end-of-life, this can be removed. + +import { Redis as IORedisClient, Cluster as IORedisCluster } from "ioredis"; + +type Client = IORedisClient | IORedisCluster; + +// Define script constants. +const ACQUIRE_SCRIPT = ` + -- Return 0 if an entry already exists. + for i, key in ipairs(KEYS) do + if redis.call("exists", key) == 1 then + return 0 + end + end + + -- Create an entry for each provided key. + for i, key in ipairs(KEYS) do + redis.call("set", key, ARGV[1], "PX", ARGV[2]) + end + + -- Return the number of entries added. + return #KEYS +`; + +const EXTEND_SCRIPT = ` + -- Return 0 if an entry exists with a *different* lock value. + for i, key in ipairs(KEYS) do + if redis.call("get", key) ~= ARGV[1] then + return 0 + end + end + + -- Update the entry for each provided key. + for i, key in ipairs(KEYS) do + redis.call("set", key, ARGV[1], "PX", ARGV[2]) + end + + -- Return the number of entries updated. + return #KEYS +`; + +const RELEASE_SCRIPT = ` + local count = 0 + for i, key in ipairs(KEYS) do + -- Only remove entries for *this* lock value. + if redis.call("get", key) == ARGV[1] then + redis.pcall("del", key) + count = count + 1 + end + end + + -- Return the number of entries removed. + return count +`; + +export type ClientExecutionResult = + | { + client: Client; + vote: "for"; + value: number; + } + | { + client: Client; + vote: "against"; + error: Error; + }; + +/* + * This object contains a summary of results. + */ +export type ExecutionStats = { + readonly membershipSize: number; + readonly quorumSize: number; + readonly votesFor: Set; + readonly votesAgainst: Map; +}; + +/* + * This object contains a summary of results. Because the result of an attempt + * can sometimes be determined before all requests are finished, each attempt + * contains a Promise that will resolve ExecutionStats once all requests are + * finished. A rejection of these promises should be considered undefined + * behavior and should cause a crash. + */ +export type ExecutionResult = { + attempts: ReadonlyArray>; + start: number; +}; + +/** + * + */ +export interface Settings { + readonly driftFactor: number; + readonly retryCount: number; + readonly retryDelay: number; + readonly retryJitter: number; + readonly automaticExtensionThreshold: number; +} + +// Define default settings. +const defaultSettings: Readonly = { + driftFactor: 0.01, + retryCount: 10, + retryDelay: 200, + retryJitter: 100, + automaticExtensionThreshold: 500 +}; + +// Modifyng this object is forbidden. +Object.freeze(defaultSettings); + +/* + * This error indicates a failure due to the existence of another lock for one + * or more of the requested resources. + */ +export class ResourceLockedError extends Error { + constructor(public readonly message: string) { + super(); + this.name = "ResourceLockedError"; + } +} + +/* + * This error indicates a failure of an operation to pass with a quorum. + */ +export class ExecutionError extends Error { + constructor( + public readonly message: string, + public readonly attempts: ReadonlyArray> + ) { + super(); + this.name = "ExecutionError"; + } +} + +/* + * An object of this type is returned when a resource is successfully locked. It + * contains convenience methods `release` and `extend` which perform the + * associated Redlock method on itself. + */ +export class Lock { + constructor( + public readonly redlock: Redlock, + public readonly resources: string[], + public readonly value: string, + public readonly attempts: ReadonlyArray>, + public expiration: number + ) {} + + async release(): Promise { + return this.redlock.release(this); + } + + async extend(duration: number): Promise { + return this.redlock.extend(this, duration); + } +} + +export type RedlockAbortSignal = AbortSignal & { error?: Error }; + +/** + * A redlock object is instantiated with an array of at least one redis client + * and an optional `options` object. Properties of the Redlock object should NOT + * be changed after it is first used, as doing so could have unintended + * consequences for live locks. + */ +export class Redlock extends EventEmitter { + public readonly clients: Set; + public readonly settings: Settings; + public readonly scripts: { + readonly acquireScript: { value: string; hash: string }; + readonly extendScript: { value: string; hash: string }; + readonly releaseScript: { value: string; hash: string }; + }; + + public constructor( + clients: Iterable, + settings: Partial = {}, + scripts: { + readonly acquireScript?: string | ((script: string) => string); + readonly extendScript?: string | ((script: string) => string); + readonly releaseScript?: string | ((script: string) => string); + } = {} + ) { + super(); + + // Prevent crashes on error events. + this.on("error", () => { + // Because redlock is designed for high availability, it does not care if + // a minority of redis instances/clusters fail at an operation. + // + // However, it can be helpful to monitor and log such cases. Redlock emits + // an "error" event whenever it encounters an error, even if the error is + // ignored in its normal operation. + // + // This function serves to prevent node's default behavior of crashing + // when an "error" event is emitted in the absence of listeners. + }); + + // Create a new array of client, to ensure no accidental mutation. + this.clients = new Set(clients); + if (this.clients.size === 0) { + throw new Error("Redlock must be instantiated with at least one redis client."); + } + + // Customize the settings for this instance. + this.settings = { + driftFactor: typeof settings.driftFactor === "number" ? settings.driftFactor : defaultSettings.driftFactor, + retryCount: typeof settings.retryCount === "number" ? settings.retryCount : defaultSettings.retryCount, + retryDelay: typeof settings.retryDelay === "number" ? settings.retryDelay : defaultSettings.retryDelay, + retryJitter: typeof settings.retryJitter === "number" ? settings.retryJitter : defaultSettings.retryJitter, + automaticExtensionThreshold: + typeof settings.automaticExtensionThreshold === "number" + ? settings.automaticExtensionThreshold + : defaultSettings.automaticExtensionThreshold + }; + + // Use custom scripts and script modifiers. + const acquireScript = + typeof scripts.acquireScript === "function" ? scripts.acquireScript(ACQUIRE_SCRIPT) : ACQUIRE_SCRIPT; + const extendScript = + typeof scripts.extendScript === "function" ? scripts.extendScript(EXTEND_SCRIPT) : EXTEND_SCRIPT; + const releaseScript = + typeof scripts.releaseScript === "function" ? scripts.releaseScript(RELEASE_SCRIPT) : RELEASE_SCRIPT; + + this.scripts = { + acquireScript: { + value: acquireScript, + hash: this._hash(acquireScript) + }, + extendScript: { + value: extendScript, + hash: this._hash(extendScript) + }, + releaseScript: { + value: releaseScript, + hash: this._hash(releaseScript) + } + }; + } + + /** + * Generate a sha1 hash compatible with redis evalsha. + */ + private _hash(value: string): string { + return createHash("sha1").update(value).digest("hex"); + } + + /** + * Generate a cryptographically random string. + */ + private _random(): string { + return randomBytes(16).toString("hex"); + } + + /** + * This method runs `.quit()` on all client connections. + */ + public async quit(): Promise { + const results = []; + for (const client of this.clients) { + results.push(client.quit()); + } + + await Promise.all(results); + } + + /** + * This method acquires a locks on the resources for the duration specified by + * the `duration`. + */ + public async acquire(resources: string[], duration: number, settings?: Partial): Promise { + if (Math.floor(duration) !== duration) { + throw new Error("Duration must be an integer value in milliseconds."); + } + + const value = this._random(); + + try { + const { attempts, start } = await this._execute( + this.scripts.acquireScript, + resources, + [value, duration], + settings + ); + + // Add 2 milliseconds to the drift to account for Redis expires precision, + // which is 1 ms, plus the configured allowable drift factor. + const drift = Math.round((settings?.driftFactor ?? this.settings.driftFactor) * duration) + 2; + + return new Lock(this, resources, value, attempts, start + duration - drift); + } catch (error) { + // If there was an error acquiring the lock, release any partial lock + // state that may exist on a minority of clients. + await this._execute(this.scripts.releaseScript, resources, [value], { + retryCount: 0 + }).catch(() => { + // Any error here will be ignored. + }); + + throw error; + } + } + + /** + * This method unlocks the provided lock from all servers still persisting it. + * It will fail with an error if it is unable to release the lock on a quorum + * of nodes, but will make no attempt to restore the lock in the case of a + * failure to release. It is safe to re-attempt a release or to ignore the + * error, as the lock will automatically expire after its timeout. + */ + public async release(lock: Lock, settings?: Partial): Promise { + // Immediately invalidate the lock. + lock.expiration = 0; + + // Attempt to release the lock. + return this._execute(this.scripts.releaseScript, lock.resources, [lock.value], settings); + } + + /** + * This method extends a valid lock by the provided `duration`. + */ + public async extend(existing: Lock, duration: number, settings?: Partial): Promise { + if (Math.floor(duration) !== duration) { + throw new Error("Duration must be an integer value in milliseconds."); + } + + // The lock has already expired. + if (existing.expiration < Date.now()) { + throw new ExecutionError("Cannot extend an already-expired lock.", []); + } + + const { attempts, start } = await this._execute( + this.scripts.extendScript, + existing.resources, + [existing.value, duration], + settings + ); + + // Invalidate the existing lock. + existing.expiration = 0; + + // Add 2 milliseconds to the drift to account for Redis expires precision, + // which is 1 ms, plus the configured allowable drift factor. + const drift = Math.round((settings?.driftFactor ?? this.settings.driftFactor) * duration) + 2; + + const replacement = new Lock(this, existing.resources, existing.value, attempts, start + duration - drift); + + return replacement; + } + + /** + * Execute a script on all clients. The resulting promise is resolved or + * rejected as soon as this quorum is reached; the resolution or rejection + * will contains a `stats` property that is resolved once all votes are in. + */ + private async _execute( + script: { value: string; hash: string }, + keys: string[], + args: (string | number)[], + _settings?: Partial + ): Promise { + const settings = _settings + ? { + ...this.settings, + ..._settings + } + : this.settings; + + // For the purpose of easy config serialization, we treat a retryCount of + // -1 a equivalent to Infinity. + const maxAttempts = settings.retryCount === -1 ? Infinity : settings.retryCount + 1; + + const attempts: Promise[] = []; + + while (true) { + const { vote, stats, start } = await this._attemptOperation(script, keys, args); + + attempts.push(stats); + + // The operation achieved a quorum in favor. + if (vote === "for") { + return { attempts, start }; + } + + // Wait before reattempting. + if (attempts.length < maxAttempts) { + await new Promise((resolve) => { + setTimeout( + resolve, + Math.max(0, settings.retryDelay + Math.floor((Math.random() * 2 - 1) * settings.retryJitter)), + undefined + ); + }); + } else { + throw new ExecutionError("The operation was unable to achieve a quorum during its retry window.", attempts); + } + } + } + + private async _attemptOperation( + script: { value: string; hash: string }, + keys: string[], + args: (string | number)[] + ): Promise< + | { vote: "for"; stats: Promise; start: number } + | { vote: "against"; stats: Promise; start: number } + > { + const start = Date.now(); + + return await new Promise((resolve) => { + const clientResults = []; + for (const client of this.clients) { + clientResults.push(this._attemptOperationOnClient(client, script, keys, args)); + } + + const stats: ExecutionStats = { + membershipSize: clientResults.length, + quorumSize: Math.floor(clientResults.length / 2) + 1, + votesFor: new Set(), + votesAgainst: new Map() + }; + + let done: () => void; + const statsPromise = new Promise((resolve) => { + done = () => resolve(stats); + }); + + // This is the expected flow for all successful and unsuccessful requests. + const onResultResolve = (clientResult: ClientExecutionResult): void => { + switch (clientResult.vote) { + case "for": + stats.votesFor.add(clientResult.client); + break; + case "against": + stats.votesAgainst.set(clientResult.client, clientResult.error); + break; + } + + // A quorum has determined a success. + if (stats.votesFor.size === stats.quorumSize) { + resolve({ + vote: "for", + stats: statsPromise, + start + }); + } + + // A quorum has determined a failure. + if (stats.votesAgainst.size === stats.quorumSize) { + resolve({ + vote: "against", + stats: statsPromise, + start + }); + } + + // All votes are in. + if (stats.votesFor.size + stats.votesAgainst.size === stats.membershipSize) { + done(); + } + }; + + // This is unexpected and should crash to prevent undefined behavior. + const onResultReject = (error: Error): void => { + throw error; + }; + + for (const result of clientResults) { + result.then(onResultResolve, onResultReject); + } + }); + } + + private async _attemptOperationOnClient( + client: Client, + script: { value: string; hash: string }, + keys: string[], + args: (string | number)[] + ): Promise { + try { + let result: number; + try { + // Attempt to evaluate the script by its hash. + // @ts-expect-error + const shaResult = (await client.evalsha(script.hash, keys.length, [...keys, ...args])) as unknown; + + if (typeof shaResult !== "number") { + throw new Error(`Unexpected result of type ${typeof shaResult} returned from redis.`); + } + + result = shaResult; + } catch (error) { + // If the redis server does not already have the script cached, + // reattempt the request with the script's raw text. + if (!(error instanceof Error) || !error.message.startsWith("NOSCRIPT")) { + throw error; + } + // @ts-expect-error + const rawResult = (await client.eval(script.value, keys.length, [...keys, ...args])) as unknown; + + if (typeof rawResult !== "number") { + throw new Error(`Unexpected result of type ${typeof rawResult} returned from redis.`); + } + + result = rawResult; + } + + // One or more of the resources was already locked. + if (result !== keys.length) { + throw new ResourceLockedError( + `The operation was applied to: ${result} of the ${keys.length} requested resources.` + ); + } + + return { + vote: "for", + client, + value: result + }; + } catch (error) { + if (!(error instanceof Error)) { + throw new Error(`Unexpected type ${typeof error} thrown with value: ${error}`); + } + + // Emit the error on the redlock instance for observability. + this.emit("error", error); + + return { + vote: "against", + client, + error + }; + } + } + + /** + * Wrap and execute a routine in the context of an auto-extending lock, + * returning a promise of the routine's value. In the case that auto-extension + * fails, an AbortSignal will be updated to indicate that abortion of the + * routine is in order, and to pass along the encountered error. + * + * @example + * ```ts + * await redlock.using([senderId, recipientId], 5000, { retryCount: 5 }, async (signal) => { + * const senderBalance = await getBalance(senderId); + * const recipientBalance = await getBalance(recipientId); + * + * if (senderBalance < amountToSend) { + * throw new Error("Insufficient balance."); + * } + * + * // The abort signal will be true if: + * // 1. the above took long enough that the lock needed to be extended + * // 2. redlock was unable to extend the lock + * // + * // In such a case, exclusivity can no longer be guaranteed for further + * // operations, and should be handled as an exceptional case. + * if (signal.aborted) { + * throw signal.error; + * } + * + * await setBalances([ + * {id: senderId, balance: senderBalance - amountToSend}, + * {id: recipientId, balance: recipientBalance + amountToSend}, + * ]); + * }); + * ``` + */ + + public async using( + resources: string[], + duration: number, + settings: Partial, + routine?: (signal: RedlockAbortSignal) => Promise + ): Promise; + + public async using( + resources: string[], + duration: number, + routine: (signal: RedlockAbortSignal) => Promise + ): Promise; + + public async using( + resources: string[], + duration: number, + settingsOrRoutine: undefined | Partial | ((signal: RedlockAbortSignal) => Promise), + optionalRoutine?: (signal: RedlockAbortSignal) => Promise + ): Promise { + if (Math.floor(duration) !== duration) { + throw new Error("Duration must be an integer value in milliseconds."); + } + + const settings = + settingsOrRoutine && typeof settingsOrRoutine !== "function" + ? { + ...this.settings, + ...settingsOrRoutine + } + : this.settings; + + const routine = optionalRoutine ?? settingsOrRoutine; + if (typeof routine !== "function") { + throw new Error("INVARIANT: routine is not a function."); + } + + if (settings.automaticExtensionThreshold > duration - 100) { + throw new Error( + "A lock `duration` must be at least 100ms greater than the `automaticExtensionThreshold` setting." + ); + } + + // The AbortController/AbortSignal pattern allows the routine to be notified + // of a failure to extend the lock, and subsequent expiration. In the event + // of an abort, the error object will be made available at `signal.error`. + const controller = new AbortController(); + + const signal = controller.signal as RedlockAbortSignal; + + function queue(): void { + timeout = setTimeout( + () => (extension = extend()), + lock.expiration - Date.now() - settings.automaticExtensionThreshold + ); + } + + async function extend(): Promise { + timeout = undefined; + + try { + lock = await lock.extend(duration); + queue(); + } catch (error) { + if (!(error instanceof Error)) { + throw new Error(`Unexpected thrown ${typeof error}: ${error}.`); + } + + if (lock.expiration > Date.now()) { + return (extension = extend()); + } + + signal.error = error instanceof Error ? error : new Error(`${error}`); + controller.abort(); + } + } + + let timeout: undefined | NodeJS.Timeout; + let extension: undefined | Promise; + let lock = await this.acquire(resources, duration, settings); + queue(); + + try { + return await routine(signal); + } finally { + // Clean up the timer. + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + + // Wait for an in-flight extension to finish. + if (extension) { + await extension.catch(() => { + // An error here doesn't matter at all, because the routine has + // already completed, and a release will be attempted regardless. The + // only reason for waiting here is to prevent possible contention + // between the extension and release. + }); + } + + await lock.release(); + } + } +} diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index b3b46e739..2c41f4d23 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -17,7 +17,7 @@ export type TOrgPermission = { actorId: string; orgId: string; actorAuthMethod: ActorAuthMethod; - actorOrgId: string | undefined; + actorOrgId: string; }; export type TProjectPermission = { diff --git a/backend/src/lib/validator/index.ts b/backend/src/lib/validator/index.ts index 6bc415680..6a70d8571 100644 --- a/backend/src/lib/validator/index.ts +++ b/backend/src/lib/validator/index.ts @@ -1 +1,2 @@ export { isDisposableEmail } from "./validate-email"; +export { validateLocalIps } from "./validate-url"; diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts new file mode 100644 index 000000000..9a953be1a --- /dev/null +++ b/backend/src/lib/validator/validate-url.ts @@ -0,0 +1,18 @@ +import { getConfig } from "../config/env"; +import { BadRequestError } from "../errors"; + +export const validateLocalIps = (url: string) => { + const validUrl = new URL(url); + const appCfg = getConfig(); + // on cloud local ips are not allowed + if ( + appCfg.isCloud && + (validUrl.host === "host.docker.internal" || + validUrl.host.match(/^10\.\d+\.\d+\.\d+/) || + validUrl.host.match(/^192\.168\.\d+\.\d+/)) + ) + throw new BadRequestError({ message: "Local IPs not allowed as URL" }); + + if (validUrl.host === "localhost" || validUrl.host === "127.0.0.1") + throw new BadRequestError({ message: "Localhost not allowed" }); +}; diff --git a/backend/src/lib/zod/index.ts b/backend/src/lib/zod/index.ts index a3cded66b..4d3fea8c7 100644 --- a/backend/src/lib/zod/index.ts +++ b/backend/src/lib/zod/index.ts @@ -7,3 +7,7 @@ export const zpStr = (schema: T, opt: { stripNull: boolean if (typeof val !== "string") return val; return val.trim() || undefined; }, schema); + +export const zodBuffer = z.custom((data) => Buffer.isBuffer(data) || data instanceof Uint8Array, { + message: "Expected binary data (Buffer Or Uint8Array)" +}); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index e1149120d..7046058b7 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -7,33 +7,42 @@ import { TScanFullRepoEventPayload, TScanPushEventPayload } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { TSyncSecretsDTO } from "@app/services/secret/secret-types"; export enum QueueName { SecretRotation = "secret-rotation", SecretReminder = "secret-reminder", AuditLog = "audit-log", + // TODO(akhilmhdh): This will get removed later. For now this is kept to stop the repeatable queue AuditLogPrune = "audit-log-prune", + DailyResourceCleanUp = "daily-resource-cleanup", TelemetryInstanceStats = "telemtry-self-hosted-stats", IntegrationSync = "sync-integrations", SecretWebhook = "secret-webhook", SecretFullRepoScan = "secret-full-repo-scan", SecretPushEventScan = "secret-push-event-scan", UpgradeProjectToGhost = "upgrade-project-to-ghost", - DynamicSecretRevocation = "dynamic-secret-revocation" + DynamicSecretRevocation = "dynamic-secret-revocation", + SecretReplication = "secret-replication", + SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication } export enum QueueJobs { SecretReminder = "secret-reminder-job", SecretRotation = "secret-rotation-job", AuditLog = "audit-log-job", + // TODO(akhilmhdh): This will get removed later. For now this is kept to stop the repeatable queue AuditLogPrune = "audit-log-prune-job", + DailyResourceCleanUp = "daily-resource-cleanup-job", SecWebhook = "secret-webhook-trigger", TelemetryInstanceStats = "telemetry-self-hosted-stats", IntegrationSync = "secret-integration-pull", SecretScan = "secret-scan", UpgradeProjectToGhost = "upgrade-project-to-ghost-job", DynamicSecretRevocation = "dynamic-secret-revocation", - DynamicSecretPruning = "dynamic-secret-pruning" + DynamicSecretPruning = "dynamic-secret-pruning", + SecretReplication = "secret-replication", + SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication } export type TQueueJobTypes = { @@ -55,6 +64,10 @@ export type TQueueJobTypes = { name: QueueJobs.AuditLog; payload: TCreateAuditLogDTO; }; + [QueueName.DailyResourceCleanUp]: { + name: QueueJobs.DailyResourceCleanUp; + payload: undefined; + }; [QueueName.AuditLogPrune]: { name: QueueJobs.AuditLogPrune; payload: undefined; @@ -65,7 +78,13 @@ export type TQueueJobTypes = { }; [QueueName.IntegrationSync]: { name: QueueJobs.IntegrationSync; - payload: { projectId: string; environment: string; secretPath: string; depth?: number }; + payload: { + projectId: string; + environment: string; + secretPath: string; + depth?: number; + deDupeQueue?: Record; + }; }; [QueueName.SecretFullRepoScan]: { name: QueueJobs.SecretScan; @@ -102,6 +121,14 @@ export type TQueueJobTypes = { dynamicSecretCfgId: string; }; }; + [QueueName.SecretReplication]: { + name: QueueJobs.SecretReplication; + payload: TSyncSecretsDTO; + }; + [QueueName.SecretSync]: { + name: QueueJobs.SecretSync; + payload: TSyncSecretsDTO; + }; }; export type TQueueServiceFactory = ReturnType; @@ -118,7 +145,7 @@ export const queueServiceFactory = (redisUrl: string) => { const start = ( name: T, - jobFn: (job: Job) => Promise, + jobFn: (job: Job, token?: string) => Promise, queueSettings: Omit = {} ) => { if (queueContainer[name]) { @@ -152,7 +179,7 @@ export const queueServiceFactory = (redisUrl: string) => { name: T, job: TQueueJobTypes[T]["name"], data: TQueueJobTypes[T]["payload"], - opts: JobsOptions & { jobId?: string } + opts?: JobsOptions & { jobId?: string } ) => { const q = queueContainer[name]; @@ -166,7 +193,9 @@ export const queueServiceFactory = (redisUrl: string) => { jobId?: string ) => { const q = queueContainer[name]; - return q.removeRepeatable(job, repeatOpt, jobId); + if (q) { + return q.removeRepeatable(job, repeatOpt, jobId); + } }; const stopRepeatableJobByJobId = async (name: T, jobId: string) => { diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index d8069b9db..8c41eb2fa 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -28,7 +28,7 @@ export const readLimit: RateLimitOptions = { // POST, PATCH, PUT, DELETE endpoints export const writeLimit: RateLimitOptions = { timeWindow: 60 * 1000, - max: 50, + max: 200, // (too low, FA having issues so increasing it - maidul) keyGenerator: (req) => req.realIp }; @@ -36,7 +36,7 @@ export const writeLimit: RateLimitOptions = { export const secretsLimit: RateLimitOptions = { // secrets, folders, secret imports timeWindow: 60 * 1000, - max: 600, + max: 60, keyGenerator: (req) => req.realIp }; @@ -52,9 +52,25 @@ export const inviteUserRateLimit: RateLimitOptions = { keyGenerator: (req) => req.realIp }; +export const mfaRateLimit: RateLimitOptions = { + timeWindow: 60 * 1000, + max: 20, + keyGenerator: (req) => { + return req.headers.authorization?.split(" ")[1] || req.realIp; + } +}; + export const creationLimit: RateLimitOptions = { // identity, project, org timeWindow: 60 * 1000, max: 30, keyGenerator: (req) => req.realIp }; + +// Public endpoints to avoid brute force attacks +export const publicEndpointLimit: RateLimitOptions = { + // Shared Secrets + timeWindow: 60 * 1000, + max: 30, + keyGenerator: (req) => req.realIp +}; diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 4c0683797..d8814dd40 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -108,6 +108,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { if (req.url.includes("/api/v3/auth/")) { return; } + if (!authMode) return; switch (authMode) { diff --git a/backend/src/server/plugins/auth/inject-permission.ts b/backend/src/server/plugins/auth/inject-permission.ts index 084f18198..11a94657b 100644 --- a/backend/src/server/plugins/auth/inject-permission.ts +++ b/backend/src/server/plugins/auth/inject-permission.ts @@ -1,5 +1,6 @@ import fp from "fastify-plugin"; +import { logger } from "@app/lib/logger"; import { ActorType } from "@app/services/auth/auth-type"; // inject permission type needed based on auth extracted @@ -15,6 +16,10 @@ export const injectPermission = fp(async (server) => { orgId: req.auth.orgId, // if the req.auth.authMode is AuthMode.API_KEY, the orgId will be "API_KEY" authMethod: req.auth.authMethod // if the req.auth.authMode is AuthMode.API_KEY, the authMethod will be null }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForIdentity=${req.auth.userId}] [type=${ActorType.USER}]` + ); } else if (req.auth.actor === ActorType.IDENTITY) { req.permission = { type: ActorType.IDENTITY, @@ -22,6 +27,10 @@ export const injectPermission = fp(async (server) => { orgId: req.auth.orgId, authMethod: null }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForIdentity=${req.auth.identityId}] [type=${ActorType.IDENTITY}]` + ); } else if (req.auth.actor === ActorType.SERVICE) { req.permission = { type: ActorType.SERVICE, @@ -29,6 +38,10 @@ export const injectPermission = fp(async (server) => { orgId: req.auth.orgId, authMethod: null }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForIdentity=${req.auth.serviceTokenId}] [type=${ActorType.SERVICE}]` + ); } else if (req.auth.actor === ActorType.SCIM_CLIENT) { req.permission = { type: ActorType.SCIM_CLIENT, @@ -36,6 +49,10 @@ export const injectPermission = fp(async (server) => { orgId: req.auth.orgId, authMethod: null }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForIdentity=${req.auth.scimTokenId}] [type=${ActorType.SCIM_CLIENT}]` + ); } }); }); diff --git a/backend/src/server/plugins/ip.ts b/backend/src/server/plugins/ip.ts index b3c8171af..7b5838d57 100644 --- a/backend/src/server/plugins/ip.ts +++ b/backend/src/server/plugins/ip.ts @@ -6,6 +6,7 @@ const headersOrder = [ "cf-connecting-ip", // Cloudflare "Cf-Pseudo-IPv4", // Cloudflare "x-client-ip", // Most common + "x-envoy-external-address", // for envoy "x-forwarded-for", // Mostly used by proxies "fastly-client-ip", "true-client-ip", // Akamai and Cloudflare @@ -23,7 +24,21 @@ export const fastifyIp = fp(async (fastify) => { const forwardedIpHeader = headersOrder.find((header) => Boolean(req.headers[header])); const forwardedIp = forwardedIpHeader ? req.headers[forwardedIpHeader] : undefined; if (forwardedIp) { - req.realIp = Array.isArray(forwardedIp) ? forwardedIp[0] : forwardedIp; + if (Array.isArray(forwardedIp)) { + // eslint-disable-next-line + req.realIp = forwardedIp[0]; + return; + } + + if (forwardedIp.includes(",")) { + // the ip header when placed with load balancers that proxy request + // will attach the internal ips to header by appending with comma + // https://github.com/go-chi/chi/blob/master/middleware/realip.go + const clientIPFromProxy = forwardedIp.slice(0, forwardedIp.indexOf(",")).trim(); + req.realIp = clientIPFromProxy; + return; + } + req.realIp = forwardedIp; } else { req.realIp = req.ip; } diff --git a/backend/src/server/plugins/maintenanceMode.ts b/backend/src/server/plugins/maintenanceMode.ts index f40f1ff6d..201dd05b8 100644 --- a/backend/src/server/plugins/maintenanceMode.ts +++ b/backend/src/server/plugins/maintenanceMode.ts @@ -5,8 +5,13 @@ import { getConfig } from "@app/lib/config/env"; export const maintenanceMode = fp(async (fastify) => { fastify.addHook("onRequest", async (req) => { const serverEnvs = getConfig(); - if (req.url !== "/api/v1/auth/checkAuth" && req.method !== "GET" && serverEnvs.MAINTENANCE_MODE) { - throw new Error("Infisical is in maintenance mode. Please try again later."); + if (serverEnvs.MAINTENANCE_MODE) { + // skip if its universal auth login or renew + if (req.url === "/api/v1/auth/universal-auth/login" && req.method === "POST") return; + if (req.url === "/api/v1/auth/token/renew" && req.method === "POST") return; + if (req.url !== "/api/v1/auth/checkAuth" && req.method !== "GET") { + throw new Error("Infisical is in maintenance mode. Please try again later."); + } } }); }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4cb56a222..00590386a 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2,9 +2,17 @@ import { Knex } from "knex"; import { z } from "zod"; import { registerV1EERoutes } from "@app/ee/routes/v1"; +import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; +import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal"; +import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; +import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal"; +import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal"; +import { accessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue"; import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { auditLogStreamDALFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-dal"; +import { auditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; import { dynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; import { dynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/providers"; @@ -36,6 +44,7 @@ import { secretApprovalRequestDALFactory } from "@app/ee/services/secret-approva import { secretApprovalRequestReviewerDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-reviewer-dal"; import { secretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; import { secretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service"; +import { secretReplicationServiceFactory } from "@app/ee/services/secret-replication/secret-replication-service"; import { secretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; import { secretRotationQueueFactory } from "@app/ee/services/secret-rotation/secret-rotation-queue"; import { secretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; @@ -70,6 +79,14 @@ import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { identityServiceFactory } from "@app/services/identity/identity-service"; import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; +import { identityAwsAuthDALFactory } from "@app/services/identity-aws-auth/identity-aws-auth-dal"; +import { identityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; +import { identityAzureAuthDALFactory } from "@app/services/identity-azure-auth/identity-azure-auth-dal"; +import { identityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; +import { identityGcpAuthDALFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-dal"; +import { identityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; +import { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal"; +import { identityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal"; import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; @@ -80,12 +97,16 @@ import { integrationDALFactory } from "@app/services/integration/integration-dal import { integrationServiceFactory } from "@app/services/integration/integration-service"; import { integrationAuthDALFactory } from "@app/services/integration-auth/integration-auth-dal"; import { integrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; +import { kmsDALFactory } from "@app/services/kms/kms-dal"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { kmsServiceFactory } from "@app/services/kms/kms-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"; import { orgRoleDALFactory } from "@app/services/org/org-role-dal"; import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; import { orgServiceFactory } from "@app/services/org/org-service"; +import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { projectDALFactory } from "@app/services/project/project-dal"; import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectServiceFactory } from "@app/services/project/project-service"; @@ -100,6 +121,7 @@ import { projectMembershipServiceFactory } from "@app/services/project-membershi import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal"; import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal"; import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service"; +import { dailyResourceCleanUpQueueServiceFactory } from "@app/services/resource-cleanup/resource-cleanup-queue"; import { secretDALFactory } from "@app/services/secret/secret-dal"; import { secretQueueFactory } from "@app/services/secret/secret-queue"; import { secretServiceFactory } from "@app/services/secret/secret-service"; @@ -112,6 +134,8 @@ import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-f import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal"; import { secretImportDALFactory } from "@app/services/secret-import/secret-import-dal"; import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; +import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal"; +import { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; import { secretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; import { secretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service"; import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal"; @@ -145,7 +169,10 @@ export const registerRoutes = async ( keyStore }: { db: Knex; smtp: TSmtpService; queue: TQueueServiceFactory; keyStore: TKeyStoreFactory } ) => { - await server.register(registerSecretScannerGhApp, { prefix: "/ss-webhook" }); + const appCfg = getConfig(); + if (!appCfg.DISABLE_SECRET_SCANNING) { + await server.register(registerSecretScannerGhApp, { prefix: "/ss-webhook" }); + } // db layers const userDAL = userDALFactory(db); @@ -153,6 +180,7 @@ export const registerRoutes = async ( const authDAL = authDALFactory(db); const authTokenDAL = tokenDALFactory(db); const orgDAL = orgDALFactory(db); + const orgMembershipDAL = orgMembershipDALFactory(db); const orgBotDAL = orgBotDALFactory(db); const incidentContactDAL = incidentContactDALFactory(db); const orgRoleDAL = orgRoleDALFactory(db); @@ -190,9 +218,14 @@ export const registerRoutes = async ( const identityProjectAdditionalPrivilegeDAL = identityProjectAdditionalPrivilegeDALFactory(db); const identityUaDAL = identityUaDALFactory(db); + const identityKubernetesAuthDAL = identityKubernetesAuthDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); + const identityAwsAuthDAL = identityAwsAuthDALFactory(db); + const identityGcpAuthDAL = identityGcpAuthDALFactory(db); + const identityAzureAuthDAL = identityAzureAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(db); + const auditLogStreamDAL = auditLogStreamDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); const telemetryDAL = telemetryDALFactory(db); @@ -202,11 +235,17 @@ export const registerRoutes = async ( const scimDAL = scimDALFactory(db); const ldapConfigDAL = ldapConfigDALFactory(db); const ldapGroupMapDAL = ldapGroupMapDALFactory(db); + + const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db); + const accessApprovalRequestDAL = accessApprovalRequestDALFactory(db); + const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db); + const accessApprovalRequestReviewerDAL = accessApprovalRequestReviewerDALFactory(db); + const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db); - const sarReviewerDAL = secretApprovalRequestReviewerDALFactory(db); - const sarSecretDAL = secretApprovalRequestSecretDALFactory(db); + const secretApprovalRequestReviewerDAL = secretApprovalRequestReviewerDALFactory(db); + const secretApprovalRequestSecretDAL = secretApprovalRequestSecretDALFactory(db); const secretRotationDAL = secretRotationDALFactory(db); const snapshotDAL = snapshotDALFactory(db); @@ -220,10 +259,14 @@ export const registerRoutes = async ( const groupProjectMembershipRoleDAL = groupProjectMembershipRoleDALFactory(db); const userGroupMembershipDAL = userGroupMembershipDALFactory(db); const secretScanningDAL = secretScanningDALFactory(db); + const secretSharingDAL = secretSharingDALFactory(db); const licenseDAL = licenseDALFactory(db); const dynamicSecretDAL = dynamicSecretDALFactory(db); const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); + const kmsDAL = kmsDALFactory(db); + const kmsRootConfigDAL = kmsRootConfigDALFactory(db); + const permissionService = permissionServiceFactory({ permissionDAL, orgRoleDAL, @@ -232,6 +275,12 @@ export const registerRoutes = async ( projectDAL }); const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL, keyStore }); + const kmsService = kmsServiceFactory({ + kmsRootConfigDAL, + keyStore, + kmsDAL + }); + const trustedIpService = trustedIpServiceFactory({ licenseService, projectDAL, @@ -243,23 +292,35 @@ export const registerRoutes = async ( auditLogDAL, queueService, projectDAL, - licenseService + licenseService, + auditLogStreamDAL }); const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); - const sapService = secretApprovalPolicyServiceFactory({ + const auditLogStreamService = auditLogStreamServiceFactory({ + licenseService, + permissionService, + auditLogStreamDAL + }); + const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({ projectMembershipDAL, projectEnvDAL, secretApprovalPolicyApproverDAL: sapApproverDAL, permissionService, secretApprovalPolicyDAL }); + const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); + const samlService = samlConfigServiceFactory({ permissionService, orgBotDAL, orgDAL, + orgMembershipDAL, userDAL, + userAliasDAL, samlConfigDAL, - licenseService + licenseService, + tokenService, + smtpService }); const groupService = groupServiceFactory({ userDAL, @@ -288,7 +349,9 @@ export const registerRoutes = async ( licenseService, scimDAL, userDAL, + userAliasDAL, orgDAL, + orgMembershipDAL, projectDAL, projectMembershipDAL, groupDAL, @@ -304,6 +367,7 @@ export const registerRoutes = async ( ldapConfigDAL, ldapGroupMapDAL, orgDAL, + orgMembershipDAL, orgBotDAL, groupDAL, groupProjectDAL, @@ -327,8 +391,13 @@ export const registerRoutes = async ( queueService }); - const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); - const userService = userServiceFactory({ userDAL }); + const userService = userServiceFactory({ + userDAL, + userAliasDAL, + orgMembershipDAL, + tokenService, + smtpService + }); const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL }); const passwordService = authPaswordServiceFactory({ tokenService, @@ -337,6 +406,7 @@ export const registerRoutes = async ( userDAL }); const orgService = orgServiceFactory({ + userAliasDAL, licenseService, samlConfigDAL, orgRoleDAL, @@ -432,7 +502,7 @@ export const registerRoutes = async ( projectBotDAL, projectMembershipDAL, secretApprovalRequestDAL, - secretApprovalSecretDAL: sarSecretDAL, + secretApprovalSecretDAL: secretApprovalRequestSecretDAL, projectUserMembershipRoleDAL }); @@ -469,7 +539,8 @@ export const registerRoutes = async ( permissionService, projectRoleDAL, projectUserMembershipRoleDAL, - identityProjectMembershipRoleDAL + identityProjectMembershipRoleDAL, + projectDAL }); const snapshotService = secretSnapshotServiceFactory({ @@ -497,8 +568,10 @@ export const registerRoutes = async ( folderDAL, folderVersionDAL, projectEnvDAL, - snapshotService + snapshotService, + projectDAL }); + const integrationAuthService = integrationAuthServiceFactory({ integrationAuthDAL, integrationDAL, @@ -527,6 +600,7 @@ export const registerRoutes = async ( secretVersionTagDAL }); const secretImportService = secretImportServiceFactory({ + licenseService, projectEnvDAL, folderDAL, permissionService, @@ -555,22 +629,69 @@ export const registerRoutes = async ( projectEnvDAL, projectBotService }); - const sarService = secretApprovalRequestServiceFactory({ + + const secretSharingService = secretSharingServiceFactory({ permissionService, + secretSharingDAL + }); + + const secretApprovalRequestService = secretApprovalRequestServiceFactory({ + permissionService, + projectBotService, folderDAL, secretDAL, secretTagDAL, - secretApprovalRequestSecretDAL: sarSecretDAL, - secretApprovalRequestReviewerDAL: sarReviewerDAL, + secretApprovalRequestSecretDAL, + secretApprovalRequestReviewerDAL, projectDAL, secretVersionDAL, secretBlindIndexDAL, secretApprovalRequestDAL, - secretService, snapshotService, secretVersionTagDAL, secretQueueService }); + + const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ + accessApprovalPolicyDAL, + accessApprovalPolicyApproverDAL, + permissionService, + projectEnvDAL, + projectMembershipDAL, + projectDAL + }); + + const accessApprovalRequestService = accessApprovalRequestServiceFactory({ + projectDAL, + permissionService, + accessApprovalRequestReviewerDAL, + additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, + projectMembershipDAL, + accessApprovalPolicyDAL, + accessApprovalRequestDAL, + projectEnvDAL, + userDAL, + smtpService, + accessApprovalPolicyApproverDAL + }); + + const secretReplicationService = secretReplicationServiceFactory({ + secretTagDAL, + secretVersionTagDAL, + secretDAL, + secretVersionDAL, + secretImportDAL, + keyStore, + queueService, + folderDAL, + secretApprovalPolicyService, + secretBlindIndexDAL, + secretApprovalRequestDAL, + secretApprovalRequestSecretDAL, + secretQueueService, + projectMembershipDAL, + projectBotService + }); const secretRotationQueue = secretRotationQueueFactory({ telemetryService, secretRotationDAL, @@ -637,6 +758,41 @@ export const registerRoutes = async ( identityUaDAL, licenseService }); + const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({ + identityKubernetesAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + orgBotDAL, + permissionService, + licenseService + }); + const identityGcpAuthService = identityGcpAuthServiceFactory({ + identityGcpAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService + }); + + const identityAwsAuthService = identityAwsAuthServiceFactory({ + identityAccessTokenDAL, + identityAwsAuthDAL, + identityOrgMembershipDAL, + identityDAL, + licenseService, + permissionService + }); + + const identityAzureAuthService = identityAzureAuthServiceFactory({ + identityAzureAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService + }); const dynamicSecretProviders = buildDynamicSecretProviders(); const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ @@ -665,14 +821,21 @@ export const registerRoutes = async ( folderDAL, licenseService }); + const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ + auditLogDAL, + queueService, + identityAccessTokenDAL, + secretSharingDAL + }); await superAdminService.initServerCfg(); // // setup the communication with license key server await licenseService.init(); - await auditLogQueue.startAuditLogPruneJob(); await telemetryQueue.startTelemetryCheck(); + await dailyResourceCleanUp.startCleanUp(); + await kmsService.startService(); // inject all services server.decorate("services", { @@ -694,6 +857,7 @@ export const registerRoutes = async ( projectEnv: projectEnvService, projectRole: projectRoleService, secret: secretService, + secretReplication: secretReplicationService, secretTag: secretTagService, folder: folderService, secretImport: secretImportService, @@ -706,8 +870,14 @@ export const registerRoutes = async ( identityAccessToken: identityAccessTokenService, identityProject: identityProjectService, identityUa: identityUaService, - secretApprovalPolicy: sapService, - secretApprovalRequest: sarService, + identityKubernetesAuth: identityKubernetesAuthService, + identityGcpAuth: identityGcpAuthService, + identityAwsAuth: identityAwsAuthService, + identityAzureAuth: identityAzureAuthService, + accessApprovalPolicy: accessApprovalPolicyService, + accessApprovalRequest: accessApprovalRequestService, + secretApprovalPolicy: secretApprovalPolicyService, + secretApprovalRequest: secretApprovalRequestService, secretRotation: secretRotationService, dynamicSecret: dynamicSecretService, dynamicSecretLease: dynamicSecretLeaseService, @@ -715,6 +885,7 @@ export const registerRoutes = async ( saml: samlService, ldap: ldapService, auditLog: auditLogService, + auditLogStream: auditLogStreamService, secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, @@ -722,7 +893,8 @@ export const registerRoutes = async ( secretBlindIndex: secretBlindIndexService, telemetry: telemetryService, projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService, - identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService + identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService, + secretSharing: secretSharingService }); server.decorate("store", { @@ -747,7 +919,8 @@ export const registerRoutes = async ( emailConfigured: z.boolean().optional(), inviteOnlySignup: z.boolean().optional(), redisConfigured: z.boolean().optional(), - secretScanningConfigured: z.boolean().optional() + secretScanningConfigured: z.boolean().optional(), + samlDefaultOrgSlug: z.string().optional() }) } }, @@ -760,7 +933,8 @@ export const registerRoutes = async ( emailConfigured: cfg.isSmtpConfigured, inviteOnlySignup: Boolean(serverCfg.allowSignUp), redisConfigured: cfg.isRedisConfigured, - secretScanningConfigured: cfg.isSecretScanningConfigured + secretScanningConfigured: cfg.isSecretScanningConfigured, + samlDefaultOrgSlug: cfg.samlDefaultOrgSlug }; } }); diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index eaae4149c..5b0b754f3 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -2,10 +2,14 @@ import { z } from "zod"; import { DynamicSecretsSchema, + IdentityProjectAdditionalPrivilegeSchema, IntegrationAuthsSchema, + ProjectRolesSchema, SecretApprovalPoliciesSchema, UsersSchema } from "@app/db/schemas"; +import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; // sometimes the return data must be santizied to avoid leaking important values // always prefer pick over omit in zod @@ -62,6 +66,61 @@ export const secretRawSchema = z.object({ secretComment: z.string().optional() }); +export const ProjectPermissionSchema = z.object({ + action: z + .nativeEnum(ProjectPermissionActions) + .describe("Describe what action an entity can take. Possible actions: create, edit, delete, and read"), + subject: z + .nativeEnum(ProjectPermissionSub) + .describe("The entity this permission pertains to. Possible options: secrets, environments"), + conditions: z + .object({ + environment: z.string().describe("The environment slug this permission should allow.").optional(), + secretPath: z + .object({ + $glob: z + .string() + .min(1) + .describe("The secret path this permission should allow. Can be a glob pattern such as /folder-name/*/** ") + }) + .optional() + }) + .describe("When specified, only matching conditions will be allowed to access given resource.") + .optional() +}); + +export const ProjectSpecificPrivilegePermissionSchema = z.object({ + actions: z + .nativeEnum(ProjectPermissionActions) + .describe("Describe what action an entity can take. Possible actions: create, edit, delete, and read") + .array() + .min(1), + subject: z + .enum([ProjectPermissionSub.Secrets]) + .describe("The entity this permission pertains to. Possible options: secrets, environments"), + conditions: z + .object({ + environment: z.string().describe("The environment slug this permission should allow."), + secretPath: z + .object({ + $glob: z + .string() + .min(1) + .describe("The secret path this permission should allow. Can be a glob pattern such as /folder-name/*/** ") + }) + .optional() + }) + .describe("When specified, only matching conditions will be allowed to access given resource.") +}); + +export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.extend({ + permissions: UnpackedPermissionSchema.array() +}); + +export const SanitizedRoleSchema = ProjectRolesSchema.extend({ + permissions: UnpackedPermissionSchema.array() +}); + export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ inputIV: true, inputTag: true, @@ -69,3 +128,10 @@ export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ keyEncoding: true, algorithm: true }); + +export const SanitizedAuditLogStreamSchema = z.object({ + id: z.string(), + url: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index e70822128..572409d9b 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -20,16 +20,23 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { schema: { response: { 200: z.object({ - config: SuperAdminSchema.omit({ createdAt: true, updatedAt: true }).merge( - z.object({ isMigrationModeOn: z.boolean() }) - ) + config: SuperAdminSchema.omit({ createdAt: true, updatedAt: true }).extend({ + isMigrationModeOn: z.boolean(), + isSecretScanningDisabled: z.boolean() + }) }) } }, handler: async () => { const config = await getServerCfg(); const serverEnvs = getConfig(); - return { config: { ...config, isMigrationModeOn: serverEnvs.MAINTENANCE_MODE } }; + return { + config: { + ...config, + isMigrationModeOn: serverEnvs.MAINTENANCE_MODE, + isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING + } + }; } }); @@ -42,7 +49,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ allowSignUp: z.boolean().optional(), - allowedSignUpDomain: z.string().optional().nullable() + allowedSignUpDomain: z.string().optional().nullable(), + trustSamlEmails: z.boolean().optional(), + trustLdapEmails: z.boolean().optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/identity-access-token-router.ts b/backend/src/server/routes/v1/identity-access-token-router.ts index 387c54c13..7ed62e679 100644 --- a/backend/src/server/routes/v1/identity-access-token-router.ts +++ b/backend/src/server/routes/v1/identity-access-token-router.ts @@ -36,4 +36,29 @@ export const registerIdentityAccessTokenRouter = async (server: FastifyZodProvid }; } }); + + server.route({ + url: "/token/revoke", + method: "POST", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Revoke access token", + body: z.object({ + accessToken: z.string().trim().describe(UNIVERSAL_AUTH.REVOKE_ACCESS_TOKEN.accessToken) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req) => { + await server.services.identityAccessToken.revokeAccessToken(req.body.accessToken); + return { + message: "Successfully revoked access token" + }; + } + }); }; diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts new file mode 100644 index 000000000..f8c045168 --- /dev/null +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -0,0 +1,269 @@ +import { z } from "zod"; + +import { IdentityAwsAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { AWS_AUTH } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { + validateAccountIds, + validatePrincipalArns +} from "@app/services/identity-aws-auth/identity-aws-auth-validators"; + +export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/aws-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with AWS Auth", + body: z.object({ + identityId: z.string().describe(AWS_AUTH.LOGIN.identityId), + iamHttpRequestMethod: z.string().default("POST").describe(AWS_AUTH.LOGIN.iamHttpRequestMethod), + iamRequestBody: z.string().describe(AWS_AUTH.LOGIN.iamRequestBody), + iamRequestHeaders: z.string().describe(AWS_AUTH.LOGIN.iamRequestHeaders) + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityAwsAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityAwsAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_AWS_AUTH, + metadata: { + identityId: identityAwsAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityAwsAuthId: identityAwsAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/aws-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach AWS Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + stsEndpoint: z.string().trim().min(1).default("https://sts.amazonaws.com/"), + allowedPrincipalArns: validatePrincipalArns, + allowedAccountIds: validateAccountIds, + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z + .number() + .int() + .min(1) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }), + response: { + 200: z.object({ + identityAwsAuth: IdentityAwsAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAwsAuth = await server.services.identityAwsAuth.attachAwsAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAwsAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_AWS_AUTH, + metadata: { + identityId: identityAwsAuth.identityId, + stsEndpoint: identityAwsAuth.stsEndpoint, + allowedPrincipalArns: identityAwsAuth.allowedPrincipalArns, + allowedAccountIds: identityAwsAuth.allowedAccountIds, + accessTokenTTL: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAwsAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAwsAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/aws-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update AWS Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + body: z.object({ + stsEndpoint: z.string().trim().min(1).optional(), + allowedPrincipalArns: validatePrincipalArns, + allowedAccountIds: validateAccountIds, + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .optional() + }), + response: { + 200: z.object({ + identityAwsAuth: IdentityAwsAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAwsAuth = await server.services.identityAwsAuth.updateAwsAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAwsAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_AWS_AUTH, + metadata: { + identityId: identityAwsAuth.identityId, + stsEndpoint: identityAwsAuth.stsEndpoint, + allowedPrincipalArns: identityAwsAuth.allowedPrincipalArns, + allowedAccountIds: identityAwsAuth.allowedAccountIds, + accessTokenTTL: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAwsAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAwsAuth }; + } + }); + + server.route({ + method: "GET", + url: "/aws-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve AWS Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityAwsAuth: IdentityAwsAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAwsAuth = await server.services.identityAwsAuth.getAwsAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAwsAuth.orgId, + event: { + type: EventType.GET_IDENTITY_AWS_AUTH, + metadata: { + identityId: identityAwsAuth.identityId + } + } + }); + return { identityAwsAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-azure-auth-router.ts b/backend/src/server/routes/v1/identity-azure-auth-router.ts new file mode 100644 index 000000000..d10cd131b --- /dev/null +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -0,0 +1,262 @@ +import { z } from "zod"; + +import { IdentityAzureAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-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"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { validateAzureAuthField } from "@app/services/identity-azure-auth/identity-azure-auth-validators"; + +export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/azure-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with Azure Auth", + body: z.object({ + identityId: z.string(), + jwt: z.string() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityAzureAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityAzureAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg.orgId, + event: { + type: EventType.LOGIN_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityAzureAuthId: identityAzureAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach Azure Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + tenantId: z.string().trim(), + resource: z.string().trim(), + allowedServicePrincipalIds: validateAzureAuthField, + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z + .number() + .int() + .min(1) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.attachAzureAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId, + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAzureAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAzureAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update Azure Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + tenantId: z.string().trim().optional(), + resource: z.string().trim().optional(), + allowedServicePrincipalIds: validateAzureAuthField.optional(), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .optional() + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.updateAzureAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId, + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAzureAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAzureAuth }; + } + }); + + server.route({ + method: "GET", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve Azure Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.getAzureAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.GET_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId + } + } + }); + + return { identityAzureAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts new file mode 100644 index 000000000..34940eb13 --- /dev/null +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -0,0 +1,268 @@ +import { z } from "zod"; + +import { IdentityGcpAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-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"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { validateGcpAuthField } from "@app/services/identity-gcp-auth/identity-gcp-auth-validators"; + +export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/gcp-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with GCP Auth", + body: z.object({ + identityId: z.string(), + jwt: z.string() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityGcpAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityGcpAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityGcpAuthId: identityGcpAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/gcp-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach GCP Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + type: z.enum(["iam", "gce"]), + allowedServiceAccounts: validateGcpAuthField, + allowedProjects: validateGcpAuthField, + allowedZones: validateGcpAuthField, + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z + .number() + .int() + .min(1) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }), + response: { + 200: z.object({ + identityGcpAuth: IdentityGcpAuthsSchema + }) + } + }, + handler: async (req) => { + const identityGcpAuth = await server.services.identityGcpAuth.attachGcpAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityGcpAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId, + type: identityGcpAuth.type, + allowedServiceAccounts: identityGcpAuth.allowedServiceAccounts, + allowedProjects: identityGcpAuth.allowedProjects, + allowedZones: identityGcpAuth.allowedZones, + accessTokenTTL: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityGcpAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityGcpAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/gcp-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update GCP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + type: z.enum(["iam", "gce"]).optional(), + allowedServiceAccounts: validateGcpAuthField.optional(), + allowedProjects: validateGcpAuthField.optional(), + allowedZones: validateGcpAuthField.optional(), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .optional() + }), + response: { + 200: z.object({ + identityGcpAuth: IdentityGcpAuthsSchema + }) + } + }, + handler: async (req) => { + const identityGcpAuth = await server.services.identityGcpAuth.updateGcpAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityGcpAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId, + type: identityGcpAuth.type, + allowedServiceAccounts: identityGcpAuth.allowedServiceAccounts, + allowedProjects: identityGcpAuth.allowedProjects, + allowedZones: identityGcpAuth.allowedZones, + accessTokenTTL: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityGcpAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityGcpAuth }; + } + }); + + server.route({ + method: "GET", + url: "/gcp-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve GCP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityGcpAuth: IdentityGcpAuthsSchema + }) + } + }, + handler: async (req) => { + const identityGcpAuth = await server.services.identityGcpAuth.getGcpAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityGcpAuth.orgId, + event: { + type: EventType.GET_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId + } + } + }); + + return { identityGcpAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts new file mode 100644 index 000000000..d20ea0edc --- /dev/null +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -0,0 +1,283 @@ +import { z } from "zod"; + +import { IdentityKubernetesAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-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"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; + +const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.omit({ + encryptedCaCert: true, + caCertIV: true, + caCertTag: true, + encryptedTokenReviewerJwt: true, + tokenReviewerJwtIV: true, + tokenReviewerJwtTag: true +}).extend({ + caCert: z.string(), + tokenReviewerJwt: z.string() +}); + +export const registerIdentityKubernetesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/kubernetes-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with Kubernetes Auth", + body: z.object({ + identityId: z.string().trim(), + jwt: z.string().trim() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityKubernetesAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityKubernetesAuth.login({ + identityId: req.body.identityId, + jwt: req.body.jwt + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_KUBERNETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityKubernetesAuthId: identityKubernetesAuth.id + } + } + }); + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/kubernetes-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach Kubernetes Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + kubernetesHost: z.string().trim().min(1), + caCert: z.string().trim().default(""), + tokenReviewerJwt: z.string().trim().min(1), + allowedNamespaces: z.string(), // TODO: validation + allowedNames: z.string(), + allowedAudience: z.string(), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z + .number() + .int() + .min(1) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }), + response: { + 200: z.object({ + identityKubernetesAuth: IdentityKubernetesAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityKubernetesAuth = await server.services.identityKubernetesAuth.attachKubernetesAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityKubernetesAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_KUBERNETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId, + kubernetesHost: identityKubernetesAuth.kubernetesHost, + allowedNamespaces: identityKubernetesAuth.allowedNamespaces, + allowedNames: identityKubernetesAuth.allowedNames, + accessTokenTTL: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityKubernetesAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityKubernetesAuth: IdentityKubernetesAuthResponseSchema.parse(identityKubernetesAuth) }; + } + }); + + server.route({ + method: "PATCH", + url: "/kubernetes-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update Kubernetes Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + body: z.object({ + kubernetesHost: z.string().trim().min(1).optional(), + caCert: z.string().trim().optional(), + tokenReviewerJwt: z.string().trim().min(1).optional(), + allowedNamespaces: z.string().optional(), // TODO: validation + allowedNames: z.string().optional(), + allowedAudience: z.string().optional(), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .optional() + }), + response: { + 200: z.object({ + identityKubernetesAuth: IdentityKubernetesAuthsSchema + }) + } + }, + handler: async (req) => { + const identityKubernetesAuth = await server.services.identityKubernetesAuth.updateKubernetesAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityKubernetesAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_KUBENETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId, + kubernetesHost: identityKubernetesAuth.kubernetesHost, + allowedNamespaces: identityKubernetesAuth.allowedNamespaces, + allowedNames: identityKubernetesAuth.allowedNames, + accessTokenTTL: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityKubernetesAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityKubernetesAuth }; + } + }); + + server.route({ + method: "GET", + url: "/kubernetes-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve Kubernetes Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityKubernetesAuth: IdentityKubernetesAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityKubernetesAuth = await server.services.identityKubernetesAuth.getKubernetesAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityKubernetesAuth.orgId, + event: { + type: EventType.GET_IDENTITY_KUBERNETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId + } + } + }); + + return { identityKubernetesAuth: IdentityKubernetesAuthResponseSchema.parse(identityKubernetesAuth) }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index fbc68d974..cbf67ce79 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -2,6 +2,10 @@ import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; +import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; +import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; +import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; +import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityUaRouter } from "./identity-ua"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; @@ -15,6 +19,7 @@ import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; import { registerSecretFolderRouter } from "./secret-folder-router"; import { registerSecretImportRouter } from "./secret-import-router"; +import { registerSecretSharingRouter } from "./secret-sharing-router"; import { registerSecretTagRouter } from "./secret-tag-router"; import { registerSsoRouter } from "./sso-router"; import { registerUserActionRouter } from "./user-action-router"; @@ -27,7 +32,11 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { async (authRouter) => { await authRouter.register(registerAuthRoutes); await authRouter.register(registerIdentityUaRouter); + await authRouter.register(registerIdentityKubernetesRouter); + await authRouter.register(registerIdentityGcpAuthRouter); await authRouter.register(registerIdentityAccessTokenRouter); + await authRouter.register(registerIdentityAwsAuthRouter); + await authRouter.register(registerIdentityAzureAuthRouter); }, { prefix: "/auth" } ); @@ -57,4 +66,5 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" }); await server.register(registerWebhookRouter, { prefix: "/webhooks" }); await server.register(registerIdentityRouter, { prefix: "/identities" }); + await server.register(registerSecretSharingRouter, { prefix: "/secret-sharing" }); }; diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index d9db7404e..899c1cac8 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -330,7 +330,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) teams: z .object({ name: z.string(), - id: z.string().optional() + id: z.string() }) .array() }) diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index f908aa1fc..bdb58aa8b 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -8,6 +8,7 @@ import { writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { IntegrationMappingBehavior } from "@app/services/integration-auth/integration-list"; import { PostHogEventTypes, TIntegrationCreatedEvent } from "@app/services/telemetry/telemetry-types"; export const registerIntegrationRouter = async (server: FastifyZodProvider) => { @@ -41,6 +42,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { targetService: z.string().trim().optional().describe(INTEGRATION.CREATE.targetService), targetServiceId: z.string().trim().optional().describe(INTEGRATION.CREATE.targetServiceId), owner: z.string().trim().optional().describe(INTEGRATION.CREATE.owner), + url: z.string().trim().optional().describe(INTEGRATION.CREATE.url), path: z.string().trim().optional().describe(INTEGRATION.CREATE.path), region: z.string().trim().optional().describe(INTEGRATION.CREATE.region), scope: z.string().trim().optional().describe(INTEGRATION.CREATE.scope), @@ -49,6 +51,10 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + mappingBehavior: z + .nativeEnum(IntegrationMappingBehavior) + .optional() + .describe(INTEGRATION.CREATE.metadata.mappingBehavior), shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), secretGCPLabel: z .object({ @@ -66,7 +72,8 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { ) .optional() .describe(INTEGRATION.CREATE.metadata.secretAWSTag), - kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId) + kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), + shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete) }) .default({}) }), @@ -142,8 +149,8 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { integrationId: z.string().trim().describe(INTEGRATION.UPDATE.integrationId) }), body: z.object({ - app: z.string().trim().describe(INTEGRATION.UPDATE.app), - appId: z.string().trim().describe(INTEGRATION.UPDATE.appId), + app: z.string().trim().optional().describe(INTEGRATION.UPDATE.app), + appId: z.string().trim().optional().describe(INTEGRATION.UPDATE.appId), isActive: z.boolean().describe(INTEGRATION.UPDATE.isActive), secretPath: z .string() @@ -153,7 +160,34 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { .describe(INTEGRATION.UPDATE.secretPath), targetEnvironment: z.string().trim().describe(INTEGRATION.UPDATE.targetEnvironment), owner: z.string().trim().describe(INTEGRATION.UPDATE.owner), - environment: z.string().trim().describe(INTEGRATION.UPDATE.environment) + environment: z.string().trim().describe(INTEGRATION.UPDATE.environment), + metadata: z + .object({ + secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), + secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), + initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + mappingBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.mappingBehavior), + shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), + secretGCPLabel: z + .object({ + labelName: z.string(), + labelValue: z.string() + }) + .optional() + .describe(INTEGRATION.CREATE.metadata.secretGCPLabel), + secretAWSTag: z + .array( + z.object({ + key: z.string(), + value: z.string() + }) + ) + .optional() + .describe(INTEGRATION.CREATE.metadata.secretAWSTag), + kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), + shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete) + }) + .optional() }), response: { 200: z.object({ @@ -235,5 +269,64 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { } }); - // TODO(akhilmhdh-pg): manual sync + server.route({ + method: "POST", + url: "/:integrationId/sync", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Manually trigger sync of an integration by integration id", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + integrationId: z.string().trim().describe(INTEGRATION.SYNC.integrationId) + }), + response: { + 200: z.object({ + integration: IntegrationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const integration = await server.services.integration.syncIntegration({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.integrationId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: integration.projectId, + event: { + type: EventType.MANUAL_SYNC_INTEGRATION, + // eslint-disable-next-line + metadata: shake({ + integrationId: integration.id, + integration: integration.integration, + environment: integration.environment.slug, + secretPath: integration.secretPath, + url: integration.url, + app: integration.app, + appId: integration.appId, + targetEnvironment: integration.targetEnvironment, + targetEnvironmentId: integration.targetEnvironmentId, + targetService: integration.targetService, + targetServiceId: integration.targetServiceId, + path: integration.path, + region: integration.region + // eslint-disable-next-line + }) as any + } + }); + + return { integration }; + } + }); }; diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index d2ed649db..6bbb8d7ef 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -9,7 +9,7 @@ import { UsersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { PROJECTS } from "@app/lib/api-docs"; +import { PROJECT_USERS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -30,7 +30,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider } ], params: z.object({ - workspaceId: z.string().trim().describe(PROJECTS.GET_USER_MEMBERSHIPS.workspaceId) + workspaceId: z.string().trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIPS.workspaceId) }), response: { 200: z.object({ @@ -74,6 +74,66 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider } }); + server.route({ + method: "POST", + url: "/:workspaceId/memberships/details", + config: { + rateLimit: readLimit + }, + schema: { + description: "Return project user memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.workspaceId) + }), + body: z.object({ + username: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.username) + }), + response: { + 200: z.object({ + membership: ProjectMembershipsSchema.extend({ + user: UsersSchema.pick({ + email: true, + firstName: true, + lastName: true, + id: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ) + }).omit({ createdAt: true, updatedAt: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const membership = await server.services.projectMembership.getProjectMembershipByUsername({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + username: req.body.username + }); + return { membership }; + } + }); + server.route({ method: "POST", url: "/:workspaceId/memberships", @@ -142,8 +202,8 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider } ], params: z.object({ - workspaceId: z.string().trim().describe(PROJECTS.UPDATE_USER_MEMBERSHIP.workspaceId), - membershipId: z.string().trim().describe(PROJECTS.UPDATE_USER_MEMBERSHIP.membershipId) + workspaceId: z.string().trim().describe(PROJECT_USERS.UPDATE_USER_MEMBERSHIP.workspaceId), + membershipId: z.string().trim().describe(PROJECT_USERS.UPDATE_USER_MEMBERSHIP.membershipId) }), body: z.object({ roles: z @@ -164,7 +224,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider ) .min(1) .refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least one long lived role is required") - .describe(PROJECTS.UPDATE_USER_MEMBERSHIP.roles) + .describe(PROJECT_USERS.UPDATE_USER_MEMBERSHIP.roles) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index 3b8d0988f..1a1747f64 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -127,6 +127,70 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => } }); + server.route({ + url: "/batch", + method: "PATCH", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Update folders by batch", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectSlug: z.string().trim().describe(FOLDERS.UPDATE.projectSlug), + folders: z + .object({ + id: z.string().describe(FOLDERS.UPDATE.folderId), + environment: z.string().trim().describe(FOLDERS.UPDATE.environment), + name: z.string().trim().describe(FOLDERS.UPDATE.name), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(FOLDERS.UPDATE.path) + }) + .array() + .min(1) + }), + response: { + 200: z.object({ + folders: SecretFoldersSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { newFolders, oldFolders, projectId } = await server.services.folder.updateManyFolders({ + ...req.body, + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await Promise.all( + req.body.folders.map(async (folder, index) => { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.UPDATE_FOLDER, + metadata: { + environment: oldFolders[index].envId, + folderId: oldFolders[index].id, + folderPath: folder.path, + newFolderName: newFolders[index].name, + oldFolderName: oldFolders[index].name + } + } + }); + }) + ); + + return { folders: newFolders }; + } + }); + // TODO(daniel): Expose this route in api reference and write docs for it. server.route({ method: "DELETE", diff --git a/backend/src/server/routes/v1/secret-import-router.ts b/backend/src/server/routes/v1/secret-import-router.ts index d036fdbdd..50311273c 100644 --- a/backend/src/server/routes/v1/secret-import-router.ts +++ b/backend/src/server/routes/v1/secret-import-router.ts @@ -29,7 +29,8 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => import: z.object({ environment: z.string().trim().describe(SECRET_IMPORTS.CREATE.import.environment), path: z.string().trim().transform(removeTrailingSlash).describe(SECRET_IMPORTS.CREATE.import.path) - }) + }), + isReplication: z.boolean().default(false) }), response: { 200: z.object({ @@ -210,6 +211,49 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => } }); + server.route({ + method: "POST", + url: "/:secretImportId/replication-resync", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Resync secret replication of secret imports", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretImportId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.secretImportId) + }), + body: z.object({ + workspaceId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.workspaceId), + environment: z.string().trim().describe(SECRET_IMPORTS.UPDATE.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.UPDATE.path) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { message } = await server.services.secretImport.resyncSecretImportReplication({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.secretImportId, + ...req.body, + projectId: req.body.workspaceId + }); + + return { message }; + } + }); + server.route({ method: "GET", url: "/", @@ -232,11 +276,9 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => 200: z.object({ message: z.string(), secretImports: SecretImportsSchema.omit({ importEnv: true }) - .merge( - z.object({ - importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) - }) - ) + .extend({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) + }) .array() }) } diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts new file mode 100644 index 000000000..6cb551698 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; + +import { SecretSharingSchema } from "@app/db/schemas"; +import { publicEndpointLimit, 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 registerSecretSharingRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.array(SecretSharingSchema) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const sharedSecrets = await req.server.services.secretSharing.getSharedSecrets({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return sharedSecrets; + } + }); + + server.route({ + method: "GET", + url: "/public/:id", + config: { + rateLimit: publicEndpointLimit + }, + schema: { + params: z.object({ + id: z.string().uuid() + }), + querystring: z.object({ + hashedHex: z.string() + }), + response: { + 200: SecretSharingSchema.pick({ + encryptedValue: true, + iv: true, + tag: true, + expiresAt: true, + expiresAfterViews: true + }) + } + }, + handler: async (req) => { + const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretByIdAndHashedHex( + req.params.id, + req.query.hashedHex + ); + if (!sharedSecret) return undefined; + return { + encryptedValue: sharedSecret.encryptedValue, + iv: sharedSecret.iv, + tag: sharedSecret.tag, + expiresAt: sharedSecret.expiresAt, + expiresAfterViews: sharedSecret.expiresAfterViews + }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + encryptedValue: z.string(), + iv: z.string(), + tag: z.string(), + hashedHex: z.string(), + expiresAt: z + .string() + .refine((date) => date === undefined || new Date(date) > new Date(), "Expires at should be a future date"), + expiresAfterViews: z.number() + }), + response: { + 200: z.object({ + id: z.string().uuid() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body; + const sharedSecret = await req.server.services.secretSharing.createSharedSecret({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + encryptedValue, + iv, + tag, + hashedHex, + expiresAt: new Date(expiresAt), + expiresAfterViews + }); + return { id: sharedSecret.id }; + } + }); + + server.route({ + method: "DELETE", + url: "/:sharedSecretId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + sharedSecretId: z.string().uuid() + }), + response: { + 200: SecretSharingSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { sharedSecretId } = req.params; + const deletedSharedSecret = await req.server.services.secretSharing.deleteSharedSecretById({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + sharedSecretId + }); + + return { ...deletedSharedSecret }; + } + }); +}; diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index bdede8a3a..3d9f531b9 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -1,11 +1,15 @@ import { z } from "zod"; import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; -import { readLimit } from "@app/server/config/rateLimiter"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { authRateLimit, 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 registerUserRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + server.route({ method: "GET", url: "/", @@ -25,4 +29,29 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { return { user }; } }); + + server.route({ + method: "GET", + url: "/:userId/unlock", + config: { + rateLimit: authRateLimit + }, + schema: { + querystring: z.object({ + token: z.string().trim() + }), + params: z.object({ + userId: z.string() + }) + }, + handler: async (req, res) => { + try { + await server.services.user.unlockUser(req.params.userId, req.query.token); + } catch (err) { + logger.error(`User unlock failed for ${req.params.userId}`); + logger.error(err); + } + return res.redirect(`${appCfg.SITE_URL}/login`); + } + }); }; diff --git a/backend/src/server/routes/v2/identity-project-router.ts b/backend/src/server/routes/v2/identity-project-router.ts index a4068053f..d259a46fd 100644 --- a/backend/src/server/routes/v2/identity-project-router.ts +++ b/backend/src/server/routes/v2/identity-project-router.ts @@ -7,7 +7,8 @@ import { ProjectMembershipRole, ProjectUserMembershipRolesSchema } from "@app/db/schemas"; -import { PROJECTS } from "@app/lib/api-docs"; +import { PROJECT_IDENTITIES } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -22,12 +23,48 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Create project identity membership", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ projectId: z.string().trim(), identityId: z.string().trim() }), body: z.object({ - role: z.string().trim().min(1).default(ProjectMembershipRole.NoAccess) + // @depreciated + role: z.string().trim().optional().default(ProjectMembershipRole.NoAccess), + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryMode: z + .nativeEnum(ProjectUserMembershipTemporaryMode) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }) + ]) + ) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.description) + .optional() }), response: { 200: z.object({ @@ -36,6 +73,9 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { + const { role, roles } = req.body; + if (!role && !roles) throw new BadRequestError({ message: "You must provide either role or roles field" }); + const identityMembership = await server.services.identityProject.createProjectIdentity({ actor: req.permission.type, actorId: req.permission.id, @@ -43,7 +83,7 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) actorOrgId: req.permission.orgId, identityId: req.params.identityId, projectId: req.params.projectId, - role: req.body.role + roles: roles || [{ role }] }); return { identityMembership }; } @@ -64,28 +104,39 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) } ], params: z.object({ - projectId: z.string().trim().describe(PROJECTS.UPDATE_IDENTITY_MEMBERSHIP.projectId), - identityId: z.string().trim().describe(PROJECTS.UPDATE_IDENTITY_MEMBERSHIP.identityId) + projectId: z.string().trim().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.identityId) }), body: z.object({ roles: z .array( z.union([ z.object({ - role: z.string(), - isTemporary: z.literal(false).default(false) + role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary) }), z.object({ - role: z.string(), - isTemporary: z.literal(true), - temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), - temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), - temporaryAccessStartTime: z.string().datetime() + role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary), + temporaryMode: z + .nativeEnum(ProjectUserMembershipTemporaryMode) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) }) ]) ) .min(1) - .describe(PROJECTS.UPDATE_IDENTITY_MEMBERSHIP.roles) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.description) }), response: { 200: z.object({ @@ -122,8 +173,8 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) } ], params: z.object({ - projectId: z.string().trim().describe(PROJECTS.DELETE_IDENTITY_MEMBERSHIP.projectId), - identityId: z.string().trim().describe(PROJECTS.DELETE_IDENTITY_MEMBERSHIP.identityId) + projectId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.identityId) }), response: { 200: z.object({ @@ -159,7 +210,7 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) } ], params: z.object({ - projectId: z.string().trim().describe(PROJECTS.LIST_IDENTITY_MEMBERSHIPS.projectId) + projectId: z.string().trim().describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.projectId) }), response: { 200: z.object({ @@ -200,4 +251,61 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) return { identityMemberships }; } }); + + server.route({ + method: "GET", + url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Return project identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITIES.GET_IDENTITY_MEMBERSHIP_BY_ID.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.GET_IDENTITY_MEMBERSHIP_BY_ID.identityId) + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.identityProject.getProjectIdentityByIdentityId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + identityId: req.params.identityId + }); + return { identityMembership }; + } + }); }; diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts index 973804c7c..1c685866d 100644 --- a/backend/src/server/routes/v2/mfa-router.ts +++ b/backend/src/server/routes/v2/mfa-router.ts @@ -2,7 +2,7 @@ import jwt from "jsonwebtoken"; import { z } from "zod"; import { getConfig } from "@app/lib/config/env"; -import { writeLimit } from "@app/server/config/rateLimiter"; +import { mfaRateLimit } from "@app/server/config/rateLimiter"; import { AuthModeMfaJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; export const registerMfaRouter = async (server: FastifyZodProvider) => { @@ -34,7 +34,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/mfa/send", config: { - rateLimit: writeLimit + rateLimit: mfaRateLimit }, schema: { response: { @@ -53,7 +53,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { url: "/mfa/verify", method: "POST", config: { - rateLimit: writeLimit + rateLimit: mfaRateLimit }, schema: { body: z.object({ diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index e8204222e..07074eba3 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -76,6 +76,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { .object({ id: z.string(), name: z.string(), + slug: z.string(), organization: z.string(), environments: z .object({ diff --git a/backend/src/server/routes/v2/project-membership-router.ts b/backend/src/server/routes/v2/project-membership-router.ts index 96471dc2c..a9592faab 100644 --- a/backend/src/server/routes/v2/project-membership-router.ts +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { ProjectMembershipsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { PROJECTS } from "@app/lib/api-docs"; +import { PROJECT_USERS } from "@app/lib/api-docs"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -22,11 +22,11 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider } ], params: z.object({ - projectId: z.string().describe(PROJECTS.INVITE_MEMBER.projectId) + projectId: z.string().describe(PROJECT_USERS.INVITE_MEMBER.projectId) }), body: z.object({ - emails: z.string().email().array().default([]).describe(PROJECTS.INVITE_MEMBER.emails), - usernames: z.string().array().default([]).describe(PROJECTS.INVITE_MEMBER.usernames) + emails: z.string().email().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.emails), + usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames) }), response: { 200: z.object({ @@ -77,11 +77,11 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider } ], params: z.object({ - projectId: z.string().describe(PROJECTS.REMOVE_MEMBER.projectId) + projectId: z.string().describe(PROJECT_USERS.REMOVE_MEMBER.projectId) }), body: z.object({ - emails: z.string().email().array().default([]).describe(PROJECTS.REMOVE_MEMBER.emails), - usernames: z.string().array().default([]).describe(PROJECTS.REMOVE_MEMBER.usernames) + emails: z.string().email().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.emails), + usernames: z.string().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.usernames) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index d1e80702f..1f15008c7 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -2,11 +2,52 @@ import { z } from "zod"; import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; -import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMethod, AuthMode } from "@app/services/auth/auth-type"; export const registerUserRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/me/emails/code", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + username: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + handler: async (req) => { + await server.services.user.sendEmailVerificationCode(req.body.username); + return {}; + } + }); + + server.route({ + method: "POST", + url: "/me/emails/verify", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + username: z.string().trim(), + code: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + handler: async (req) => { + await server.services.user.verifyEmailVerificationCode(req.body.username, req.body.code); + return {}; + } + }); + server.route({ method: "PATCH", url: "/me/mfa", diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 955aa01be..05db617b9 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -9,7 +9,6 @@ import { ServiceTokenScopes } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { CommitType } from "@app/ee/services/secret-approval-request/secret-approval-request-types"; import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs"; import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; @@ -19,6 +18,7 @@ import { getUserAgentType } from "@app/server/plugins/audit-log"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { ProjectFilterType } from "@app/services/project/project-types"; +import { SecretOperations } from "@app/services/secret/secret-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { secretRawSchema } from "../sanitizedSchemas"; @@ -166,6 +166,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { workspaceSlug: z.string().trim().optional().describe(RAW_SECRETS.LIST.workspaceSlug), environment: z.string().trim().optional().describe(RAW_SECRETS.LIST.environment), secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.LIST.secretPath), + expandSecretReferences: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + .describe(RAW_SECRETS.LIST.expand), recursive: z .enum(["true", "false"]) .default("false") @@ -233,6 +238,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorOrgId: req.permission.orgId, environment, + expandSecretReferences: req.query.expandSecretReferences, actorAuthMethod: req.permission.authMethod, projectId: workspaceId, path: secretPath, @@ -287,6 +293,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }), querystring: z.object({ workspaceId: z.string().trim().optional().describe(RAW_SECRETS.GET.workspaceId), + workspaceSlug: z.string().trim().optional().describe(RAW_SECRETS.GET.workspaceSlug), environment: z.string().trim().optional().describe(RAW_SECRETS.GET.environment), secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.GET.secretPath), version: z.coerce.number().optional().describe(RAW_SECRETS.GET.version), @@ -305,6 +312,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const { workspaceSlug } = req.query; let { secretPath, environment, workspaceId } = req.query; if (req.auth.actor === ActorType.SERVICE) { const scope = ServiceTokenScopes.parse(req.auth.serviceToken.scopes); @@ -316,7 +324,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } } - if (!workspaceId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" }); + if (!environment) throw new BadRequestError({ message: "Missing environment" }); + if (!workspaceId && !workspaceSlug) + throw new BadRequestError({ message: "You must provide workspaceSlug or workspaceId" }); const secret = await server.services.secret.getSecretByNameRaw({ actorId: req.permission.id, @@ -325,6 +335,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, environment, projectId: workspaceId, + projectSlug: workspaceSlug, path: secretPath, secretName: req.params.secretName, type: req.query.type, @@ -333,7 +344,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); await server.services.auditLog.createAuditLog({ - projectId: req.query.workspaceId, + projectId: secret.workspace, ...req.auditLogInfo, event: { type: EventType.GET_SECRET, @@ -352,7 +363,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: 1, - workspaceId, + workspaceId: secret.workspace, environment, secretPath: req.query.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -891,7 +902,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Create]: [ + [SecretOperations.Create]: [ { secretName: req.params.secretName, secretValueCiphertext, @@ -1073,7 +1084,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Update]: [ + [SecretOperations.Update]: [ { secretName: req.params.secretName, newSecretName, @@ -1223,7 +1234,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Delete]: [ + [SecretOperations.Delete]: [ { secretName: req.params.secretName } @@ -1353,7 +1364,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Create]: inputSecrets + [SecretOperations.Create]: inputSecrets } }); @@ -1480,7 +1491,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Update]: inputSecrets.filter(({ type }) => type === "shared") + [SecretOperations.Update]: inputSecrets.filter(({ type }) => type === "shared") } }); @@ -1595,7 +1606,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Delete]: inputSecrets.filter(({ type }) => type === "shared") + [SecretOperations.Delete]: inputSecrets.filter(({ type }) => type === "shared") } }); await server.services.auditLog.createAuditLog({ @@ -1915,4 +1926,41 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { return { secrets }; } }); + + server.route({ + method: "POST", + url: "/backfill-secret-references", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Backfill secret references", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { projectId } = req.body; + const message = await server.services.secret.backfillSecretReferences({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId + }); + + return message; + } + }); }; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 59f336e5a..b1f8aa2f6 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -13,8 +13,9 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo type TAuthTokenServiceFactoryDep = { tokenDAL: TTokenDALFactory; - userDAL: Pick; + userDAL: Pick; }; + export type TAuthTokenServiceFactory = ReturnType; export const getTokenConfig = (tokenType: TokenType) => { @@ -27,10 +28,17 @@ export const getTokenConfig = (tokenType: TokenType) => { const expiresAt = new Date(new Date().getTime() + 86400000); return { token, expiresAt }; } + case TokenType.TOKEN_EMAIL_VERIFICATION: { + // generate random 6-digit code + const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); + const triesLeft = 3; + const expiresAt = new Date(new Date().getTime() + 86400000); + return { token, triesLeft, expiresAt }; + } case TokenType.TOKEN_EMAIL_MFA: { // generate random 6-digit code const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); - const triesLeft = 5; + const triesLeft = 3; const expiresAt = new Date(new Date().getTime() + 300000); return { token, triesLeft, expiresAt }; } @@ -46,6 +54,11 @@ export const getTokenConfig = (tokenType: TokenType) => { const expiresAt = new Date(new Date().getTime() + 86400000); return { token, expiresAt }; } + case TokenType.TOKEN_USER_UNLOCK: { + const token = crypto.randomBytes(16).toString("hex"); + const expiresAt = new Date(new Date().getTime() + 259200000); + return { token, expiresAt }; + } default: { const token = crypto.randomBytes(16).toString("hex"); const expiresAt = new Date(); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 74787f4ac..8917bd672 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -1,8 +1,10 @@ export enum TokenType { TOKEN_EMAIL_CONFIRMATION = "emailConfirmation", + TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified TOKEN_EMAIL_MFA = "emailMfa", TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", - TOKEN_EMAIL_PASSWORD_RESET = "passwordReset" + TOKEN_EMAIL_PASSWORD_RESET = "passwordReset", + TOKEN_USER_UNLOCK = "userUnlock" } export type TCreateTokenForUserDTO = { diff --git a/backend/src/services/auth/auth-fns.ts b/backend/src/services/auth/auth-fns.ts index 80fb0b325..ecbf73a48 100644 --- a/backend/src/services/auth/auth-fns.ts +++ b/backend/src/services/auth/auth-fns.ts @@ -44,3 +44,27 @@ export const validateSignUpAuthorization = (token: string, userId: string, valid if (decodedToken.authTokenType !== AuthTokenType.SIGNUP_TOKEN) throw new UnauthorizedError(); if (decodedToken.userId !== userId) throw new UnauthorizedError(); }; + +export const enforceUserLockStatus = (isLocked: boolean, temporaryLockDateEnd?: Date | null) => { + if (isLocked) { + throw new UnauthorizedError({ + name: "User Locked", + message: + "User is locked due to multiple failed login attempts. An email has been sent to you in order to unlock your account. You can also reset your password to unlock your account." + }); + } + + if (temporaryLockDateEnd) { + const timeDiff = new Date().getTime() - temporaryLockDateEnd.getTime(); + if (timeDiff < 0) { + const secondsDiff = (-1 * timeDiff) / 1000; + const timeDisplay = + secondsDiff > 60 ? `${Math.ceil(secondsDiff / 60)} minutes` : `${Math.ceil(secondsDiff)} seconds`; + + throw new UnauthorizedError({ + name: "User Locked", + message: `User is temporary locked due to multiple failed login attempts. Try again after ${timeDisplay}. You can also reset your password now to proceed.` + }); + } + } +}; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 5d81eaae1..cbf43b245 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -4,7 +4,7 @@ import { TUsers, UserDeviceSchema } from "@app/db/schemas"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, DatabaseError, UnauthorizedError } from "@app/lib/errors"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TTokenDALFactory } from "../auth-token/auth-token-dal"; @@ -13,7 +13,7 @@ import { TokenType } from "../auth-token/auth-token-types"; import { TOrgDALFactory } from "../org/org-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; -import { validateProviderAuthToken } from "./auth-fns"; +import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns"; import { TLoginClientProofDTO, TLoginGenServerPublicKeyDTO, @@ -212,6 +212,9 @@ export const authLoginServiceFactory = ({ }); // send multi factor auth token if they it enabled if (userEnc.isMfaEnabled && userEnc.email) { + const user = await userDAL.findById(userEnc.userId); + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); + const mfaToken = jwt.sign( { authMethod, @@ -300,28 +303,111 @@ export const authLoginServiceFactory = ({ const resendMfaToken = async (userId: string) => { const user = await userDAL.findById(userId); if (!user || !user.email) return; + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); await sendUserMfaCode({ userId: user.id, email: user.email }); }; + const processFailedMfaAttempt = async (userId: string) => { + try { + const updatedUser = await userDAL.transaction(async (tx) => { + const PROGRESSIVE_DELAY_INTERVAL = 3; + const user = await userDAL.updateById(userId, { $incr: { consecutiveFailedMfaAttempts: 1 } }, tx); + + if (!user) { + throw new Error("User not found"); + } + + const progressiveDelaysInMins = [5, 30, 60]; + + // lock user when failed attempt exceeds threshold + if ( + user.consecutiveFailedMfaAttempts && + user.consecutiveFailedMfaAttempts >= PROGRESSIVE_DELAY_INTERVAL * (progressiveDelaysInMins.length + 1) + ) { + return userDAL.updateById( + userId, + { + isLocked: true, + temporaryLockDateEnd: null + }, + tx + ); + } + + // delay user only when failed MFA attempts is a multiple of configured delay interval + if (user.consecutiveFailedMfaAttempts && user.consecutiveFailedMfaAttempts % PROGRESSIVE_DELAY_INTERVAL === 0) { + const delayIndex = user.consecutiveFailedMfaAttempts / PROGRESSIVE_DELAY_INTERVAL - 1; + return userDAL.updateById( + userId, + { + temporaryLockDateEnd: new Date(new Date().getTime() + progressiveDelaysInMins[delayIndex] * 60 * 1000) + }, + tx + ); + } + + return user; + }); + + return updatedUser; + } catch (error) { + throw new DatabaseError({ error, name: "Process failed MFA Attempt" }); + } + }; + /* * Multi factor authentication verification of code * Third step of login in which user completes with mfa * */ const verifyMfaToken = async ({ userId, mfaToken, mfaJwtToken, ip, userAgent, orgId }: TVerifyMfaTokenDTO) => { - await tokenService.validateTokenForUser({ - type: TokenType.TOKEN_EMAIL_MFA, - userId, - code: mfaToken - }); + const appCfg = getConfig(); + const user = await userDAL.findById(userId); + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); + + try { + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_EMAIL_MFA, + userId, + code: mfaToken + }); + } catch (err) { + const updatedUser = await processFailedMfaAttempt(userId); + if (updatedUser.isLocked) { + if (updatedUser.email) { + const unlockToken = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_USER_UNLOCK, + userId: updatedUser.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.UnlockAccount, + subjectLine: "Unlock your Infisical account", + recipients: [updatedUser.email], + substitutions: { + token: unlockToken, + callback_url: `${appCfg.SITE_URL}/api/v1/user/${updatedUser.id}/unlock` + } + }); + } + } + + throw err; + } const decodedToken = jwt.verify(mfaJwtToken, getConfig().AUTH_SECRET) as AuthModeMfaJwtTokenPayload; const userEnc = await userDAL.findUserEncKeyByUserId(userId); if (!userEnc) throw new Error("Failed to authenticate user"); + // reset lock states + await userDAL.updateById(userId, { + consecutiveFailedMfaAttempts: 0, + temporaryLockDateEnd: null + }); + const token = await generateUserTokens({ user: { ...userEnc, @@ -361,6 +447,7 @@ export const authLoginServiceFactory = ({ user = await userDAL.create({ username: email, email, + isEmailVerified: true, firstName, lastName, authMethods: [authMethod], @@ -374,6 +461,8 @@ export const authLoginServiceFactory = ({ authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, + email: user.email, + isEmailVerified: user.isEmailVerified, firstName: user.firstName, lastName: user.lastName, authMethod, diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 4025e4903..a400c297b 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -174,6 +174,12 @@ export const authPaswordServiceFactory = ({ salt, verifier }); + + await userDAL.updateById(userId, { + isLocked: false, + temporaryLockDateEnd: null, + consecutiveFailedMfaAttempts: 0 + }); }; /* diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 3433a0a98..be7f5777d 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -1,6 +1,6 @@ import jwt from "jsonwebtoken"; -import { OrgMembershipStatus } 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"; @@ -80,9 +80,9 @@ export const authSignupServiceFactory = ({ }); await smtpService.sendMail({ - template: SmtpTemplates.EmailVerification, + template: SmtpTemplates.SignupEmailVerification, subjectLine: "Infisical confirmation code", - recipients: [email], + recipients: [user.email as string], substitutions: { code: token } @@ -102,6 +102,8 @@ export const authSignupServiceFactory = ({ code }); + await userDAL.updateById(user.id, { isEmailVerified: true }); + // generate jwt token this is a temporary token const jwtToken = jwt.sign( { @@ -169,12 +171,11 @@ export const authSignupServiceFactory = ({ 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 (isAuthMethodSaml(authMethod) && organizationId) { + if ((isAuthMethodSaml(authMethod) || authMethod === AuthMethod.LDAP) && organizationId) { const [pendingOrgMembership] = await orgDAL.findMembership({ - inviteEmail: email, - userId: user.id, + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, status: OrgMembershipStatus.Invited, - orgId: organizationId + [`${TableName.OrgMembership}.orgId` as "orgId"]: organizationId }); if (pendingOrgMembership) { diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index 42fb5bba5..a0f9fbc27 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TIdentityAccessTokens } from "@app/db/schemas"; +import { IdentityAuthMethod, TableName, TIdentityAccessTokens } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; @@ -15,27 +15,111 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { const doc = await (tx || db)(TableName.IdentityAccessToken) .where(filter) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityAccessToken}.identityId`) - .leftJoin( - TableName.IdentityUaClientSecret, - `${TableName.IdentityAccessToken}.identityUAClientSecretId`, - `${TableName.IdentityUaClientSecret}.id` - ) - .leftJoin( - TableName.IdentityUniversalAuth, - `${TableName.IdentityUaClientSecret}.identityUAId`, - `${TableName.IdentityUniversalAuth}.id` - ) + .leftJoin(TableName.IdentityUaClientSecret, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.Univeral])).andOn( + `${TableName.IdentityAccessToken}.identityUAClientSecretId`, + `${TableName.IdentityUaClientSecret}.id` + ); + }) + .leftJoin(TableName.IdentityUniversalAuth, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.Univeral])).andOn( + `${TableName.IdentityUaClientSecret}.identityUAId`, + `${TableName.IdentityUniversalAuth}.id` + ); + }) + .leftJoin(TableName.IdentityGcpAuth, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.GCP_AUTH])).andOn( + `${TableName.Identity}.id`, + `${TableName.IdentityGcpAuth}.identityId` + ); + }) + .leftJoin(TableName.IdentityAwsAuth, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.AWS_AUTH])).andOn( + `${TableName.Identity}.id`, + `${TableName.IdentityAwsAuth}.identityId` + ); + }) + .leftJoin(TableName.IdentityAzureAuth, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.AZURE_AUTH])).andOn( + `${TableName.Identity}.id`, + `${TableName.IdentityAzureAuth}.identityId` + ); + }) + .leftJoin(TableName.IdentityKubernetesAuth, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.KUBERNETES_AUTH])).andOn( + `${TableName.Identity}.id`, + `${TableName.IdentityKubernetesAuth}.identityId` + ); + }) .select(selectAllTableCols(TableName.IdentityAccessToken)) .select( - db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityGcpAuth).as("accessTokenTrustedIpsGcp"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAwsAuth).as("accessTokenTrustedIpsAws"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAzureAuth).as("accessTokenTrustedIpsAzure"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"), db.ref("name").withSchema(TableName.Identity) ) .first(); - return doc; + + if (!doc) return; + + return { + ...doc, + accessTokenTrustedIps: + doc.accessTokenTrustedIpsUa || + doc.accessTokenTrustedIpsGcp || + doc.accessTokenTrustedIpsAws || + doc.accessTokenTrustedIpsAzure || + doc.accessTokenTrustedIpsK8s + }; } catch (error) { throw new DatabaseError({ error, name: "IdAccessTokenFindOne" }); } }; - return { ...identityAccessTokenOrm, findOne }; + const removeExpiredTokens = async (tx?: Knex) => { + try { + const docs = (tx || db)(TableName.IdentityAccessToken) + .where({ + isAccessTokenRevoked: true + }) + .orWhere((qb) => { + void qb + .where("accessTokenNumUsesLimit", ">", 0) + .andWhere( + "accessTokenNumUses", + ">=", + db.ref("accessTokenNumUsesLimit").withSchema(TableName.IdentityAccessToken) + ); + }) + .orWhere((qb) => { + void qb.where("accessTokenTTL", ">", 0).andWhere((qb2) => { + void qb2 + .where((qb3) => { + void qb3 + .whereNotNull("accessTokenLastRenewedAt") + // accessTokenLastRenewedAt + convert_integer_to_seconds(accessTokenTTL) < present_date + .andWhereRaw( + `"${TableName.IdentityAccessToken}"."accessTokenLastRenewedAt" + make_interval(secs => "${TableName.IdentityAccessToken}"."accessTokenTTL") < NOW()` + ); + }) + .orWhere((qb3) => { + void qb3 + .whereNull("accessTokenLastRenewedAt") + // created + convert_integer_to_seconds(accessTokenTTL) < present_date + .andWhereRaw( + `"${TableName.IdentityAccessToken}"."createdAt" + make_interval(secs => "${TableName.IdentityAccessToken}"."accessTokenTTL") < NOW()` + ); + }); + }); + }) + .delete(); + return await docs; + } catch (error) { + throw new DatabaseError({ error, name: "IdentityAccessTokenPrune" }); + } + }; + + return { ...identityAccessTokenOrm, findOne, removeExpiredTokens }; }; diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 4b53c8174..3e7fe31a6 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -21,17 +21,18 @@ export const identityAccessTokenServiceFactory = ({ identityAccessTokenDAL, identityOrgMembershipDAL }: TIdentityAccessTokenServiceFactoryDep) => { - const validateAccessTokenExp = (identityAccessToken: TIdentityAccessTokens) => { + const validateAccessTokenExp = async (identityAccessToken: TIdentityAccessTokens) => { const { + id: tokenId, accessTokenTTL, accessTokenNumUses, accessTokenNumUsesLimit, accessTokenLastRenewedAt, - accessTokenMaxTTL, createdAt: accessTokenCreatedAt } = identityAccessToken; if (accessTokenNumUsesLimit > 0 && accessTokenNumUses > 0 && accessTokenNumUses >= accessTokenNumUsesLimit) { + await identityAccessTokenDAL.deleteById(tokenId); throw new BadRequestError({ message: "Unable to renew because access token number of uses limit reached" }); @@ -46,41 +47,26 @@ export const identityAccessTokenServiceFactory = ({ const ttlInMilliseconds = Number(accessTokenTTL) * 1000; const expirationDate = new Date(accessTokenRenewed.getTime() + ttlInMilliseconds); - if (currentDate > expirationDate) + if (currentDate > expirationDate) { + await identityAccessTokenDAL.deleteById(tokenId); throw new UnauthorizedError({ message: "Failed to renew MI access token due to TTL expiration" }); + } } else { // access token has never been renewed const accessTokenCreated = new Date(accessTokenCreatedAt); const ttlInMilliseconds = Number(accessTokenTTL) * 1000; const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); - if (currentDate > expirationDate) + if (currentDate > expirationDate) { + await identityAccessTokenDAL.deleteById(tokenId); throw new UnauthorizedError({ message: "Failed to renew MI access token due to TTL expiration" }); + } } } - - // max ttl checks - if (Number(accessTokenMaxTTL) > 0) { - const accessTokenCreated = new Date(accessTokenCreatedAt); - const ttlInMilliseconds = Number(accessTokenMaxTTL) * 1000; - const currentDate = new Date(); - const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); - - if (currentDate > expirationDate) - throw new UnauthorizedError({ - message: "Failed to renew MI access token due to Max TTL expiration" - }); - - const extendToDate = new Date(currentDate.getTime() + Number(accessTokenTTL)); - if (extendToDate > expirationDate) - throw new UnauthorizedError({ - message: "Failed to renew MI access token past its Max TTL expiration" - }); - } }; const renewAccessToken = async ({ accessToken }: TRenewAccessTokenDTO) => { @@ -97,7 +83,32 @@ export const identityAccessTokenServiceFactory = ({ }); if (!identityAccessToken) throw new UnauthorizedError(); - validateAccessTokenExp(identityAccessToken); + await validateAccessTokenExp(identityAccessToken); + + const { accessTokenMaxTTL, createdAt: accessTokenCreatedAt, accessTokenTTL } = identityAccessToken; + + // max ttl checks - will it go above max ttl + if (Number(accessTokenMaxTTL) > 0) { + const accessTokenCreated = new Date(accessTokenCreatedAt); + const ttlInMilliseconds = Number(accessTokenMaxTTL) * 1000; + const currentDate = new Date(); + const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) { + await identityAccessTokenDAL.deleteById(identityAccessToken.id); + throw new UnauthorizedError({ + message: "Failed to renew MI access token due to Max TTL expiration" + }); + } + + const extendToDate = new Date(currentDate.getTime() + Number(accessTokenTTL * 1000)); + if (extendToDate > expirationDate) { + await identityAccessTokenDAL.deleteById(identityAccessToken.id); + throw new UnauthorizedError({ + message: "Failed to renew MI access token past its Max TTL expiration" + }); + } + } const updatedIdentityAccessToken = await identityAccessTokenDAL.updateById(identityAccessToken.id, { accessTokenLastRenewedAt: new Date() @@ -106,6 +117,24 @@ export const identityAccessTokenServiceFactory = ({ return { accessToken, identityAccessToken: updatedIdentityAccessToken }; }; + const revokeAccessToken = async (accessToken: string) => { + const appCfg = getConfig(); + + const decodedToken = jwt.verify(accessToken, appCfg.AUTH_SECRET) as JwtPayload & { + identityAccessTokenId: string; + }; + if (decodedToken.authTokenType !== AuthTokenType.IDENTITY_ACCESS_TOKEN) throw new UnauthorizedError(); + + const identityAccessToken = await identityAccessTokenDAL.findOne({ + [`${TableName.IdentityAccessToken}.id` as "id"]: decodedToken.identityAccessTokenId, + isAccessTokenRevoked: false + }); + if (!identityAccessToken) throw new UnauthorizedError(); + + const revokedToken = await identityAccessTokenDAL.deleteById(identityAccessToken.id); + return { revokedToken }; + }; + const fnValidateIdentityAccessToken = async (token: TIdentityAccessTokenJwtPayload, ipAddress?: string) => { const identityAccessToken = await identityAccessTokenDAL.findOne({ [`${TableName.IdentityAccessToken}.id` as "id"]: token.identityAccessTokenId, @@ -113,7 +142,7 @@ export const identityAccessTokenServiceFactory = ({ }); if (!identityAccessToken) throw new UnauthorizedError(); - if (ipAddress) { + if (ipAddress && identityAccessToken) { checkIPAgainstBlocklist({ ipAddress, trustedIps: identityAccessToken?.accessTokenTrustedIps as TIp[] @@ -128,9 +157,16 @@ export const identityAccessTokenServiceFactory = ({ throw new UnauthorizedError({ message: "Identity does not belong to any organization" }); } - validateAccessTokenExp(identityAccessToken); + await validateAccessTokenExp(identityAccessToken); + + await identityAccessTokenDAL.updateById(identityAccessToken.id, { + accessTokenLastUsedAt: new Date(), + $incr: { + accessTokenNumUses: 1 + } + }); return { ...identityAccessToken, orgId: identityOrgMembership.orgId }; }; - return { renewAccessToken, fnValidateIdentityAccessToken }; + return { renewAccessToken, revokeAccessToken, fnValidateIdentityAccessToken }; }; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-dal.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-dal.ts new file mode 100644 index 000000000..6ce215c58 --- /dev/null +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityAwsAuthDALFactory = ReturnType; + +export const identityAwsAuthDALFactory = (db: TDbClient) => { + const awsAuthOrm = ormify(db, TableName.IdentityAwsAuth); + + return awsAuthOrm; +}; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts new file mode 100644 index 000000000..517e9f613 --- /dev/null +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts @@ -0,0 +1,67 @@ +/** + * Extracts the identity ARN from the GetCallerIdentity response to one of the following formats: + * - arn:aws:iam::123456789012:user/MyUserName + * - arn:aws:iam::123456789012:role/MyRoleName + */ +export const extractPrincipalArn = (arn: string) => { + // split the ARN into parts using ":" as the delimiter + const fullParts = arn.split(":"); + if (fullParts.length !== 6) { + throw new Error(`Unrecognized ARN: contains ${fullParts.length} colon-separated parts, expected 6`); + } + const [prefix, partition, service, , accountNumber, resource] = fullParts; + if (prefix !== "arn") { + throw new Error('Unrecognized ARN: does not begin with "arn:"'); + } + + // structure to hold the parsed data + const entity = { + Partition: partition, + Service: service, + AccountNumber: accountNumber, + Type: "", + Path: "", + FriendlyName: "", + SessionInfo: "" + }; + + // validate the service is either 'iam' or 'sts' + if (entity.Service !== "iam" && entity.Service !== "sts") { + throw new Error(`Unrecognized service: ${entity.Service}, not one of iam or sts`); + } + + // parse the last part of the ARN which describes the resource + const parts = resource.split("/"); + if (parts.length < 2) { + throw new Error(`Unrecognized ARN: "${resource}" contains fewer than 2 slash-separated parts`); + } + + const [type, ...rest] = parts; + entity.Type = type; + entity.FriendlyName = parts[parts.length - 1]; + + // handle different types of resources + switch (entity.Type) { + case "assumed-role": { + if (rest.length < 2) { + throw new Error(`Unrecognized ARN: "${resource}" contains fewer than 3 slash-separated parts`); + } + // assumed roles use a special format where the friendly name is the role name + const [roleName, sessionId] = rest; + entity.Type = "role"; // treat assumed role case as role + entity.FriendlyName = roleName; + entity.SessionInfo = sessionId; + break; + } + case "user": + case "role": + case "instance-profile": + // standard cases: just join back the path if there's any + entity.Path = rest.slice(0, -1).join("/"); + break; + default: + throw new Error(`Unrecognized principal type: "${entity.Type}"`); + } + + return `arn:aws:iam::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`; +}; 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 new file mode 100644 index 000000000..a58944909 --- /dev/null +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -0,0 +1,310 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { ForbiddenError } from "@casl/ability"; +import axios from "axios"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TIdentityAwsAuthDALFactory } from "./identity-aws-auth-dal"; +import { extractPrincipalArn } from "./identity-aws-auth-fns"; +import { + TAttachAwsAuthDTO, + TAwsGetCallerIdentityHeaders, + TGetAwsAuthDTO, + TGetCallerIdentityResponse, + TLoginAwsAuthDTO, + TUpdateAwsAuthDTO +} from "./identity-aws-auth-types"; + +type TIdentityAwsAuthServiceFactoryDep = { + identityAccessTokenDAL: Pick; + identityAwsAuthDAL: Pick; + identityOrgMembershipDAL: Pick; + identityDAL: Pick; + licenseService: Pick; + permissionService: Pick; +}; + +export type TIdentityAwsAuthServiceFactory = ReturnType; + +export const identityAwsAuthServiceFactory = ({ + identityAccessTokenDAL, + identityAwsAuthDAL, + identityOrgMembershipDAL, + identityDAL, + licenseService, + permissionService +}: TIdentityAwsAuthServiceFactoryDep) => { + const login = async ({ identityId, iamHttpRequestMethod, iamRequestBody, iamRequestHeaders }: TLoginAwsAuthDTO) => { + const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId }); + if (!identityAwsAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAwsAuth.identityId }); + + const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); + const body: string = Buffer.from(iamRequestBody, "base64").toString(); + + const { + data: { + GetCallerIdentityResponse: { + GetCallerIdentityResult: { Account, Arn } + } + } + }: { data: TGetCallerIdentityResponse } = await axios({ + method: iamHttpRequestMethod, + url: identityAwsAuth.stsEndpoint, + headers, + data: body + }); + + if (identityAwsAuth.allowedAccountIds) { + // validate if Account is in the list of allowed Account IDs + + const isAccountAllowed = identityAwsAuth.allowedAccountIds + .split(",") + .map((accountId) => accountId.trim()) + .some((accountId) => accountId === Account); + + if (!isAccountAllowed) throw new UnauthorizedError(); + } + + if (identityAwsAuth.allowedPrincipalArns) { + // validate if Arn is in the list of allowed Principal ARNs + + const isArnAllowed = identityAwsAuth.allowedPrincipalArns + .split(",") + .map((principalArn) => principalArn.trim()) + .some((principalArn) => { + // convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$" + // considers exact matches + wildcard matches + const regex = new RegExp(`^${principalArn.replace(/\*/g, ".*")}$`); + return regex.test(extractPrincipalArn(Arn)); + }); + + if (!isArnAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAwsAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityAwsAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityAwsAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachAwsAuth = async ({ + identityId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachAwsAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add AWS Auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => { + const doc = await identityAwsAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + type: "iam", + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.AWS_AUTH + }, + tx + ); + return doc; + }); + return { ...identityAwsAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateAwsAuth = async ({ + identityId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateAwsAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_AUTH) + throw new BadRequestError({ + message: "Failed to update AWS Auth" + }); + + const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityAwsAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityAwsAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityAwsAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updatedAwsAuth = await identityAwsAuthDAL.updateById(identityAwsAuth.id, { + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { ...updatedAwsAuth, orgId: identityMembershipOrg.orgId }; + }; + + const getAwsAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAwsAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_AUTH) + throw new BadRequestError({ + message: "The identity does not have AWS Auth attached" + }); + + const awsIdentityAuth = await identityAwsAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + return { ...awsIdentityAuth, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachAwsAuth, + updateAwsAuth, + getAwsAuth + }; +}; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts new file mode 100644 index 000000000..e45783ae1 --- /dev/null +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts @@ -0,0 +1,54 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginAwsAuthDTO = { + identityId: string; + iamHttpRequestMethod: string; + iamRequestBody: string; + iamRequestHeaders: string; +}; + +export type TAttachAwsAuthDTO = { + identityId: string; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateAwsAuthDTO = { + identityId: string; + stsEndpoint?: string; + allowedPrincipalArns?: string; + allowedAccountIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetAwsAuthDTO = { + identityId: string; +} & Omit; + +export type TAwsGetCallerIdentityHeaders = { + "Content-Type": string; + Host: string; + "X-Amz-Date": string; + "Content-Length": number; + "x-amz-security-token": string; + Authorization: string; +}; + +export type TGetCallerIdentityResponse = { + GetCallerIdentityResponse: { + GetCallerIdentityResult: { + Account: string; + Arn: string; + UserId: string; + }; + ResponseMetadata: { RequestId: string }; + }; +}; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts new file mode 100644 index 000000000..2cb7b4ea4 --- /dev/null +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +const twelveDigitRegex = /^\d{12}$/; +const arnRegex = /^arn:aws:iam::\d{12}:(user\/[\w-]+|role\/[\w-]+|\*)$/; + +export const validateAccountIds = z + .string() + .trim() + .default("") + // Custom validation to ensure each part is a 12-digit number + .refine( + (data) => { + if (data === "") return true; + // Split the string by commas to check each supposed number + const accountIds = data.split(",").map((id) => id.trim()); + // Return true only if every item matches the 12-digit requirement + return accountIds.every((id) => twelveDigitRegex.test(id)); + }, + { + message: "Each account ID must be a 12-digit number." + } + ) + // Transform the string to normalize space after commas + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); + +export const validatePrincipalArns = z + .string() + .trim() + .default("") + // Custom validation for ARN format + .refine( + (data) => { + // Skip validation if the string is empty + if (data === "") return true; + // Split the string by commas to check each supposed ARN + const arns = data.split(","); + // Return true only if every item matches one of the allowed ARN formats + return arns.every((arn) => arnRegex.test(arn.trim())); + }, + { + message: + "Each ARN must be in the format of 'arn:aws:iam::123456789012:user/UserName', 'arn:aws:iam::123456789012:role/RoleName', or 'arn:aws:iam::123456789012:*'." + } + ) + // Transform to normalize the spaces around commas + .transform((data) => + data + .split(",") + .map((arn) => arn.trim()) + .join(", ") + ); diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-dal.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-dal.ts new file mode 100644 index 000000000..7038e2b9c --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityAzureAuthDALFactory = ReturnType; + +export const identityAzureAuthDALFactory = (db: TDbClient) => { + const azureAuthOrm = ormify(db, TableName.IdentityAzureAuth); + return azureAuthOrm; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts new file mode 100644 index 000000000..ad9e6f12d --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts @@ -0,0 +1,34 @@ +import axios from "axios"; +import jwt from "jsonwebtoken"; + +import { UnauthorizedError } from "@app/lib/errors"; + +import { TAzureAuthJwtPayload, TAzureJwksUriResponse, TDecodedAzureAuthJwt } from "./identity-azure-auth-types"; + +export const validateAzureIdentity = async ({ + tenantId, + resource, + jwt: azureJwt +}: { + tenantId: string; + resource: string; + jwt: string; +}) => { + const jwksUri = `https://login.microsoftonline.com/${tenantId}/discovery/keys`; + + const decodedJwt = jwt.decode(azureJwt, { complete: true }) as TDecodedAzureAuthJwt; + const { kid } = decodedJwt.header; + + const { data }: { data: TAzureJwksUriResponse } = await axios.get(jwksUri); + const signingKeys = data.keys; + + const signingKey = signingKeys.find((key) => key.kid === kid); + if (!signingKey) throw new UnauthorizedError(); + + const publicKey = `-----BEGIN CERTIFICATE-----\n${signingKey.x5c[0]}\n-----END CERTIFICATE-----`; + + return jwt.verify(azureJwt, publicKey, { + audience: resource, + issuer: `https://sts.windows.net/${tenantId}/` + }) as TAzureAuthJwtPayload; +}; 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 new file mode 100644 index 000000000..fa439bdc0 --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -0,0 +1,286 @@ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TIdentityAzureAuthDALFactory } from "./identity-azure-auth-dal"; +import { validateAzureIdentity } from "./identity-azure-auth-fns"; +import { + TAttachAzureAuthDTO, + TGetAzureAuthDTO, + TLoginAzureAuthDTO, + TUpdateAzureAuthDTO +} from "./identity-azure-auth-types"; + +type TIdentityAzureAuthServiceFactoryDep = { + identityAzureAuthDAL: Pick; + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + identityDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TIdentityAzureAuthServiceFactory = ReturnType; + +export const identityAzureAuthServiceFactory = ({ + identityAzureAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService +}: TIdentityAzureAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => { + const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); + if (!identityAzureAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAzureAuth.identityId }); + if (!identityMembershipOrg) throw new UnauthorizedError(); + + const azureIdentity = await validateAzureIdentity({ + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + jwt: azureJwt + }); + + if (azureIdentity.tid !== identityAzureAuth.tenantId) throw new UnauthorizedError(); + + if (identityAzureAuth.allowedServicePrincipalIds) { + // validate if the service principal id is in the list of allowed service principal ids + + const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds + .split(",") + .map((servicePrincipalId) => servicePrincipalId.trim()) + .some((servicePrincipalId) => servicePrincipalId === azureIdentity.oid); + + if (!isServicePrincipalAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAzureAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityAzureAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityAzureAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachAzureAuth = async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachAzureAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add Azure Auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => { + const doc = await identityAzureAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.AZURE_AUTH + }, + tx + ); + return doc; + }); + return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateAzureAuth = async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateAzureAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AZURE_AUTH) + throw new BadRequestError({ + message: "Failed to update Azure Auth" + }); + + const identityGcpAuth = await identityAzureAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityGcpAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updatedAzureAuth = await identityAzureAuthDAL.updateById(identityGcpAuth.id, { + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { + ...updatedAzureAuth, + orgId: identityMembershipOrg.orgId + }; + }; + + const getAzureAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAzureAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AZURE_AUTH) + throw new BadRequestError({ + message: "The identity does not have Azure Auth attached" + }); + + const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachAzureAuth, + updateAzureAuth, + getAzureAuth + }; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts new file mode 100644 index 000000000..65459003c --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts @@ -0,0 +1,120 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginAzureAuthDTO = { + identityId: string; + jwt: string; +}; + +export type TAttachAzureAuthDTO = { + identityId: string; + tenantId: string; + resource: string; + allowedServicePrincipalIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateAzureAuthDTO = { + identityId: string; + tenantId?: string; + resource?: string; + allowedServicePrincipalIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetAzureAuthDTO = { + identityId: string; +} & Omit; + +export type TAzureJwksUriResponse = { + keys: { + kty: string; + use: string; + kid: string; + x5t: string; + n: string; + e: string; + x5c: string[]; + }[]; +}; + +type TUserPayload = { + aud: string; + iss: string; + iat: number; + nbf: number; + exp: number; + acr: string; + aio: string; + amr: string[]; + appid: string; + appidacr: string; + family_name: string; + given_name: string; + groups: string[]; + idtyp: string; + ipaddr: string; + name: string; + oid: string; + puid: string; + rh: string; + scp: string; + sub: string; + tid: string; + unique_name: string; + upn: string; + uti: string; + ver: string; + wids: string[]; + xms_cae: string; + xms_cc: string[]; + xms_filter_index: string[]; + xms_rd: string; + xms_ssm: string; + xms_tcdt: number; +}; + +type TAppPayload = { + aud: string; + iss: string; + iat: number; + nbf: number; + exp: number; + aio: string; + appid: string; + appidacr: string; + idp: string; + idtyp: string; + oid: string; // service principal id + rh: string; + sub: string; + tid: string; + uti: string; + ver: string; + xms_cae: string; + xms_cc: string[]; + xms_rd: string; + xms_ssm: string; + xms_tcdt: number; +}; + +export type TAzureAuthJwtPayload = TUserPayload | TAppPayload; + +export type TDecodedAzureAuthJwt = { + header: { + type: string; + alg: string; + x5t: string; + kid: string; + }; + payload: TAzureAuthJwtPayload; + signature: string; + metadata: { + [key: string]: string; + }; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-validators.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-validators.ts new file mode 100644 index 000000000..3f7f7d8af --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-validators.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const validateAzureAuthField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-dal.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-dal.ts new file mode 100644 index 000000000..e10250445 --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityGcpAuthDALFactory = ReturnType; + +export const identityGcpAuthDALFactory = (db: TDbClient) => { + const gcpAuthOrm = ormify(db, TableName.IdentityGcpAuth); + return gcpAuthOrm; +}; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-fns.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-fns.ts new file mode 100644 index 000000000..e1afceada --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-fns.ts @@ -0,0 +1,70 @@ +import axios from "axios"; +import { OAuth2Client } from "google-auth-library"; +import jwt from "jsonwebtoken"; + +import { UnauthorizedError } from "@app/lib/errors"; + +import { TDecodedGcpIamAuthJwt, TGcpIdTokenPayload } from "./identity-gcp-auth-types"; + +/** + * Validates that the identity token [jwt] sent in from a client GCE instance as part of GCP ID Token authentication + * is valid. + * @param {string} identityId - The ID of the identity in Infisical that is being authenticated against (used as audience). + * @param {string} jwt - The identity token to validate. + * @param {string} credentials - The credentials in the GCP Auth configuration for Infisical. + */ +export const validateIdTokenIdentity = async ({ + identityId, + jwt: identityToken +}: { + identityId: string; + jwt: string; +}) => { + const oAuth2Client = new OAuth2Client(); + const response = await oAuth2Client.getFederatedSignonCerts(); + const ticket = await oAuth2Client.verifySignedJwtWithCertsAsync( + identityToken, + response.certs, + identityId, // audience + ["https://accounts.google.com"] + ); + const payload = ticket.getPayload() as TGcpIdTokenPayload; + if (!payload || !payload.email) throw new UnauthorizedError(); + + return { email: payload.email, computeEngineDetails: payload.google?.compute_engine }; +}; + +/** + * Validates that the signed JWT token for a GCP service account is valid as part of GCP IAM authentication. + * @param {string} identityId - The ID of the identity in Infisical that is being authenticated against (used as audience). + * @param {string} jwt - The signed JWT token to validate. + * @param {string} credentials - The credentials in the GCP Auth configuration for Infisical. + * @returns + */ +export const validateIamIdentity = async ({ + identityId, + jwt: serviceAccountJwt +}: { + identityId: string; + jwt: string; +}) => { + const decodedJwt = jwt.decode(serviceAccountJwt, { complete: true }) as TDecodedGcpIamAuthJwt; + const { sub, aud } = decodedJwt.payload; + + const { + data + }: { + data: { + [key: string]: string; + }; + } = await axios.get(`https://www.googleapis.com/service_accounts/v1/metadata/x509/${sub}`); + + const publicKey = data[decodedJwt.header.kid]; + + jwt.verify(serviceAccountJwt, publicKey, { + algorithms: ["RS256"] + }); + + if (aud !== identityId) throw new UnauthorizedError(); + return { email: sub }; +}; 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 new file mode 100644 index 000000000..5f829cb33 --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -0,0 +1,324 @@ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TIdentityGcpAuthDALFactory } from "./identity-gcp-auth-dal"; +import { validateIamIdentity, validateIdTokenIdentity } from "./identity-gcp-auth-fns"; +import { + TAttachGcpAuthDTO, + TGcpIdentityDetails, + TGetGcpAuthDTO, + TLoginGcpAuthDTO, + TUpdateGcpAuthDTO +} from "./identity-gcp-auth-types"; + +type TIdentityGcpAuthServiceFactoryDep = { + identityGcpAuthDAL: Pick; + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + identityDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TIdentityGcpAuthServiceFactory = ReturnType; + +export const identityGcpAuthServiceFactory = ({ + identityGcpAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService +}: TIdentityGcpAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: gcpJwt }: TLoginGcpAuthDTO) => { + const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); + if (!identityGcpAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityGcpAuth.identityId }); + if (!identityMembershipOrg) throw new UnauthorizedError(); + + let gcpIdentityDetails: TGcpIdentityDetails; + switch (identityGcpAuth.type) { + case "gce": { + gcpIdentityDetails = await validateIdTokenIdentity({ + identityId, + jwt: gcpJwt + }); + break; + } + case "iam": { + gcpIdentityDetails = await validateIamIdentity({ + identityId, + jwt: gcpJwt + }); + break; + } + default: { + throw new BadRequestError({ message: "Invalid GCP Auth type" }); + } + } + + if (identityGcpAuth.allowedServiceAccounts) { + // validate if the service account is in the list of allowed service accounts + + const isServiceAccountAllowed = identityGcpAuth.allowedServiceAccounts + .split(",") + .map((serviceAccount) => serviceAccount.trim()) + .some((serviceAccount) => serviceAccount === gcpIdentityDetails.email); + + if (!isServiceAccountAllowed) throw new UnauthorizedError(); + } + + if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedProjects && gcpIdentityDetails.computeEngineDetails) { + // validate if the project that the service account belongs to is in the list of allowed projects + + const isProjectAllowed = identityGcpAuth.allowedProjects + .split(",") + .map((project) => project.trim()) + .some((project) => project === gcpIdentityDetails.computeEngineDetails?.project_id); + + if (!isProjectAllowed) throw new UnauthorizedError(); + } + + if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedZones && gcpIdentityDetails.computeEngineDetails) { + const isZoneAllowed = identityGcpAuth.allowedZones + .split(",") + .map((zone) => zone.trim()) + .some((zone) => zone === gcpIdentityDetails.computeEngineDetails?.zone); + + if (!isZoneAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityGcpAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityGcpAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityGcpAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachGcpAuth = async ({ + identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachGcpAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add GCP Auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => { + const doc = await identityGcpAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.GCP_AUTH + }, + tx + ); + return doc; + }); + return { ...identityGcpAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateGcpAuth = async ({ + identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateGcpAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.GCP_AUTH) + throw new BadRequestError({ + message: "Failed to update GCP Auth" + }); + + const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityGcpAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updatedGcpAuth = await identityGcpAuthDAL.updateById(identityGcpAuth.id, { + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { + ...updatedGcpAuth, + orgId: identityMembershipOrg.orgId + }; + }; + + const getGcpAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetGcpAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.GCP_AUTH) + throw new BadRequestError({ + message: "The identity does not have GCP Auth attached" + }); + + const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + return { ...identityGcpAuth, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachGcpAuth, + updateGcpAuth, + getGcpAuth + }; +}; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts new file mode 100644 index 000000000..60ab36b58 --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts @@ -0,0 +1,78 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginGcpAuthDTO = { + identityId: string; + jwt: string; +}; + +export type TAttachGcpAuthDTO = { + identityId: string; + type: "iam" | "gce"; + allowedServiceAccounts: string; + allowedProjects: string; + allowedZones: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateGcpAuthDTO = { + identityId: string; + type?: "iam" | "gce"; + allowedServiceAccounts?: string; + allowedProjects?: string; + allowedZones?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetGcpAuthDTO = { + identityId: string; +} & Omit; + +type TComputeEngineDetails = { + instance_creation_timestamp: number; + instance_id: string; + instance_name: string; + project_id: string; + project_number: number; + zone: string; +}; + +export type TGcpIdentityDetails = { + email: string; + computeEngineDetails?: TComputeEngineDetails; +}; + +export type TGcpIdTokenPayload = { + aud: string; + azp: string; + email: string; + email_verified: boolean; + exp: number; + google?: { + compute_engine: TComputeEngineDetails; + }; + iat: number; + iss: string; + sub: string; +}; + +export type TDecodedGcpIamAuthJwt = { + header: { + alg: string; + kid: string; + typ: string; + }; + payload: { + sub: string; + aud: string; + }; + signature: string; + metadata: { + [key: string]: string; + }; +}; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-validators.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-validators.ts new file mode 100644 index 000000000..a49cee417 --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-validators.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const validateGcpAuthField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts new file mode 100644 index 000000000..df5919101 --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityKubernetesAuthDALFactory = ReturnType; + +export const identityKubernetesAuthDALFactory = (db: TDbClient) => { + const kubernetesAuthOrm = ormify(db, TableName.IdentityKubernetesAuth); + return kubernetesAuthOrm; +}; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts new file mode 100644 index 000000000..194e69b3c --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts @@ -0,0 +1,15 @@ +/** + * Extracts the K8s service account name and namespace + * from the username in this format: system:serviceaccount:default:infisical-auth + */ +export const extractK8sUsername = (username: string) => { + const parts = username.split(":"); + // Ensure that the username format is correct + if (parts.length === 4 && parts[0] === "system" && parts[1] === "serviceaccount") { + return { + namespace: parts[2], + name: parts[3] + }; + } + throw new Error("Invalid username format"); +}; 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 new file mode 100644 index 000000000..8ee8c36bd --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -0,0 +1,515 @@ +import { ForbiddenError } from "@casl/ability"; +import axios from "axios"; +import https from "https"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod, SecretKeyEncoding, TIdentityKubernetesAuthsUpdate } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { + decryptSymmetric, + encryptSymmetric, + generateAsymmetricKeyPair, + generateSymmetricKey, + infisicalSymmetricDecrypt, + infisicalSymmetricEncypt +} from "@app/lib/crypto/encryption"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; + +import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TIdentityKubernetesAuthDALFactory } from "./identity-kubernetes-auth-dal"; +import { extractK8sUsername } from "./identity-kubernetes-auth-fns"; +import { + TAttachKubernetesAuthDTO, + TCreateTokenReviewResponse, + TGetKubernetesAuthDTO, + TLoginKubernetesAuthDTO, + TUpdateKubernetesAuthDTO +} from "./identity-kubernetes-auth-types"; + +type TIdentityKubernetesAuthServiceFactoryDep = { + identityKubernetesAuthDAL: Pick< + TIdentityKubernetesAuthDALFactory, + "create" | "findOne" | "transaction" | "updateById" + >; + identityAccessTokenDAL: Pick; + identityOrgMembershipDAL: Pick; + identityDAL: Pick; + orgBotDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TIdentityKubernetesAuthServiceFactory = ReturnType; + +export const identityKubernetesAuthServiceFactory = ({ + identityKubernetesAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + orgBotDAL, + permissionService, + licenseService +}: TIdentityKubernetesAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => { + const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); + if (!identityKubernetesAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ + identityId: identityKubernetesAuth.identityId + }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + + const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { encryptedCaCert, caCertIV, caCertTag, encryptedTokenReviewerJwt, tokenReviewerJwtIV, tokenReviewerJwtTag } = + identityKubernetesAuth; + + let caCert = ""; + if (encryptedCaCert && caCertIV && caCertTag) { + caCert = decryptSymmetric({ + ciphertext: encryptedCaCert, + iv: caCertIV, + tag: caCertTag, + key + }); + } + + let tokenReviewerJwt = ""; + if (encryptedTokenReviewerJwt && tokenReviewerJwtIV && tokenReviewerJwtTag) { + tokenReviewerJwt = decryptSymmetric({ + ciphertext: encryptedTokenReviewerJwt, + iv: tokenReviewerJwtIV, + tag: tokenReviewerJwtTag, + key + }); + } + + const { data }: { data: TCreateTokenReviewResponse } = await axios.post( + `${identityKubernetesAuth.kubernetesHost}/apis/authentication.k8s.io/v1/tokenreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt + } + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenReviewerJwt}` + }, + httpsAgent: new https.Agent({ + ca: caCert, + rejectUnauthorized: !!caCert + }) + } + ); + + if ("error" in data.status) throw new UnauthorizedError({ message: data.status.error }); + + // check the response to determine if the token is valid + if (!(data.status && data.status.authenticated)) throw new UnauthorizedError(); + + const { namespace: targetNamespace, name: targetName } = extractK8sUsername(data.status.user.username); + + if (identityKubernetesAuth.allowedNamespaces) { + // validate if [targetNamespace] is in the list of allowed namespaces + + const isNamespaceAllowed = identityKubernetesAuth.allowedNamespaces + .split(",") + .map((namespace) => namespace.trim()) + .some((namespace) => namespace === targetNamespace); + + if (!isNamespaceAllowed) throw new UnauthorizedError(); + } + + if (identityKubernetesAuth.allowedNames) { + // validate if [targetName] is in the list of allowed names + + const isNameAllowed = identityKubernetesAuth.allowedNames + .split(",") + .map((name) => name.trim()) + .some((name) => name === targetName); + + if (!isNameAllowed) throw new UnauthorizedError(); + } + + if (identityKubernetesAuth.allowedAudience) { + // validate if [audience] is in the list of allowed audiences + const isAudienceAllowed = data.status.audiences.some( + (audience) => audience === identityKubernetesAuth.allowedAudience + ); + + if (!isAudienceAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityKubernetesAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityKubernetesAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityKubernetesAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachKubernetesAuth = async ({ + identityId, + kubernetesHost, + caCert, + tokenReviewerJwt, + allowedNamespaces, + allowedNames, + allowedAudience, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachKubernetesAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add Kubernetes Auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const orgBot = await orgBotDAL.transaction(async (tx) => { + const doc = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }, tx); + if (doc) return doc; + + const { privateKey, publicKey } = generateAsymmetricKeyPair(); + const key = generateSymmetricKey(); + const { + ciphertext: encryptedPrivateKey, + iv: privateKeyIV, + tag: privateKeyTag, + encoding: privateKeyKeyEncoding, + algorithm: privateKeyAlgorithm + } = infisicalSymmetricEncypt(privateKey); + const { + ciphertext: encryptedSymmetricKey, + iv: symmetricKeyIV, + tag: symmetricKeyTag, + encoding: symmetricKeyKeyEncoding, + algorithm: symmetricKeyAlgorithm + } = infisicalSymmetricEncypt(key); + + return orgBotDAL.create( + { + name: "Infisical org bot", + publicKey, + privateKeyIV, + encryptedPrivateKey, + symmetricKeyIV, + symmetricKeyTag, + encryptedSymmetricKey, + symmetricKeyAlgorithm, + orgId: identityMembershipOrg.orgId, + privateKeyTag, + privateKeyAlgorithm, + privateKeyKeyEncoding, + symmetricKeyKeyEncoding + }, + tx + ); + }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { ciphertext: encryptedCaCert, iv: caCertIV, tag: caCertTag } = encryptSymmetric(caCert, key); + const { + ciphertext: encryptedTokenReviewerJwt, + iv: tokenReviewerJwtIV, + tag: tokenReviewerJwtTag + } = encryptSymmetric(tokenReviewerJwt, key); + + const identityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { + const doc = await identityKubernetesAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + kubernetesHost, + encryptedCaCert, + caCertIV, + caCertTag, + encryptedTokenReviewerJwt, + tokenReviewerJwtIV, + tokenReviewerJwtTag, + allowedNamespaces, + allowedNames, + allowedAudience, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.KUBERNETES_AUTH + }, + tx + ); + return doc; + }); + + return { ...identityKubernetesAuth, caCert, tokenReviewerJwt, orgId: identityMembershipOrg.orgId }; + }; + + const updateKubernetesAuth = async ({ + identityId, + kubernetesHost, + caCert, + tokenReviewerJwt, + allowedNamespaces, + allowedNames, + allowedAudience, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateKubernetesAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.KUBERNETES_AUTH) + throw new BadRequestError({ + message: "Failed to update Kubernetes Auth" + }); + + const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityKubernetesAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityKubernetesAuth.accessTokenMaxTTL) > + (accessTokenMaxTTL || identityKubernetesAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updateQuery: TIdentityKubernetesAuthsUpdate = { + kubernetesHost, + allowedNamespaces, + allowedNames, + allowedAudience, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }; + + const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + if (caCert !== undefined) { + const { ciphertext: encryptedCACert, iv: caCertIV, tag: caCertTag } = encryptSymmetric(caCert, key); + updateQuery.encryptedCaCert = encryptedCACert; + updateQuery.caCertIV = caCertIV; + updateQuery.caCertTag = caCertTag; + } + + if (tokenReviewerJwt !== undefined) { + const { + ciphertext: encryptedTokenReviewerJwt, + iv: tokenReviewerJwtIV, + tag: tokenReviewerJwtTag + } = encryptSymmetric(tokenReviewerJwt, key); + updateQuery.encryptedTokenReviewerJwt = encryptedTokenReviewerJwt; + updateQuery.tokenReviewerJwtIV = tokenReviewerJwtIV; + updateQuery.tokenReviewerJwtTag = tokenReviewerJwtTag; + } + + const updatedKubernetesAuth = await identityKubernetesAuthDAL.updateById(identityKubernetesAuth.id, updateQuery); + + return { ...updatedKubernetesAuth, orgId: identityMembershipOrg.orgId }; + }; + + const getKubernetesAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TGetKubernetesAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.KUBERNETES_AUTH) + throw new BadRequestError({ + message: "The identity does not have Kubernetes Auth attached" + }); + + const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { encryptedCaCert, caCertIV, caCertTag, encryptedTokenReviewerJwt, tokenReviewerJwtIV, tokenReviewerJwtTag } = + identityKubernetesAuth; + + let caCert = ""; + if (encryptedCaCert && caCertIV && caCertTag) { + caCert = decryptSymmetric({ + ciphertext: encryptedCaCert, + iv: caCertIV, + tag: caCertTag, + key + }); + } + + let tokenReviewerJwt = ""; + if (encryptedTokenReviewerJwt && tokenReviewerJwtIV && tokenReviewerJwtTag) { + tokenReviewerJwt = decryptSymmetric({ + ciphertext: encryptedTokenReviewerJwt, + iv: tokenReviewerJwtIV, + tag: tokenReviewerJwtTag, + key + }); + } + + return { ...identityKubernetesAuth, caCert, tokenReviewerJwt, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachKubernetesAuth, + updateKubernetesAuth, + getKubernetesAuth + }; +}; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts new file mode 100644 index 000000000..dbb42dce8 --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts @@ -0,0 +1,61 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginKubernetesAuthDTO = { + identityId: string; + jwt: string; +}; + +export type TAttachKubernetesAuthDTO = { + identityId: string; + kubernetesHost: string; + caCert: string; + tokenReviewerJwt: string; + allowedNamespaces: string; + allowedNames: string; + allowedAudience: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateKubernetesAuthDTO = { + identityId: string; + kubernetesHost?: string; + caCert?: string; + tokenReviewerJwt?: string; + allowedNamespaces?: string; + allowedNames?: string; + allowedAudience?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetKubernetesAuthDTO = { + identityId: string; +} & Omit; + +type TCreateTokenReviewSuccessResponse = { + authenticated: true; + user: { + username: string; + uid: string; + groups: string[]; + }; + audiences: string[]; +}; + +type TCreateTokenReviewErrorResponse = { + error: string; +}; + +export type TCreateTokenReviewResponse = { + apiVersion: "authentication.k8s.io/v1"; + kind: "TokenReview"; + spec: { + token: string; + }; + status: TCreateTokenReviewSuccessResponse | TCreateTokenReviewErrorResponse; +}; diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index e932d2068..c1cfe79cc 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -10,11 +10,16 @@ export type TIdentityProjectDALFactory = ReturnType { const identityProjectOrm = ormify(db, TableName.IdentityProjectMembership); - const findByProjectId = async (projectId: string, tx?: Knex) => { + const findByProjectId = async (projectId: string, filter: { identityId?: string } = {}, tx?: Knex) => { try { const docs = await (tx || db)(TableName.IdentityProjectMembership) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) .join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`) + .where((qb) => { + if (filter.identityId) { + void qb.where("identityId", filter.identityId); + } + }) .join( TableName.IdentityProjectMembershipRole, `${TableName.IdentityProjectMembershipRole}.projectMembershipId`, diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index 18a1803ac..10f2b3460 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -18,6 +18,7 @@ import { TIdentityProjectMembershipRoleDALFactory } from "./identity-project-mem import { TCreateProjectIdentityDTO, TDeleteProjectIdentityDTO, + TGetProjectIdentityByIdentityIdDTO, TListProjectIdentityDTO, TUpdateProjectIdentityDTO } from "./identity-project-types"; @@ -51,7 +52,7 @@ export const identityProjectServiceFactory = ({ actorOrgId, actorAuthMethod, projectId, - role + roles }: TCreateProjectIdentityDTO) => { const { permission } = await permissionService.getProjectPermission( actor, @@ -78,17 +79,33 @@ export const identityProjectServiceFactory = ({ message: `Failed to find identity with id ${identityId}` }); - const { permission: rolePermission, role: customRole } = await permissionService.getProjectPermissionByRole( - role, - project.id - ); - const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasPriviledge) - throw new ForbiddenRequestError({ - message: "Failed to add identity to project with more privileged role" - }); - const isCustomRole = Boolean(customRole); + for await (const { role: requestedRoleChange } of roles) { + const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( + requestedRoleChange, + projectId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); + + if (!hasRequiredPriviledges) { + throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" }); + } + } + + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + ); + const hasCustomRole = Boolean(customInputRoles.length); + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" }); + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); const projectIdentity = await identityProjectDAL.transaction(async (tx) => { const identityProjectMembership = await identityProjectDAL.create( { @@ -97,16 +114,32 @@ export const identityProjectServiceFactory = ({ }, tx ); + const sanitizedProjectMembershipRoles = roles.map((inputRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); + if (!inputRole.isTemporary) { + return { + projectMembershipId: identityProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null + }; + } - await identityProjectMembershipRoleDAL.create( - { + // check cron or relative here later for now its just relative + const relativeTimeInMs = ms(inputRole.temporaryRange); + return { projectMembershipId: identityProjectMembership.id, - role: isCustomRole ? ProjectMembershipRole.Custom : role, - customRoleId: customRole?.id - }, - tx - ); - return identityProjectMembership; + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, + isTemporary: true, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: inputRole.temporaryRange, + temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) + }; + }); + + const identityRoles = await identityProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); + return { ...identityProjectMembership, roles: identityRoles }; }); return projectIdentity; }; @@ -135,16 +168,18 @@ export const identityProjectServiceFactory = ({ message: `Identity with id ${identityId} doesn't exists in project with id ${projectId}` }); - const { permission: identityRolePermission } = await permissionService.getProjectPermission( - ActorType.IDENTITY, - projectIdentity.identityId, - projectIdentity.projectId, - actorAuthMethod, - actorOrgId - ); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + for await (const { role: requestedRoleChange } of roles) { + const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( + requestedRoleChange, + projectId + ); + + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); + + if (!hasRequiredPriviledges) { + throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" }); + } + } // validate custom roles input const customInputRoles = roles.filter( @@ -224,7 +259,7 @@ export const identityProjectServiceFactory = ({ if (!hasRequiredPriviledges) throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); - const [deletedIdentity] = await identityProjectDAL.delete({ identityId }); + const [deletedIdentity] = await identityProjectDAL.delete({ identityId, projectId }); return deletedIdentity; }; @@ -248,10 +283,33 @@ export const identityProjectServiceFactory = ({ return identityMemberships; }; + const getProjectIdentityByIdentityId = async ({ + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + identityId + }: TGetProjectIdentityByIdentityIdDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + + const [identityMembership] = await identityProjectDAL.findByProjectId(projectId, { identityId }); + if (!identityMembership) throw new BadRequestError({ message: `Membership not found for identity ${identityId}` }); + return identityMembership; + }; + return { createProjectIdentity, updateProjectIdentity, deleteProjectIdentity, - listProjectIdentities + listProjectIdentities, + getProjectIdentityByIdentityId }; }; diff --git a/backend/src/services/identity-project/identity-project-types.ts b/backend/src/services/identity-project/identity-project-types.ts index 73e8ec246..43c671e50 100644 --- a/backend/src/services/identity-project/identity-project-types.ts +++ b/backend/src/services/identity-project/identity-project-types.ts @@ -4,7 +4,19 @@ import { ProjectUserMembershipTemporaryMode } from "../project-membership/projec export type TCreateProjectIdentityDTO = { identityId: string; - role: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; } & TProjectPermission; export type TUpdateProjectIdentityDTO = { @@ -29,3 +41,7 @@ export type TDeleteProjectIdentityDTO = { } & TProjectPermission; export type TListProjectIdentityDTO = TProjectPermission; + +export type TGetProjectIdentityByIdentityIdDTO = { + identityId: string; +} & TProjectPermission; diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 54a074073..5e940871b 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -52,7 +52,7 @@ export const identityUaServiceFactory = ({ }: TIdentityUaServiceFactoryDep) => { const login = async (clientId: string, clientSecret: string, ip: string) => { const identityUa = await identityUaDAL.findOne({ clientId }); - if (!identityUa) throw new UnauthorizedError(); + if (!identityUa) throw new UnauthorizedError({ message: "Invalid credentials" }); const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); @@ -68,7 +68,7 @@ export const identityUaServiceFactory = ({ const validClientSecretInfo = clientSecrtInfo.find(({ clientSecretHash }) => bcrypt.compareSync(clientSecret, clientSecretHash) ); - if (!validClientSecretInfo) throw new UnauthorizedError(); + if (!validClientSecretInfo) throw new UnauthorizedError({ message: "Invalid credentials" }); const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; if (Number(clientSecretTTL) > 0) { diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 778589de8..02091d88c 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -199,6 +199,7 @@ export const integrationAuthServiceFactory = ({ projectId, namespace, integration, + url, algorithm: SecretEncryptionAlgo.AES_256_GCM, keyEncoding: SecretKeyEncoding.UTF8, ...(integration === Integrations.GCP_SECRET_MANAGER @@ -566,20 +567,32 @@ export const integrationAuthServiceFactory = ({ } }); const kms = new AWS.KMS(); - const aliases = await kms.listAliases({}).promise(); - const keys = await kms.listKeys({}).promise(); - const response = keys - .Keys!.map((key) => { - const keyAlias = aliases.Aliases!.find((alias) => key.KeyId === alias.TargetKeyId); - if (!keyAlias?.AliasName?.includes("alias/aws/")) { - return { id: String(key.KeyId), alias: String(keyAlias?.AliasName || key.KeyId) }; - } - return { id: "null", alias: "null" }; - }) - .filter((elem) => elem.id !== "null"); - return [...response, { id: "null", alias: "default" }]; + const keyAliases = aliases.Aliases!.filter((alias) => { + if (!alias.TargetKeyId) return false; + + if (integrationAuth.integration === Integrations.AWS_PARAMETER_STORE && alias.AliasName === "alias/aws/ssm") + return true; + + if ( + integrationAuth.integration === Integrations.AWS_SECRET_MANAGER && + alias.AliasName === "alias/aws/secretsmanager" + ) + return true; + + if (alias.AliasName?.includes("alias/aws/")) return false; + return alias.TargetKeyId; + }); + + const keysWithAliases = keyAliases.map((alias) => { + return { + id: alias.TargetKeyId!, + alias: alias.AliasName! + }; + }); + + return keysWithAliases; }; const getQoveryProjects = async ({ diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index e49cd3862..edc426327 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -30,7 +30,8 @@ export enum Integrations { DIGITAL_OCEAN_APP_PLATFORM = "digital-ocean-app-platform", CLOUD_66 = "cloud-66", NORTHFLANK = "northflank", - HASURA_CLOUD = "hasura-cloud" + HASURA_CLOUD = "hasura-cloud", + RUNDECK = "rundeck" } export enum IntegrationType { @@ -43,6 +44,11 @@ export enum IntegrationInitialSyncBehavior { PREFER_SOURCE = "prefer-source" } +export enum IntegrationMappingBehavior { + ONE_TO_ONE = "one-to-one", + MANY_TO_ONE = "many-to-one" +} + export enum IntegrationUrls { // integration oauth endpoints GCP_TOKEN_URL = "https://oauth2.googleapis.com/token", @@ -363,6 +369,15 @@ export const getIntegrationOptions = async () => { type: "pat", clientId: "", docsLink: "" + }, + { + name: "Rundeck", + slug: "rundeck", + image: "Rundeck.svg", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" } ]; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index de576dc99..0ae0a0275 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -9,9 +9,12 @@ import { CreateSecretCommand, + DescribeSecretCommand, GetSecretValueCommand, ResourceNotFoundException, SecretsManagerClient, + TagResourceCommand, + UntagResourceCommand, UpdateSecretCommand } from "@aws-sdk/client-secrets-manager"; import { Octokit } from "@octokit/rest"; @@ -24,10 +27,16 @@ import { z } from "zod"; import { SecretType, TIntegrationAuths, TIntegrations, TSecrets } from "@app/db/schemas"; import { request } from "@app/lib/config/request"; import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/secret/secret-types"; import { TIntegrationDALFactory } from "../integration/integration-dal"; -import { IntegrationInitialSyncBehavior, Integrations, IntegrationUrls } from "./integration-list"; +import { + IntegrationInitialSyncBehavior, + IntegrationMappingBehavior, + Integrations, + IntegrationUrls +} from "./integration-list"; const getSecretKeyValuePair = (secrets: Record) => Object.keys(secrets).reduce>((prev, key) => { @@ -459,27 +468,39 @@ const syncSecretsAWSParameterStore = async ({ ssm.config.update(config); const metadata = z.record(z.any()).parse(integration.metadata || {}); + const awsParameterStoreSecretsObj: Record = {}; - const params = { - Path: integration.path as string, - Recursive: false, - WithDecryption: true - }; + // now fetch all aws parameter store secrets + let hasNext = true; + let nextToken: string | undefined; + while (hasNext) { + const parameters = await ssm + .getParametersByPath({ + Path: integration.path as string, + Recursive: false, + WithDecryption: true, + MaxResults: 10, + NextToken: nextToken + }) + .promise(); - const parameterList = (await ssm.getParametersByPath(params).promise()).Parameters; + if (parameters.Parameters) { + parameters.Parameters.forEach((parameter) => { + if (parameter.Name) { + const secKey = parameter.Name.substring((integration.path as string).length); + awsParameterStoreSecretsObj[secKey] = parameter; + } + }); + } + hasNext = Boolean(parameters.NextToken); + nextToken = parameters.NextToken; + } - const awsParameterStoreSecretsObj = (parameterList || []) - .filter(({ Name }) => Boolean(Name)) - .reduce( - (obj, secret) => ({ - ...obj, - [(secret.Name as string).substring((integration.path as string).length)]: secret - }), - {} as Record - ); // Identify secrets to create - await Promise.all( - Object.keys(secrets).map(async (key) => { + // don't use Promise.all() and promise map here + // it will cause rate limit + for (const key in secrets) { + if (Object.hasOwn(secrets, key)) { if (!(key in awsParameterStoreSecretsObj)) { // case: secret does not exist in AWS parameter store // -> create secret @@ -489,7 +510,7 @@ const syncSecretsAWSParameterStore = async ({ Name: `${integration.path}${key}`, Type: "SecureString", Value: secrets[key].value, - KeyId: metadata.kmsKeyId ? metadata.kmsKeyId : undefined, + ...(metadata.kmsKeyId && { KeyId: metadata.kmsKeyId }), // Overwrite: true, Tags: metadata.secretAWSTag ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ @@ -501,36 +522,68 @@ const syncSecretsAWSParameterStore = async ({ .promise(); } // case: secret exists in AWS parameter store - } else if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) { - // case: secret value doesn't match one in AWS parameter store + } else { // -> update secret - await ssm - .putParameter({ - Name: `${integration.path}${key}`, - Type: "SecureString", - Value: secrets[key].value, - Overwrite: true - // Tags: metadata.secretAWSTag ? [{ Key: metadata.secretAWSTag.key, Value: metadata.secretAWSTag.value }] : [] - }) - .promise(); - } - }) - ); + if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) { + await ssm + .putParameter({ + Name: `${integration.path}${key}`, + Type: "SecureString", + Value: secrets[key].value, + Overwrite: true + }) + .promise(); + } - // Identify secrets to delete - await Promise.all( - Object.keys(awsParameterStoreSecretsObj).map(async (key) => { - if (!(key in secrets)) { - // case: - // -> delete secret - await ssm - .deleteParameter({ - Name: awsParameterStoreSecretsObj[key].Name as string - }) - .promise(); + if (awsParameterStoreSecretsObj[key].Name) { + try { + await ssm + .addTagsToResource({ + ResourceType: "Parameter", + ResourceId: awsParameterStoreSecretsObj[key].Name as string, + Tags: metadata.secretAWSTag + ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ + Key: tag.key, + Value: tag.value + })) + : [] + }) + .promise(); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((err as any).code === "AccessDeniedException") { + logger.error( + `AWS Parameter Store Error [integration=${integration.id}]: double check AWS account permissions (refer to the Infisical docs)` + ); + } + } + } } - }) - ); + + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + } + } + + if (!metadata.shouldDisableDelete) { + for (const key in awsParameterStoreSecretsObj) { + if (Object.hasOwn(awsParameterStoreSecretsObj, key)) { + if (!(key in secrets)) { + // case: + // -> delete secret + await ssm + .deleteParameter({ + Name: awsParameterStoreSecretsObj[key].Name as string + }) + .promise(); + } + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + } + } + } }; /** @@ -547,53 +600,149 @@ const syncSecretsAWSSecretManager = async ({ accessId: string | null; accessToken: string; }) => { - let secretsManager; - const secKeyVal = getSecretKeyValuePair(secrets); const metadata = z.record(z.any()).parse(integration.metadata || {}); - try { - if (!accessId) return; - secretsManager = new SecretsManagerClient({ - region: integration.region as string, - credentials: { - accessKeyId: accessId, - secretAccessKey: accessToken + if (!accessId) return; + + const secretsManager = new SecretsManagerClient({ + region: integration.region as string, + credentials: { + accessKeyId: accessId, + secretAccessKey: accessToken + } + }); + + const processAwsSecret = async ( + secretId: string, + secretValue: Record | string + ) => { + try { + const awsSecretManagerSecret = await secretsManager.send( + new GetSecretValueCommand({ + SecretId: secretId + }) + ); + + let secretToCompare; + if (awsSecretManagerSecret?.SecretString) { + if (typeof secretValue === "string") { + secretToCompare = awsSecretManagerSecret.SecretString; + } else { + secretToCompare = JSON.parse(awsSecretManagerSecret.SecretString); + } } - }); - const awsSecretManagerSecret = await secretsManager.send( - new GetSecretValueCommand({ - SecretId: integration.app as string - }) - ); + if (!isEqual(secretToCompare, secretValue)) { + await secretsManager.send( + new UpdateSecretCommand({ + SecretId: secretId, + SecretString: typeof secretValue === "string" ? secretValue : JSON.stringify(secretValue) + }) + ); + } - let awsSecretManagerSecretObj: { [key: string]: AWS.SecretsManager } = {}; + const secretAWSTag = metadata.secretAWSTag as { key: string; value: string }[] | undefined; - if (awsSecretManagerSecret?.SecretString) { - awsSecretManagerSecretObj = JSON.parse(awsSecretManagerSecret.SecretString); + if (secretAWSTag && secretAWSTag.length) { + const describedSecret = await secretsManager.send( + // requires secretsmanager:DescribeSecret policy + new DescribeSecretCommand({ + SecretId: secretId + }) + ); + + if (!describedSecret.Tags) return; + + const integrationTagObj = secretAWSTag.reduce( + (acc, item) => { + acc[item.key] = item.value; + return acc; + }, + {} as Record + ); + + const awsTagObj = (describedSecret.Tags || []).reduce( + (acc, item) => { + if (item.Key && item.Value) { + acc[item.Key] = item.Value; + } + return acc; + }, + {} as Record + ); + + const tagsToUpdate: { Key: string; Value: string }[] = []; + const tagsToDelete: { Key: string; Value: string }[] = []; + + describedSecret.Tags?.forEach((tag) => { + if (tag.Key && tag.Value) { + if (!(tag.Key in integrationTagObj)) { + // delete tag from AWS secret manager + tagsToDelete.push({ + Key: tag.Key, + Value: tag.Value + }); + } else if (tag.Value !== integrationTagObj[tag.Key]) { + // update tag in AWS secret manager + tagsToUpdate.push({ + Key: tag.Key, + Value: integrationTagObj[tag.Key] + }); + } + } + }); + + secretAWSTag?.forEach((tag) => { + if (!(tag.key in awsTagObj)) { + // create tag in AWS secret manager + tagsToUpdate.push({ + Key: tag.key, + Value: tag.value + }); + } + }); + + if (tagsToUpdate.length) { + await secretsManager.send( + new TagResourceCommand({ + SecretId: secretId, + Tags: tagsToUpdate + }) + ); + } + + if (tagsToDelete.length) { + await secretsManager.send( + new UntagResourceCommand({ + SecretId: secretId, + TagKeys: tagsToDelete.map((tag) => tag.Key) + }) + ); + } + } + } catch (err) { + // case when AWS manager can't find the specified secret + if (err instanceof ResourceNotFoundException && secretsManager) { + await secretsManager.send( + new CreateSecretCommand({ + Name: secretId, + SecretString: typeof secretValue === "string" ? secretValue : JSON.stringify(secretValue), + ...(metadata.kmsKeyId && { KmsKeyId: metadata.kmsKeyId }), + Tags: metadata.secretAWSTag + ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ Key: tag.key, Value: tag.value })) + : [] + }) + ); + } } + }; - if (!isEqual(awsSecretManagerSecretObj, secKeyVal)) { - await secretsManager.send( - new UpdateSecretCommand({ - SecretId: integration.app as string, - SecretString: JSON.stringify(secKeyVal) - }) - ); - } - } catch (err) { - if (err instanceof ResourceNotFoundException && secretsManager) { - await secretsManager.send( - new CreateSecretCommand({ - Name: integration.app as string, - SecretString: JSON.stringify(secKeyVal), - KmsKeyId: metadata.kmsKeyId ? metadata.kmsKeyId : null, - Tags: metadata.secretAWSTag - ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ Key: tag.key, Value: tag.value })) - : [] - }) - ); + if (metadata.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE) { + for await (const [key, value] of Object.entries(secrets)) { + await processAwsSecret(key, value.value); } + } else { + await processAwsSecret(integration.app as string, getSecretKeyValuePair(secrets)); } }; @@ -2572,18 +2721,21 @@ const syncSecretsCloudflarePages = async ({ }) ).data.result.deployment_configs[integration.targetEnvironment as string].env_vars; - // copy the secrets object, so we can set deleted keys to null - const secretsObj = Object.fromEntries( - Object.entries(getSecretKeyValuePair(secrets)).map(([key, val]) => [ - key, - key in Object.keys(getSecretsRes) ? { type: "secret_text", value: val } : null - ]) - ); + let secretEntries: [string, object | null][] = Object.entries(getSecretKeyValuePair(secrets)).map(([key, val]) => [ + key, + { type: "secret_text", value: val } + ]); + + if (getSecretsRes) { + const toDeleteKeys = Object.keys(getSecretsRes).filter((key) => !Object.keys(secrets).includes(key)); + const toDeleteEntries: [string, null][] = toDeleteKeys.map((key) => [key, null]); + secretEntries = [...secretEntries, ...toDeleteEntries]; + } const data = { deployment_configs: { [integration.targetEnvironment as string]: { - env_vars: secretsObj + env_vars: Object.fromEntries(secretEntries) } } }; @@ -2598,6 +2750,20 @@ const syncSecretsCloudflarePages = async ({ } } ); + + const metadata = z.record(z.any()).parse(integration.metadata); + if (metadata.shouldAutoRedeploy) { + await request.post( + `${IntegrationUrls.CLOUDFLARE_PAGES_API_URL}/client/v4/accounts/${accessId}/pages/projects/${integration.app}/deployments`, + {}, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } }; /** @@ -2861,7 +3027,7 @@ const syncSecretsDigitalOceanAppPlatform = async ({ spec: { name: integration.app, ...appSettings, - envs: Object.entries(secrets).map(([key, data]) => ({ key, value: data.value })) + envs: Object.entries(secrets).map(([key, data]) => ({ key, value: data.value, type: "SECRET" })) } }, { @@ -3203,6 +3369,82 @@ const syncSecretsHasuraCloud = async ({ } }; +/** Sync/push [secrets] to Rundeck + * @param {Object} obj + * @param {TIntegrations} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Rundeck integration + */ +const syncSecretsRundeck = async ({ + integration, + secrets, + accessToken +}: { + integration: TIntegrations; + secrets: Record; + accessToken: string; +}) => { + interface RundeckSecretResource { + name: string; + } + interface RundeckSecretsGetRes { + resources: RundeckSecretResource[]; + } + + let existingRundeckSecrets: string[] = []; + + try { + const listResult = await request.get( + `${integration.url}/api/44/storage/${integration.path}`, + { + headers: { + "X-Rundeck-Auth-Token": accessToken + } + } + ); + + existingRundeckSecrets = listResult.data.resources.map((res) => res.name); + } catch (err) { + logger.info("No existing rundeck secrets"); + } + + try { + for await (const [key, value] of Object.entries(secrets)) { + if (existingRundeckSecrets.includes(key)) { + await request.put(`${integration.url}/api/44/storage/${integration.path}/${key}`, value.value, { + headers: { + "X-Rundeck-Auth-Token": accessToken, + "Content-Type": "application/x-rundeck-data-password" + } + }); + } else { + await request.post(`${integration.url}/api/44/storage/${integration.path}/${key}`, value.value, { + headers: { + "X-Rundeck-Auth-Token": accessToken, + "Content-Type": "application/x-rundeck-data-password" + } + }); + } + } + + for await (const existingSecret of existingRundeckSecrets) { + if (!(existingSecret in secrets)) { + await request.delete(`${integration.url}/api/44/storage/${integration.path}/${existingSecret}`, { + headers: { + "X-Rundeck-Auth-Token": accessToken + } + }); + } + } + } catch (err: unknown) { + throw new Error( + `Ensure that the provided Rundeck URL is accessible by Infisical and that the linked API token has sufficient permissions.\n\n${ + (err as Error).message + }` + ); + } +}; + /** * Sync/push [secrets] to [app] in integration named [integration] * @@ -3469,6 +3711,13 @@ export const syncIntegrationSecrets = async ({ accessToken }); break; + case Integrations.RUNDECK: + await syncSecretsRundeck({ + integration, + secrets, + accessToken + }); + break; default: throw new BadRequestError({ message: "Invalid integration" }); } diff --git a/backend/src/services/integration-auth/integration-team.ts b/backend/src/services/integration-auth/integration-team.ts index 81ef9b70c..c39b2c44f 100644 --- a/backend/src/services/integration-auth/integration-team.ts +++ b/backend/src/services/integration-auth/integration-team.ts @@ -5,7 +5,7 @@ import { Integrations, IntegrationUrls } from "./integration-list"; type Team = { name: string; - teamId: string; + id: string; }; const getTeamsGitLab = async ({ url, accessToken }: { url: string; accessToken: string }) => { const gitLabApiUrl = url ? `${url}/api` : IntegrationUrls.GITLAB_API_URL; @@ -22,7 +22,7 @@ const getTeamsGitLab = async ({ url, accessToken }: { url: string; accessToken: teams = res.map((t) => ({ name: t.name, - teamId: t.id + id: t.id.toString() })); return teams; diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index a3c4c84db..da9cfc71f 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -9,7 +9,12 @@ import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth import { TSecretQueueFactory } from "../secret/secret-queue"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TIntegrationDALFactory } from "./integration-dal"; -import { TCreateIntegrationDTO, TDeleteIntegrationDTO, TUpdateIntegrationDTO } from "./integration-types"; +import { + TCreateIntegrationDTO, + TDeleteIntegrationDTO, + TSyncIntegrationDTO, + TUpdateIntegrationDTO +} from "./integration-types"; type TIntegrationServiceFactoryDep = { integrationDAL: TIntegrationDALFactory; @@ -38,6 +43,7 @@ export const integrationServiceFactory = ({ scope, actorId, region, + url, isActive, metadata, secretPath, @@ -61,6 +67,11 @@ export const integrationServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: sourceEnvironment, secretPath }) + ); + const folder = await folderDAL.findBySecretPath(integrationAuth.projectId, sourceEnvironment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder path not found" }); @@ -77,6 +88,7 @@ export const integrationServiceFactory = ({ region, scope, owner, + url, appId, path, app, @@ -103,7 +115,8 @@ export const integrationServiceFactory = ({ owner, isActive, environment, - secretPath + secretPath, + metadata }: TUpdateIntegrationDTO) => { const integration = await integrationDAL.findById(id); if (!integration) throw new BadRequestError({ message: "Integration auth not found" }); @@ -117,6 +130,11 @@ export const integrationServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + const folder = await folderDAL.findBySecretPath(integration.projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder path not found" }); @@ -127,7 +145,17 @@ export const integrationServiceFactory = ({ appId, targetEnvironment, owner, - secretPath + secretPath, + metadata: { + ...(integration.metadata as object), + ...metadata + } + }); + + await secretQueueService.syncIntegrations({ + environment: folder.environment.slug, + secretPath, + projectId: folder.projectId }); return updatedIntegration; @@ -190,10 +218,35 @@ export const integrationServiceFactory = ({ return integrations; }; + const syncIntegration = async ({ id, actorId, actor, actorOrgId, actorAuthMethod }: TSyncIntegrationDTO) => { + const integration = await integrationDAL.findById(id); + if (!integration) { + throw new BadRequestError({ message: "Integration not found" }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integration.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + + await secretQueueService.syncIntegrations({ + environment: integration.environment.slug, + secretPath: integration.secretPath, + projectId: integration.projectId + }); + + return { ...integration, envId: integration.environment.id }; + }; + return { createIntegration, updateIntegration, deleteIntegration, - listIntegrationByProject + listIntegrationByProject, + syncIntegration }; }; diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 56ea46350..9c75cad2d 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -12,6 +12,7 @@ export type TCreateIntegrationDTO = { targetService?: string; targetServiceId?: string; owner?: string; + url?: string; path?: string; region?: string; scope?: string; @@ -27,20 +28,39 @@ export type TCreateIntegrationDTO = { value: string; }[]; kmsKeyId?: string; + shouldDisableDelete?: boolean; }; } & Omit; export type TUpdateIntegrationDTO = { id: string; - app: string; - appId: string; + app?: string; + appId?: string; isActive?: boolean; secretPath: string; targetEnvironment: string; owner: string; environment: string; + metadata?: { + secretPrefix?: string; + secretSuffix?: string; + secretGCPLabel?: { + labelName: string; + labelValue: string; + }; + secretAWSTag?: { + key: string; + value: string; + }[]; + kmsKeyId?: string; + shouldDisableDelete?: boolean; + }; } & Omit; export type TDeleteIntegrationDTO = { id: string; } & Omit; + +export type TSyncIntegrationDTO = { + id: string; +} & Omit; diff --git a/backend/src/services/kms/kms-dal.ts b/backend/src/services/kms/kms-dal.ts new file mode 100644 index 000000000..bee667e10 --- /dev/null +++ b/backend/src/services/kms/kms-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TKmsDALFactory = ReturnType; + +export const kmsDALFactory = (db: TDbClient) => { + const kmsOrm = ormify(db, TableName.KmsKey); + return kmsOrm; +}; diff --git a/backend/src/services/kms/kms-root-config-dal.ts b/backend/src/services/kms/kms-root-config-dal.ts new file mode 100644 index 000000000..f448e2df8 --- /dev/null +++ b/backend/src/services/kms/kms-root-config-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TKmsRootConfigDALFactory = ReturnType; + +export const kmsRootConfigDALFactory = (db: TDbClient) => { + const kmsOrm = ormify(db, TableName.KmsServerRootConfig); + return kmsOrm; +}; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts new file mode 100644 index 000000000..97d2b29d6 --- /dev/null +++ b/backend/src/services/kms/kms-service.ts @@ -0,0 +1,126 @@ +import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { randomSecureBytes } from "@app/lib/crypto"; +import { symmetricCipherService, SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; + +import { TKmsDALFactory } from "./kms-dal"; +import { TKmsRootConfigDALFactory } from "./kms-root-config-dal"; +import { TDecryptWithKmsDTO, TEncryptWithKmsDTO, TGenerateKMSDTO } from "./kms-types"; + +type TKmsServiceFactoryDep = { + kmsDAL: TKmsDALFactory; + kmsRootConfigDAL: Pick; + keyStore: Pick; +}; + +export type TKmsServiceFactory = ReturnType; + +const KMS_ROOT_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; + +const KMS_ROOT_CREATION_WAIT_KEY = "wait_till_ready_kms_root_key"; +const KMS_ROOT_CREATION_WAIT_TIME = 10; + +// akhilmhdh: Don't edit this value. This is measured for blob concatination in kms +const KMS_VERSION = "v01"; +const KMS_VERSION_BLOB_LENGTH = 3; +export const kmsServiceFactory = ({ kmsDAL, kmsRootConfigDAL, keyStore }: TKmsServiceFactoryDep) => { + let ROOT_ENCRYPTION_KEY = Buffer.alloc(0); + + // this is used symmetric encryption + const generateKmsKey = async ({ scopeId, scopeType, isReserved = true }: TGenerateKMSDTO) => { + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const kmsKeyMaterial = randomSecureBytes(32); + const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY); + + const { encryptedKey, ...doc } = await kmsDAL.create({ + version: 1, + encryptedKey: encryptedKeyMaterial, + encryptionAlgorithm: SymmetricEncryption.AES_GCM_256, + isReserved, + orgId: scopeType === "org" ? scopeId : undefined, + projectId: scopeType === "project" ? scopeId : undefined + }); + return doc; + }; + + const encrypt = async ({ kmsId, plainText }: TEncryptWithKmsDTO) => { + const kmsDoc = await kmsDAL.findById(kmsId); + if (!kmsDoc) throw new BadRequestError({ message: "KMS ID not found" }); + // akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + + const kmsKey = cipher.decrypt(kmsDoc.encryptedKey, ROOT_ENCRYPTION_KEY); + const encryptedPlainTextBlob = cipher.encrypt(plainText, kmsKey); + + // Buffer#1 encrypted text + Buffer#2 version number + const versionBlob = Buffer.from(KMS_VERSION, "utf8"); // length is 3 + const cipherTextBlob = Buffer.concat([encryptedPlainTextBlob, versionBlob]); + return { cipherTextBlob }; + }; + + const decrypt = async ({ cipherTextBlob: versionedCipherTextBlob, kmsId }: TDecryptWithKmsDTO) => { + const kmsDoc = await kmsDAL.findById(kmsId); + if (!kmsDoc) throw new BadRequestError({ message: "KMS ID not found" }); + // akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const kmsKey = cipher.decrypt(kmsDoc.encryptedKey, ROOT_ENCRYPTION_KEY); + + const cipherTextBlob = versionedCipherTextBlob.subarray(0, -KMS_VERSION_BLOB_LENGTH); + const decryptedBlob = cipher.decrypt(cipherTextBlob, kmsKey); + return decryptedBlob; + }; + + const startService = async () => { + const appCfg = getConfig(); + // This will switch to a seal process and HMS flow in future + const encryptionKey = appCfg.ENCRYPTION_KEY || appCfg.ROOT_ENCRYPTION_KEY; + // if root key its base64 encoded + const isBase64 = !appCfg.ENCRYPTION_KEY; + if (!encryptionKey) throw new Error("Root encryption key not found for KMS service."); + const encryptionKeyBuffer = Buffer.from(encryptionKey, isBase64 ? "base64" : "utf8"); + + const lock = await keyStore.acquireLock([`KMS_ROOT_CFG_LOCK`], 3000, { retryCount: 3 }).catch(() => null); + if (!lock) { + await keyStore.waitTillReady({ + key: KMS_ROOT_CREATION_WAIT_KEY, + keyCheckCb: (val) => val === "true", + waitingCb: () => logger.info("KMS. Waiting for leader to finish creation of KMS Root Key") + }); + } + + // check if KMS root key was already generated and saved in DB + const kmsRootConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID); + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + if (kmsRootConfig) { + if (lock) await lock.release(); + logger.info("KMS: Encrypted ROOT Key found from DB. Decrypting."); + const decryptedRootKey = cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer); + // set the flag so that other instancen nodes can start + await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true"); + logger.info("KMS: Loading ROOT Key into Memory."); + ROOT_ENCRYPTION_KEY = decryptedRootKey; + return; + } + + logger.info("KMS: Generating ROOT Key"); + const newRootKey = randomSecureBytes(32); + const encryptedRootKey = cipher.encrypt(newRootKey, encryptionKeyBuffer); + // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition + await kmsRootConfigDAL.create({ encryptedRootKey, id: KMS_ROOT_CONFIG_UUID }); + + // set the flag so that other instancen nodes can start + await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true"); + logger.info("KMS: Saved and loaded ROOT Key into memory"); + if (lock) await lock.release(); + ROOT_ENCRYPTION_KEY = newRootKey; + }; + + return { + startService, + generateKmsKey, + encrypt, + decrypt + }; +}; diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts new file mode 100644 index 000000000..96ad25f6e --- /dev/null +++ b/backend/src/services/kms/kms-types.ts @@ -0,0 +1,15 @@ +export type TGenerateKMSDTO = { + scopeType: "project" | "org"; + scopeId: string; + isReserved?: boolean; +}; + +export type TEncryptWithKmsDTO = { + kmsId: string; + plainText: Buffer; +}; + +export type TDecryptWithKmsDTO = { + kmsId: string; + cipherTextBlob: Buffer; +}; diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts new file mode 100644 index 000000000..9990d9c3d --- /dev/null +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TOrgMembershipDALFactory = ReturnType; + +export const orgMembershipDALFactory = (db: TDbClient) => { + const orgMembershipOrm = ormify(db, TableName.OrgMembership); + + return { + ...orgMembershipOrm + }; +}; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 4dc76b612..1e52053b2 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -262,13 +262,19 @@ export const orgDALFactory = (db: TDbClient) => { .where(buildFindFilter(filter)) .join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .leftJoin(TableName.UserAliases, function joinUserAlias() { + this.on(`${TableName.UserAliases}.userId`, "=", `${TableName.OrgMembership}.userId`) + .andOn(`${TableName.UserAliases}.orgId`, "=", `${TableName.OrgMembership}.orgId`) + .andOn(`${TableName.UserAliases}.aliasType`, "=", (tx || db).raw("?", ["saml"])); + }) .select( selectAllTableCols(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("scimEnabled").withSchema(TableName.Organization) + db.ref("scimEnabled").withSchema(TableName.Organization), + db.ref("externalId").withSchema(TableName.UserAliases) ) .where({ isGhost: false }); diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts index ec6d4cb2d..a63ffabee 100644 --- a/backend/src/services/org/org-fns.ts +++ b/backend/src/services/org/org-fns.ts @@ -1,41 +1,78 @@ +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOrgDALFactory } from "@app/services/org/org-dal"; -import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; type TDeleteOrgMembership = { orgMembershipId: string; orgId: string; orgDAL: Pick; - projectDAL: Pick; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick; + projectKeyDAL: Pick; + userAliasDAL: Pick; + licenseService: Pick; }; -export const deleteOrgMembership = async ({ +export const deleteOrgMembershipFn = async ({ orgMembershipId, orgId, orgDAL, - projectDAL, - projectMembershipDAL + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }: TDeleteOrgMembership) => { - const membership = await orgDAL.transaction(async (tx) => { - // delete org membership + const deletedMembership = await orgDAL.transaction(async (tx) => { const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx); - const projects = await projectDAL.find({ orgId }, { tx }); + if (!orgMembership.userId) { + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMembership; + } - // delete associated project memberships - await projectMembershipDAL.delete( + await userAliasDAL.delete( { - $in: { - projectId: projects.map((project) => project.id) - }, - userId: orgMembership.userId as string + userId: orgMembership.userId, + orgId }, tx ); + // Get all the project memberships of the user in the organization + const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, orgMembership.userId); + + // Delete all the project memberships of the user 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: orgMembership.userId + }); + + // 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 orgMembership; }); - return membership; + return deletedMembership; }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 996a08c4d..60ddc5230 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -4,7 +4,7 @@ import crypto from "crypto"; import jwt from "jsonwebtoken"; import { Knex } from "knex"; -import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName } from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -18,6 +18,7 @@ import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; @@ -30,6 +31,7 @@ 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 { TOrgRoleDALFactory } from "./org-role-dal"; import { TDeleteOrgMembershipDTO, @@ -43,6 +45,7 @@ import { } from "./org-types"; type TOrgServiceFactoryDep = { + userAliasDAL: Pick; orgDAL: TOrgDALFactory; orgBotDAL: TOrgBotDALFactory; orgRoleDAL: TOrgRoleDALFactory; @@ -65,6 +68,7 @@ type TOrgServiceFactoryDep = { export type TOrgServiceFactory = ReturnType; export const orgServiceFactory = ({ + userAliasDAL, orgDAL, userDAL, groupDAL, @@ -427,7 +431,13 @@ export const orgServiceFactory = ({ if (inviteeUser) { // if user already exist means its already part of infisical // Thus the signup flow is not needed anymore - const [inviteeMembership] = await orgDAL.findMembership({ orgId, userId: inviteeUser.id }, { tx }); + const [inviteeMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUser.id + }, + { tx } + ); if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) { throw new BadRequestError({ message: "Failed to invite an existing member of org", @@ -519,9 +529,9 @@ export const orgServiceFactory = ({ throw new BadRequestError({ message: "Invalid request", name: "Verify user to org" }); } const [orgMembership] = await orgDAL.findMembership({ - userId: user.id, + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, status: OrgMembershipStatus.Invited, - orgId + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }); if (!orgMembership) throw new BadRequestError({ @@ -536,6 +546,10 @@ export const orgServiceFactory = ({ code }); + await userDAL.updateById(user.id, { + isEmailVerified: true + }); + if (user.isAccepted) { // this means user has already completed signup process // isAccepted is set true when keys are exchanged @@ -572,47 +586,14 @@ export const orgServiceFactory = ({ const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); - const deletedMembership = await orgDAL.transaction(async (tx) => { - const orgMembership = await orgDAL.deleteMembershipById(membershipId, orgId, tx); - - if (!orgMembership.userId) { - await licenseService.updateSubscriptionOrgMemberCount(orgId); - return orgMembership; - } - - // Get all the project memberships of the user in the organization - const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, orgMembership.userId); - - // Delete all the project memberships of the user 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: orgMembership.userId - }); - - // 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 orgMembership; + const deletedMembership = await deleteOrgMembershipFn({ + orgMembershipId: membershipId, + orgId, + orgDAL, + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService }); return deletedMembership; diff --git a/backend/src/services/project-bot/project-bot-fns.ts b/backend/src/services/project-bot/project-bot-fns.ts index 3f22b8704..00604b37f 100644 --- a/backend/src/services/project-bot/project-bot-fns.ts +++ b/backend/src/services/project-bot/project-bot-fns.ts @@ -3,6 +3,7 @@ import { decryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/en import { BadRequestError } from "@app/lib/errors"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { TProjectDALFactory } from "../project/project-dal"; import { TGetPrivateKeyDTO } from "./project-bot-types"; export const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) => @@ -13,11 +14,17 @@ export const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) => ciphertext: bot.encryptedPrivateKey }); -export const getBotKeyFnFactory = (projectBotDAL: TProjectBotDALFactory) => { +export const getBotKeyFnFactory = ( + projectBotDAL: TProjectBotDALFactory, + projectDAL: Pick +) => { const getBotKeyFn = async (projectId: string) => { - const bot = await projectBotDAL.findOne({ projectId }); + const project = await projectDAL.findById(projectId); + if (!project) throw new BadRequestError({ message: "Project not found during bot lookup." }); - if (!bot) throw new BadRequestError({ message: "failed to find bot key" }); + const bot = await projectBotDAL.findOne({ projectId: project.id }); + + if (!bot) throw new BadRequestError({ message: "Failed to find bot key" }); if (!bot.isActive) throw new BadRequestError({ message: "Bot is not active" }); if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey) throw new BadRequestError({ message: "Encryption key missing" }); diff --git a/backend/src/services/project-bot/project-bot-service.ts b/backend/src/services/project-bot/project-bot-service.ts index 23667ef67..ce7782a80 100644 --- a/backend/src/services/project-bot/project-bot-service.ts +++ b/backend/src/services/project-bot/project-bot-service.ts @@ -25,7 +25,7 @@ export const projectBotServiceFactory = ({ projectDAL, permissionService }: TProjectBotServiceFactoryDep) => { - const getBotKeyFn = getBotKeyFnFactory(projectBotDAL); + const getBotKeyFn = getBotKeyFnFactory(projectBotDAL, projectDAL); const getBotKey = async (projectId: string) => { return getBotKeyFn(projectId); diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 8faab487e..590c26ecc 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -1,3 +1,5 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; @@ -9,11 +11,19 @@ export const projectMembershipDALFactory = (db: TDbClient) => { const projectMemberOrm = ormify(db, TableName.ProjectMembership); // special query - const findAllProjectMembers = async (projectId: string) => { + const findAllProjectMembers = async (projectId: string, filter: { usernames?: string[]; username?: string } = {}) => { try { const docs = await db(TableName.ProjectMembership) .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .where((qb) => { + if (filter.usernames) { + void qb.whereIn("username", filter.usernames); + } + if (filter.username) { + void qb.where("username", filter.username); + } + }) .join( TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, @@ -96,9 +106,9 @@ export const projectMembershipDALFactory = (db: TDbClient) => { } }; - const findProjectGhostUser = async (projectId: string) => { + const findProjectGhostUser = async (projectId: string, tx?: Knex) => { try { - const ghostUser = await db(TableName.ProjectMembership) + const ghostUser = await (tx || db)(TableName.ProjectMembership) .where({ projectId }) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .select(selectAllTableCols(TableName.Users)) diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 6d148d03b..a6682465f 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -34,6 +34,7 @@ import { TAddUsersToWorkspaceNonE2EEDTO, TDeleteProjectMembershipOldDTO, TDeleteProjectMembershipsDTO, + TGetProjectMembershipByUsernameDTO, TGetProjectMembershipDTO, TUpdateProjectMembershipDTO } from "./project-membership-types"; @@ -89,6 +90,28 @@ export const projectMembershipServiceFactory = ({ return projectMembershipDAL.findAllProjectMembers(projectId); }; + const getProjectMembershipByUsername = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + username + }: TGetProjectMembershipByUsernameDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + + const [membership] = await projectMembershipDAL.findAllProjectMembers(projectId, { username }); + if (!membership) throw new BadRequestError({ message: `Project membership not found for user ${username}` }); + return membership; + }; + const addUsersToProject = async ({ projectId, actorId, @@ -110,7 +133,7 @@ export const projectMembershipServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); const orgMembers = await orgDAL.findMembership({ - orgId: project.orgId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: project.orgId, $in: { [`${TableName.OrgMembership}.id` as "id"]: members.map(({ orgMembershipId }) => orgMembershipId) } @@ -119,7 +142,7 @@ export const projectMembershipServiceFactory = ({ const existingMembers = await projectMembershipDAL.find({ projectId, - $in: { userId: orgMembers.map(({ userId }) => userId).filter(Boolean) as string[] } + $in: { userId: orgMembers.map(({ userId }) => userId).filter(Boolean) } }); if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); @@ -134,7 +157,7 @@ export const projectMembershipServiceFactory = ({ const projectMemberships = await projectMembershipDAL.insertMany( orgMembers.map(({ userId }) => ({ projectId, - userId: userId as string + userId })), tx ); @@ -145,12 +168,12 @@ export const projectMembershipServiceFactory = ({ const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); await projectKeyDAL.insertMany( orgMembers - .filter(({ userId }) => !userIdsToExcludeForProjectKeyAddition.has(userId as string)) + .filter(({ userId }) => !userIdsToExcludeForProjectKeyAddition.has(userId)) .map(({ userId, id }) => ({ encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, senderId: actorId, - receiverId: userId as string, + receiverId: userId, projectId })), tx @@ -510,6 +533,7 @@ export const projectMembershipServiceFactory = ({ return { getProjectMemberships, + getProjectMembershipByUsername, updateProjectMembership, addUsersToProjectNonE2EE, deleteProjectMemberships, diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 2ba245c8c..1eab75265 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -9,6 +9,10 @@ export type TInviteUserToProjectDTO = { emails: string[]; } & TProjectPermission; +export type TGetProjectMembershipByUsernameDTO = { + username: string; +} & TProjectPermission; + export type TUpdateProjectMembershipDTO = { membershipId: string; roles: ( diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 831af3200..ffd446fad 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -1,25 +1,30 @@ -import { ForbiddenError } from "@casl/ability"; -import { packRules } from "@casl/ability/extra"; +import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; +import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; -import { ProjectMembershipRole, TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas"; +import { ProjectMembershipRole } from "@app/db/schemas"; +import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { projectAdminPermissions, projectMemberPermissions, projectNoAccessPermissions, ProjectPermissionActions, + ProjectPermissionSet, ProjectPermissionSub, projectViewerPermission } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; -import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { ActorAuthMethod } from "../auth/auth-type"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "./project-role-dal"; +import { TCreateRoleDTO, TDeleteRoleDTO, TGetRoleBySlugDTO, TListRolesDTO, TUpdateRoleDTO } from "./project-role-types"; type TProjectRoleServiceFactoryDep = { projectRoleDAL: TProjectRoleDALFactory; + projectDAL: Pick; permissionService: Pick; identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory; projectUserMembershipRoleDAL: TProjectUserMembershipRoleDALFactory; @@ -27,20 +32,68 @@ type TProjectRoleServiceFactoryDep = { export type TProjectRoleServiceFactory = ReturnType; +const unpackPermissions = (permissions: unknown) => + UnpackedPermissionSchema.array().parse( + unpackRules((permissions || []) as PackRule>>[]) + ); + +const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => { + return [ + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid + projectId, + name: "Admin", + slug: ProjectMembershipRole.Admin, + permissions: projectAdminPermissions, + description: "Full administrative access over a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response + projectId, + name: "Developer", + slug: ProjectMembershipRole.Member, + permissions: projectMemberPermissions, + description: "Limited read/write role in a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response + projectId, + name: "Viewer", + slug: ProjectMembershipRole.Viewer, + permissions: projectViewerPermission, + description: "Only read role in a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response + projectId, + name: "No Access", + slug: ProjectMembershipRole.NoAccess, + permissions: projectNoAccessPermissions, + description: "No access to any resources in the project", + createdAt: new Date(), + updatedAt: new Date() + } + ].filter(({ slug }) => !roleFilter || roleFilter.includes(slug)); +}; + export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService, identityProjectMembershipRoleDAL, - projectUserMembershipRoleDAL + projectUserMembershipRoleDAL, + projectDAL }: TProjectRoleServiceFactoryDep) => { - const createRole = async ( - actor: ActorType, - actorId: string, - projectId: string, - data: Omit, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const createRole = async ({ projectSlug, data, actor, actorId, actorAuthMethod, actorOrgId }: TCreateRoleDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -53,21 +106,54 @@ export const projectRoleServiceFactory = ({ if (existingRole) throw new BadRequestError({ name: "Create Role", message: "Duplicate role" }); const role = await projectRoleDAL.create({ ...data, - projectId, - permissions: JSON.stringify(data.permissions) + projectId }); - return role; + return { ...role, permissions: unpackPermissions(role.permissions) }; }; - const updateRole = async ( - actor: ActorType, - actorId: string, - projectId: string, - roleId: string, - data: Omit, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const getRoleBySlug = async ({ + actor, + actorId, + projectSlug, + actorAuthMethod, + actorOrgId, + roleSlug + }: TGetRoleBySlugDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + if (roleSlug !== "custom" && Object.values(ProjectMembershipRole).includes(roleSlug as ProjectMembershipRole)) { + const predefinedRole = getPredefinedRoles(projectId, roleSlug as ProjectMembershipRole)[0]; + return { ...predefinedRole, permissions: UnpackedPermissionSchema.array().parse(predefinedRole.permissions) }; + } + + const customRole = await projectRoleDAL.findOne({ slug: roleSlug, projectId }); + if (!customRole) throw new BadRequestError({ message: "Role not found" }); + return { ...customRole, permissions: unpackPermissions(customRole.permissions) }; + }; + + const updateRole = async ({ + roleId, + projectSlug, + actorOrgId, + actorAuthMethod, + actorId, + actor, + data + }: TUpdateRoleDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -81,22 +167,16 @@ export const projectRoleServiceFactory = ({ if (existingRole && existingRole.id !== roleId) throw new BadRequestError({ name: "Update Role", message: "Duplicate role" }); } - const [updatedRole] = await projectRoleDAL.update( - { id: roleId, projectId }, - { ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined } - ); + const [updatedRole] = await projectRoleDAL.update({ id: roleId, projectId }, data); if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); - return updatedRole; + return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) }; }; - const deleteRole = async ( - actor: ActorType, - actorId: string, - projectId: string, - roleId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const deleteRole = async ({ actor, actorId, actorAuthMethod, actorOrgId, projectSlug, roleId }: TDeleteRoleDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -125,16 +205,14 @@ export const projectRoleServiceFactory = ({ const [deletedRole] = await projectRoleDAL.delete({ id: roleId, projectId }); if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Delete role" }); - return deletedRole; + return { ...deletedRole, permissions: unpackPermissions(deletedRole.permissions) }; }; - const listRoles = async ( - actor: ActorType, - actorId: string, - projectId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const listRoles = async ({ projectSlug, actorOrgId, actorAuthMethod, actorId, actor }: TListRolesDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -144,52 +222,7 @@ export const projectRoleServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); const customRoles = await projectRoleDAL.find({ projectId }); - const roles = [ - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid - projectId, - name: "Admin", - slug: ProjectMembershipRole.Admin, - description: "Complete administration access over the project", - permissions: packRules(projectAdminPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response - projectId, - name: "Developer", - slug: ProjectMembershipRole.Member, - description: "Non-administrative role in an project", - permissions: packRules(projectMemberPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response - projectId, - name: "Viewer", - slug: ProjectMembershipRole.Viewer, - description: "Non-administrative role in an project", - permissions: packRules(projectViewerPermission), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response - projectId, - name: "No Access", - slug: "no-access", - description: "No access to any resources in the project", - permissions: packRules(projectNoAccessPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - ...(customRoles || []).map(({ permissions, ...data }) => ({ - ...data, - permissions - })) - ]; + const roles = [...getPredefinedRoles(projectId), ...(customRoles || [])]; return roles; }; @@ -209,5 +242,5 @@ export const projectRoleServiceFactory = ({ return { permissions: packRules(permission.rules), membership }; }; - return { createRole, updateRole, deleteRole, listRoles, getUserPermission }; + return { createRole, updateRole, deleteRole, listRoles, getUserPermission, getRoleBySlug }; }; diff --git a/backend/src/services/project-role/project-role-types.ts b/backend/src/services/project-role/project-role-types.ts index e69de29bb..62b627a79 100644 --- a/backend/src/services/project-role/project-role-types.ts +++ b/backend/src/services/project-role/project-role-types.ts @@ -0,0 +1,27 @@ +import { TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateRoleDTO = { + data: Omit; + projectSlug: string; +} & Omit; + +export type TGetRoleBySlugDTO = { + roleSlug: string; + projectSlug: string; +} & Omit; + +export type TUpdateRoleDTO = { + roleId: string; + data: Omit; + projectSlug: string; +} & Omit; + +export type TDeleteRoleDTO = { + roleId: string; + projectSlug: string; +} & Omit; + +export type TListRolesDTO = { + projectSlug: string; +} & Omit; diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts index 81ecd6da1..8f1e3fc3f 100644 --- a/backend/src/services/project/project-queue.ts +++ b/backend/src/services/project/project-queue.ts @@ -8,6 +8,7 @@ import { SecretKeyEncoding, SecretsSchema, SecretVersionsSchema, + TableName, TIntegrationAuths, TSecretApprovalRequestsSecrets, TSecrets, @@ -273,7 +274,10 @@ export const projectQueueFactory = ({ for (const key of existingProjectKeys) { const user = await userDAL.findUserEncKeyByUserId(key.receiverId); - const [orgMembership] = await orgDAL.findMembership({ userId: key.receiverId, orgId: project.orgId }); + const [orgMembership] = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.userId` as "userId"]: key.receiverId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: project.orgId + }); if (!user) { throw new Error(`User with ID ${key.receiverId} was not found during upgrade.`); diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 008dac593..f58fd7788 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -340,7 +340,7 @@ export const projectServiceFactory = ({ const deletedProject = await projectDAL.transaction(async (tx) => { const delProject = await projectDAL.deleteById(project.id, tx); - const projectGhostUser = await projectMembershipDAL.findProjectGhostUser(project.id).catch(() => null); + const projectGhostUser = await projectMembershipDAL.findProjectGhostUser(project.id, tx).catch(() => null); // Delete the org membership for the ghost user if it's found. if (projectGhostUser) { diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts new file mode 100644 index 000000000..afae2677f --- /dev/null +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -0,0 +1,62 @@ +import { TAuditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal"; + +type TDailyResourceCleanUpQueueServiceFactoryDep = { + auditLogDAL: Pick; + identityAccessTokenDAL: Pick; + secretSharingDAL: Pick; + queueService: TQueueServiceFactory; +}; + +export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; + +export const dailyResourceCleanUpQueueServiceFactory = ({ + auditLogDAL, + queueService, + identityAccessTokenDAL, + secretSharingDAL +}: TDailyResourceCleanUpQueueServiceFactoryDep) => { + queueService.start(QueueName.DailyResourceCleanUp, async () => { + logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); + await auditLogDAL.pruneAuditLog(); + await identityAccessTokenDAL.removeExpiredTokens(); + await secretSharingDAL.pruneExpiredSharedSecrets(); + logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); + }); + + // we do a repeat cron job in utc timezone at 12 Midnight each day + const startCleanUp = async () => { + // TODO(akhilmhdh): remove later + 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, + { pattern: "0 0 * * *", utc: true }, + 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 } + }); + }; + + queueService.listen(QueueName.DailyResourceCleanUp, "failed", (_, err) => { + logger.error(err, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`); + }); + + return { + startCleanUp + }; +}; diff --git a/backend/src/services/secret-folder/secret-folder-dal.ts b/backend/src/services/secret-folder/secret-folder-dal.ts index b3147d1fa..0e896d0c6 100644 --- a/backend/src/services/secret-folder/secret-folder-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-dal.ts @@ -169,6 +169,7 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str // this is for root condition // if the given folder id is root folder id then intial path is set as / instead of /root // if not root folder the path here will be / + depth: 1, path: db.raw(`CONCAT('/', (CASE WHEN "parentId" is NULL THEN '' ELSE ${TableName.SecretFolder}.name END))`), child: db.raw("NULL::uuid"), environmentSlug: `${TableName.Environment}.slug` @@ -185,6 +186,7 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str .select({ // then we join join this folder name behind previous as we are going from child to parent // the root folder check is used to avoid last / and also root name in folders + depth: db.raw("parent.depth + 1"), path: db.raw( `CONCAT( CASE WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' @@ -199,7 +201,7 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str ); }) .select("*") - .from("parent"); + .from("parent"); export type TSecretFolderDALFactory = ReturnType; // never change this. If u do write a migration for it @@ -260,12 +262,23 @@ export const secretFolderDALFactory = (db: TDbClient) => { try { const folders = await sqlFindSecretPathByFolderId(tx || db, projectId, folderIds); + // travelling all the way from leaf node to root contains real path const rootFolders = groupBy( folders.filter(({ parentId }) => parentId === null), (i) => i.child || i.id // root condition then child and parent will null ); + const actualFolders = groupBy( + folders.filter(({ depth }) => depth === 1), + (i) => i.id // root condition then child and parent will null + ); - return folderIds.map((folderId) => rootFolders[folderId]?.[0]); + return folderIds.map((folderId) => { + if (!rootFolders[folderId]?.[0]) return; + + const actualId = rootFolders[folderId][0].child || rootFolders[folderId][0].id; + const folder = actualFolders[actualId][0]; + return { ...folder, path: rootFolders[folderId]?.[0].path }; + }); } catch (error) { throw new DatabaseError({ error, name: "Find by secret path" }); } diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index c925d2587..97258c006 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -8,9 +8,16 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { BadRequestError } from "@app/lib/errors"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretFolderDALFactory } from "./secret-folder-dal"; -import { TCreateFolderDTO, TDeleteFolderDTO, TGetFolderDTO, TUpdateFolderDTO } from "./secret-folder-types"; +import { + TCreateFolderDTO, + TDeleteFolderDTO, + TGetFolderDTO, + TUpdateFolderDTO, + TUpdateManyFoldersDTO +} from "./secret-folder-types"; import { TSecretFolderVersionDALFactory } from "./secret-folder-version-dal"; type TSecretFolderServiceFactoryDep = { @@ -19,6 +26,7 @@ type TSecretFolderServiceFactoryDep = { folderDAL: TSecretFolderDALFactory; projectEnvDAL: Pick; folderVersionDAL: TSecretFolderVersionDALFactory; + projectDAL: Pick; }; export type TSecretFolderServiceFactory = ReturnType; @@ -28,7 +36,8 @@ export const secretFolderServiceFactory = ({ snapshotService, permissionService, projectEnvDAL, - folderVersionDAL + folderVersionDAL, + projectDAL }: TSecretFolderServiceFactoryDep) => { const createFolder = async ({ projectId, @@ -116,6 +125,105 @@ export const secretFolderServiceFactory = ({ return folder; }; + const updateManyFolders = async ({ + actor, + actorId, + projectSlug, + actorAuthMethod, + actorOrgId, + folders + }: TUpdateManyFoldersDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) { + throw new BadRequestError({ message: "Project not found" }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + folders.forEach(({ environment, path: secretPath }) => { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + }); + + const result = await folderDAL.transaction(async (tx) => + Promise.all( + folders.map(async (newFolder) => { + const { environment, path: secretPath, id, name } = newFolder; + + const parentFolder = await folderDAL.findBySecretPath(project.id, environment, secretPath); + if (!parentFolder) { + throw new BadRequestError({ message: "Secret path not found", name: "Batch update folder" }); + } + + const env = await projectEnvDAL.findOne({ projectId: project.id, slug: environment }); + if (!env) { + throw new BadRequestError({ message: "Environment not found", name: "Batch update folder" }); + } + const folder = await folderDAL + .findOne({ envId: env.id, id, parentId: parentFolder.id }) + // now folder api accepts id based change + // this is for cli backward compatiability and when cli removes this, we will remove this logic + .catch(() => folderDAL.findOne({ envId: env.id, name: id, parentId: parentFolder.id })); + + if (!folder) { + throw new BadRequestError({ message: "Folder not found" }); + } + if (name !== folder.name) { + // ensure that new folder name is unique + const folderToCheck = await folderDAL.findOne({ + name, + envId: env.id, + parentId: parentFolder.id + }); + + if (folderToCheck) { + throw new BadRequestError({ + message: "Folder with specified name already exists", + name: "Batch update folder" + }); + } + } + + const [doc] = await folderDAL.update( + { envId: env.id, id: folder.id, parentId: parentFolder.id }, + { name }, + tx + ); + await folderVersionDAL.create( + { + name: doc.name, + envId: doc.envId, + version: doc.version, + folderId: doc.id + }, + tx + ); + if (!doc) { + throw new BadRequestError({ message: "Folder not found", name: "Batch update folder" }); + } + + return { oldFolder: folder, newFolder: doc }; + }) + ) + ); + + await Promise.all(result.map(async (res) => snapshotService.performSnapshot(res.newFolder.parentId as string))); + + return { + projectId: project.id, + newFolders: result.map((res) => res.newFolder), + oldFolders: result.map((res) => res.oldFolder) + }; + }; + const updateFolder = async ({ projectId, actor, @@ -145,15 +253,34 @@ export const secretFolderServiceFactory = ({ const env = await projectEnvDAL.findOne({ projectId, slug: environment }); if (!env) throw new BadRequestError({ message: "Environment not found", name: "Update folder" }); const folder = await folderDAL - .findOne({ envId: env.id, id, parentId: parentFolder.id }) + .findOne({ envId: env.id, id, parentId: parentFolder.id, isReserved: false }) // now folder api accepts id based change // this is for cli backward compatiability and when cli removes this, we will remove this logic .catch(() => folderDAL.findOne({ envId: env.id, name: id, parentId: parentFolder.id })); if (!folder) throw new BadRequestError({ message: "Folder not found" }); + if (name !== folder.name) { + // ensure that new folder name is unique + const folderToCheck = await folderDAL.findOne({ + name, + envId: env.id, + parentId: parentFolder.id + }); + + if (folderToCheck) { + throw new BadRequestError({ + message: "Folder with specified name already exists", + name: "Update folder" + }); + } + } const newFolder = await folderDAL.transaction(async (tx) => { - const [doc] = await folderDAL.update({ envId: env.id, id: folder.id, parentId: parentFolder.id }, { name }, tx); + const [doc] = await folderDAL.update( + { envId: env.id, id: folder.id, parentId: parentFolder.id, isReserved: false }, + { name }, + tx + ); await folderVersionDAL.create( { name: doc.name, @@ -201,7 +328,12 @@ export const secretFolderServiceFactory = ({ if (!parentFolder) throw new BadRequestError({ message: "Secret path not found" }); const [doc] = await folderDAL.delete( - { envId: env.id, [uuidValidate(idOrName) ? "id" : "name"]: idOrName, parentId: parentFolder.id }, + { + envId: env.id, + [uuidValidate(idOrName) ? "id" : "name"]: idOrName, + parentId: parentFolder.id, + isReserved: false + }, tx ); if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Delete folder" }); @@ -231,7 +363,7 @@ export const secretFolderServiceFactory = ({ const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!parentFolder) return []; - const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id }); + const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id, isReserved: false }); return folders; }; @@ -239,6 +371,7 @@ export const secretFolderServiceFactory = ({ return { createFolder, updateFolder, + updateManyFolders, deleteFolder, getFolders }; diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index 88b7b1017..c01d5f7b8 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -1,5 +1,9 @@ import { TProjectPermission } from "@app/lib/types"; +export enum ReservedFolders { + SecretReplication = "__reserve_replication_" +} + export type TCreateFolderDTO = { environment: string; path: string; @@ -13,6 +17,16 @@ export type TUpdateFolderDTO = { name: string; } & TProjectPermission; +export type TUpdateManyFoldersDTO = { + projectSlug: string; + folders: { + environment: string; + path: string; + id: string; + name: string; + }[]; +} & Omit; + export type TDeleteFolderDTO = { environment: string; path: string; diff --git a/backend/src/services/secret-folder/secret-folder-version-dal.ts b/backend/src/services/secret-folder/secret-folder-version-dal.ts index f133308cf..73b536b48 100644 --- a/backend/src/services/secret-folder/secret-folder-version-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-version-dal.ts @@ -15,7 +15,7 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => { try { const docs = await (tx || db)(TableName.SecretFolderVersion) .join(TableName.SecretFolder, `${TableName.SecretFolderVersion}.folderId`, `${TableName.SecretFolder}.id`) - .where({ parentId: folderId }) + .where({ parentId: folderId, isReserved: false }) .join( (tx || db)(TableName.SecretFolderVersion) .groupBy("envId", "folderId") diff --git a/backend/src/services/secret-import/secret-import-dal.ts b/backend/src/services/secret-import/secret-import-dal.ts index aa45d410d..0e73a8c23 100644 --- a/backend/src/services/secret-import/secret-import-dal.ts +++ b/backend/src/services/secret-import/secret-import-dal.ts @@ -20,14 +20,14 @@ export const secretImportDALFactory = (db: TDbClient) => { return lastPos?.position || 0; }; - const updateAllPosition = async (folderId: string, pos: number, targetPos: number, tx?: Knex) => { + const updateAllPosition = async (folderId: string, pos: number, targetPos: number, positionInc = 1, tx?: Knex) => { try { if (targetPos === -1) { // this means delete await (tx || db)(TableName.SecretImport) .where({ folderId }) .andWhere("position", ">", pos) - .decrement("position", 1); + .decrement("position", positionInc); return; } @@ -36,13 +36,13 @@ export const secretImportDALFactory = (db: TDbClient) => { .where({ folderId }) .where("position", "<=", targetPos) .andWhere("position", ">", pos) - .decrement("position", 1); + .decrement("position", positionInc); } else { await (tx || db)(TableName.SecretImport) .where({ folderId }) .where("position", ">=", targetPos) .andWhere("position", "<", pos) - .increment("position", 1); + .increment("position", positionInc); } } catch (error) { throw new DatabaseError({ error, name: "Update position" }); @@ -74,6 +74,7 @@ export const secretImportDALFactory = (db: TDbClient) => { try { const docs = await (tx || db)(TableName.SecretImport) .whereIn("folderId", folderIds) + .where("isReplication", false) .join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`) .select( db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports, diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index fffc22a99..06ffbc903 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -79,7 +79,7 @@ export const fnSecretsFromImports = async ({ let secretsFromDeeperImports: TSecretImportSecrets[] = []; if (deeperImports.length) { secretsFromDeeperImports = await fnSecretsFromImports({ - allowedImports: deeperImports, + allowedImports: deeperImports.filter(({ isReplication }) => !isReplication), secretImportDAL, folderDAL, secretDAL, diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 43676ba04..237c7cfe4 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -1,7 +1,12 @@ +import path from "node:path"; + import { ForbiddenError, subject } from "@casl/ability"; +import { TableName } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { getReplicationFolderName } from "@app/ee/services/secret-replication/secret-replication-service"; import { BadRequestError } from "@app/lib/errors"; import { TProjectDALFactory } from "../project/project-dal"; @@ -16,6 +21,7 @@ import { TDeleteSecretImportDTO, TGetSecretImportsDTO, TGetSecretsFromImportDTO, + TResyncSecretImportReplicationDTO, TUpdateSecretImportDTO } from "./secret-import-types"; @@ -26,7 +32,8 @@ type TSecretImportServiceFactoryDep = { projectDAL: Pick; projectEnvDAL: TProjectEnvDALFactory; permissionService: Pick; - secretQueueService: Pick; + secretQueueService: Pick; + licenseService: Pick; }; const ERR_SEC_IMP_NOT_FOUND = new BadRequestError({ message: "Secret import not found" }); @@ -40,7 +47,8 @@ export const secretImportServiceFactory = ({ folderDAL, projectDAL, secretDAL, - secretQueueService + secretQueueService, + licenseService }: TSecretImportServiceFactoryDep) => { const createImport = async ({ environment, @@ -50,7 +58,8 @@ export const secretImportServiceFactory = ({ actorOrgId, actorAuthMethod, projectId, - path + isReplication, + path: secretPath }: TCreateSecretImportDTO) => { const { permission } = await permissionService.getProjectPermission( actor, @@ -63,7 +72,7 @@ export const secretImportServiceFactory = ({ // check if user has permission to import into destination path ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); // check if user has permission to import from target path @@ -74,10 +83,18 @@ export const secretImportServiceFactory = ({ secretPath: data.path }) ); + if (isReplication) { + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.secretApproval) { + throw new BadRequestError({ + message: "Failed to create secret replication due to plan restriction. Upgrade plan to create replication." + }); + } + } await projectDAL.checkProjectUpgradeStatus(projectId); - const folder = await folderDAL.findBySecretPath(projectId, environment, path); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create import" }); const [importEnv] = await projectEnvDAL.findBySlugs(projectId, [data.environment]); @@ -88,35 +105,62 @@ export const secretImportServiceFactory = ({ const existingImport = await secretImportDAL.findOne({ folderId: sourceFolder.id, importEnv: folder.environment.id, - importPath: path + importPath: secretPath }); if (existingImport) throw new BadRequestError({ message: "Cyclic import not allowed" }); } const secImport = await secretImportDAL.transaction(async (tx) => { const lastPos = await secretImportDAL.findLastImportPosition(folder.id, tx); - return secretImportDAL.create( + const doc = await secretImportDAL.create( { folderId: folder.id, position: lastPos + 1, importEnv: importEnv.id, - importPath: data.path + importPath: data.path, + isReplication }, tx ); + if (doc.isReplication) { + await secretImportDAL.create( + { + folderId: folder.id, + position: lastPos + 2, + isReserved: true, + importEnv: folder.environment.id, + importPath: path.join(secretPath, getReplicationFolderName(doc.id)) + }, + tx + ); + } + return doc; }); - await secretQueueService.syncSecrets({ - secretPath: secImport.importPath, - projectId, - environment: importEnv.slug - }); + if (secImport.isReplication && sourceFolder) { + await secretQueueService.replicateSecrets({ + secretPath: secImport.importPath, + projectId, + environmentSlug: importEnv.slug, + pickOnlyImportIds: [secImport.id], + actorId, + actor + }); + } else { + await secretQueueService.syncSecrets({ + secretPath, + projectId, + environmentSlug: environment, + actorId, + actor + }); + } return { ...secImport, importEnv }; }; const updateImport = async ({ - path, + path: secretPath, environment, projectId, actor, @@ -135,10 +179,10 @@ export const secretImportServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); - const folder = await folderDAL.findBySecretPath(projectId, environment, path); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" }); const secImpDoc = await secretImportDAL.findOne({ folderId: folder.id, id }); @@ -158,7 +202,7 @@ export const secretImportServiceFactory = ({ const existingImport = await secretImportDAL.findOne({ folderId: sourceFolder.id, importEnv: folder.environment.id, - importPath: path + importPath: secretPath }); if (existingImport) throw new BadRequestError({ message: "Cyclic import not allowed" }); } @@ -167,12 +211,31 @@ export const secretImportServiceFactory = ({ const secImp = await secretImportDAL.findOne({ folderId: folder.id, id }); if (!secImp) throw ERR_SEC_IMP_NOT_FOUND; if (data.position) { - await secretImportDAL.updateAllPosition(folder.id, secImp.position, data.position, tx); + if (secImp.isReplication) { + await secretImportDAL.updateAllPosition(folder.id, secImp.position, data.position, 2, tx); + } else { + await secretImportDAL.updateAllPosition(folder.id, secImp.position, data.position, 1, tx); + } + } + if (secImp.isReplication) { + const replicationFolderPath = path.join(secretPath, getReplicationFolderName(secImp.id)); + await secretImportDAL.update( + { + folderId: folder.id, + importEnv: folder.environment.id, + importPath: replicationFolderPath, + isReserved: true + }, + { position: data?.position ? data.position + 1 : undefined }, + tx + ); } const [doc] = await secretImportDAL.update( { id, folderId: folder.id }, { - position: data?.position, + // when moving replicated import, the position is meant for reserved import + // replicated one should always be behind the reserved import + position: data.position, importEnv: data?.environment ? importedEnv.id : undefined, importPath: data?.path }, @@ -184,7 +247,7 @@ export const secretImportServiceFactory = ({ }; const deleteImport = async ({ - path, + path: secretPath, environment, projectId, actor, @@ -202,16 +265,34 @@ export const secretImportServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); - const folder = await folderDAL.findBySecretPath(projectId, environment, path); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Delete import" }); const secImport = await secretImportDAL.transaction(async (tx) => { const [doc] = await secretImportDAL.delete({ folderId: folder.id, id }, tx); if (!doc) throw new BadRequestError({ name: "Sec imp del", message: "Secret import doc not found" }); - await secretImportDAL.updateAllPosition(folder.id, doc.position, -1, tx); + if (doc.isReplication) { + const replicationFolderPath = path.join(secretPath, getReplicationFolderName(doc.id)); + const replicatedFolder = await folderDAL.findBySecretPath(projectId, environment, replicationFolderPath, tx); + if (replicatedFolder) { + await secretImportDAL.delete( + { + folderId: folder.id, + importEnv: folder.environment.id, + importPath: replicationFolderPath, + isReserved: true + }, + tx + ); + await folderDAL.deleteById(replicatedFolder.id, tx); + } + await secretImportDAL.updateAllPosition(folder.id, doc.position, -1, 2, tx); + } else { + await secretImportDAL.updateAllPosition(folder.id, doc.position, -1, 1, tx); + } const importEnv = await projectEnvDAL.findById(doc.importEnv); if (!importEnv) throw new BadRequestError({ error: "Imported env not found", name: "Create import" }); @@ -219,16 +300,91 @@ export const secretImportServiceFactory = ({ }); await secretQueueService.syncSecrets({ - secretPath: path, + secretPath, projectId, - environment + environmentSlug: environment, + actor, + actorId }); return secImport; }; + const resyncSecretImportReplication = async ({ + environment, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + path: secretPath, + id: secretImportDocId + }: TResyncSecretImportReplicationDTO) => { + const { permission, membership } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + // check if user has permission to import into destination path + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.secretApproval) { + throw new BadRequestError({ + message: "Failed to create secret replication due to plan restriction. Upgrade plan to create replication." + }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" }); + + const [secretImportDoc] = await secretImportDAL.find({ + folderId: folder.id, + [`${TableName.SecretImport}.id` as "id"]: secretImportDocId + }); + if (!secretImportDoc) throw new BadRequestError({ message: "Failed to find secret import" }); + + if (!secretImportDoc.isReplication) throw new BadRequestError({ message: "Import is not in replication mode" }); + + // check if user has permission to import from target path + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { + environment: secretImportDoc.importEnv.slug, + secretPath: secretImportDoc.importPath + }) + ); + + await projectDAL.checkProjectUpgradeStatus(projectId); + + const sourceFolder = await folderDAL.findBySecretPath( + projectId, + secretImportDoc.importEnv.slug, + secretImportDoc.importPath + ); + + if (membership && sourceFolder) { + await secretQueueService.replicateSecrets({ + secretPath: secretImportDoc.importPath, + projectId, + environmentSlug: secretImportDoc.importEnv.slug, + pickOnlyImportIds: [secretImportDoc.id], + actorId, + actor + }); + } + + return { message: "replication started" }; + }; + const getImports = async ({ - path, + path: secretPath, environment, projectId, actor, @@ -245,10 +401,10 @@ export const secretImportServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); - const folder = await folderDAL.findBySecretPath(projectId, environment, path); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" }); const secImports = await secretImportDAL.find({ folderId: folder.id }); @@ -256,7 +412,7 @@ export const secretImportServiceFactory = ({ }; const getSecretsFromImports = async ({ - path, + path: secretPath, environment, projectId, actor, @@ -273,13 +429,13 @@ export const secretImportServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); - const folder = await folderDAL.findBySecretPath(projectId, environment, path); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) return []; // this will already order by position // so anything based on this order will also be in right position - const secretImports = await secretImportDAL.find({ folderId: folder.id }); + const secretImports = await secretImportDAL.find({ folderId: folder.id, isReplication: false }); const allowedImports = secretImports.filter(({ importEnv, importPath }) => permission.can( @@ -299,6 +455,7 @@ export const secretImportServiceFactory = ({ deleteImport, getImports, getSecretsFromImports, + resyncSecretImportReplication, fnSecretsFromImports }; }; diff --git a/backend/src/services/secret-import/secret-import-types.ts b/backend/src/services/secret-import/secret-import-types.ts index d123f28da..01847738b 100644 --- a/backend/src/services/secret-import/secret-import-types.ts +++ b/backend/src/services/secret-import/secret-import-types.ts @@ -7,6 +7,7 @@ export type TCreateSecretImportDTO = { environment: string; path: string; }; + isReplication?: boolean; } & TProjectPermission; export type TUpdateSecretImportDTO = { @@ -16,6 +17,12 @@ export type TUpdateSecretImportDTO = { data: Partial<{ environment: string; path: string; position: number }>; } & TProjectPermission; +export type TResyncSecretImportReplicationDTO = { + environment: string; + path: string; + id: string; +} & TProjectPermission; + export type TDeleteSecretImportDTO = { environment: string; path: string; diff --git a/backend/src/services/secret-sharing/secret-sharing-dal.ts b/backend/src/services/secret-sharing/secret-sharing-dal.ts new file mode 100644 index 000000000..6b5090d66 --- /dev/null +++ b/backend/src/services/secret-sharing/secret-sharing-dal.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TSecretSharingDALFactory = ReturnType; + +export const secretSharingDALFactory = (db: TDbClient) => { + const sharedSecretOrm = ormify(db, TableName.SecretSharing); + + const pruneExpiredSharedSecrets = async (tx?: Knex) => { + try { + const today = new Date(); + const docs = await (tx || db)(TableName.SecretSharing).where("expiresAt", "<", today).del(); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "pruneExpiredSharedSecrets" }); + } + }; + + return { + ...sharedSecretOrm, + pruneExpiredSharedSecrets + }; +}; diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts new file mode 100644 index 000000000..ccbce0a52 --- /dev/null +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -0,0 +1,84 @@ +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { UnauthorizedError } from "@app/lib/errors"; + +import { TSecretSharingDALFactory } from "./secret-sharing-dal"; +import { TCreateSharedSecretDTO, TDeleteSharedSecretDTO, TSharedSecretPermission } from "./secret-sharing-types"; + +type TSecretSharingServiceFactoryDep = { + permissionService: Pick; + secretSharingDAL: TSecretSharingDALFactory; +}; + +export type TSecretSharingServiceFactory = ReturnType; + +export const secretSharingServiceFactory = ({ + permissionService, + secretSharingDAL +}: TSecretSharingServiceFactoryDep) => { + const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => { + const { + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + encryptedValue, + iv, + tag, + hashedHex, + expiresAt, + expiresAfterViews + } = createSharedSecretInput; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (!permission) throw new UnauthorizedError({ name: "User not in org" }); + const newSharedSecret = await secretSharingDAL.create({ + encryptedValue, + iv, + tag, + hashedHex, + expiresAt, + expiresAfterViews, + userId: actorId, + orgId + }); + return { id: newSharedSecret.id }; + }; + + const getSharedSecrets = async (getSharedSecretsInput: TSharedSecretPermission) => { + const { actor, actorId, orgId, actorAuthMethod, actorOrgId } = getSharedSecretsInput; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (!permission) throw new UnauthorizedError({ name: "User not in org" }); + const userSharedSecrets = await secretSharingDAL.find({ userId: actorId, orgId }, { sort: [["expiresAt", "asc"]] }); + return userSharedSecrets; + }; + + const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => { + const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex }); + if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) { + return; + } + if (sharedSecret.expiresAfterViews != null && sharedSecret.expiresAfterViews >= 0) { + if (sharedSecret.expiresAfterViews === 0) { + await secretSharingDAL.deleteById(sharedSecretId); + return; + } + await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } }); + } + return sharedSecret; + }; + + const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => { + const { actor, actorId, orgId, actorAuthMethod, actorOrgId, sharedSecretId } = deleteSharedSecretInput; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (!permission) throw new UnauthorizedError({ name: "User not in org" }); + const deletedSharedSecret = await secretSharingDAL.deleteById(sharedSecretId); + return deletedSharedSecret; + }; + + return { + createSharedSecret, + getSharedSecrets, + deleteSharedSecretById, + getActiveSharedSecretByIdAndHashedHex + }; +}; diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts new file mode 100644 index 000000000..5f35b2848 --- /dev/null +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -0,0 +1,22 @@ +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; + +export type TSharedSecretPermission = { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + orgId: string; +}; + +export type TCreateSharedSecretDTO = { + encryptedValue: string; + iv: string; + tag: string; + hashedHex: string; + expiresAt: Date; + expiresAfterViews: number; +} & TSharedSecretPermission; + +export type TDeleteSharedSecretDTO = { + sharedSecretId: string; +} & TSharedSecretPermission; diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index 8a5970b83..1a2e414dd 100644 --- a/backend/src/services/secret/secret-dal.ts +++ b/backend/src/services/secret/secret-dal.ts @@ -243,6 +243,74 @@ export const secretDALFactory = (db: TDbClient) => { } }; + const upsertSecretReferences = async ( + data: { + secretId: string; + references: Array<{ environment: string; secretPath: string }>; + }[] = [], + tx?: Knex + ) => { + try { + if (!data.length) return; + + await (tx || db)(TableName.SecretReference) + .whereIn( + "secretId", + data.map(({ secretId }) => secretId) + ) + .delete(); + const newSecretReferences = data + .filter(({ references }) => references.length) + .flatMap(({ secretId, references }) => + references.map(({ environment, secretPath }) => ({ + secretPath, + secretId, + environment + })) + ); + if (!newSecretReferences.length) return; + const secretReferences = await (tx || db)(TableName.SecretReference).insert(newSecretReferences); + return secretReferences; + } catch (error) { + throw new DatabaseError({ error, name: "UpsertSecretReference" }); + } + }; + + const findReferencedSecretReferences = async (projectId: string, envSlug: string, secretPath: string, tx?: Knex) => { + try { + const docs = await (tx || db)(TableName.SecretReference) + .where({ + secretPath, + environment: envSlug + }) + .join(TableName.Secret, `${TableName.Secret}.id`, `${TableName.SecretReference}.secretId`) + .join(TableName.SecretFolder, `${TableName.Secret}.folderId`, `${TableName.SecretFolder}.id`) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .where("projectId", projectId) + .select(selectAllTableCols(TableName.SecretReference)) + .select("folderId"); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindReferencedSecretReferences" }); + } + }; + + // special query to backfill secret value + const findAllProjectSecretValues = async (projectId: string, tx?: Knex) => { + try { + const docs = await (tx || db)(TableName.Secret) + .join(TableName.SecretFolder, `${TableName.Secret}.folderId`, `${TableName.SecretFolder}.id`) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .where("projectId", projectId) + // not empty + .whereNotNull("secretValueCiphertext") + .select("secretValueTag", "secretValueCiphertext", "secretValueIV", `${TableName.Secret}.id` as "id"); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindAllProjectSecretValues" }); + } + }; + return { ...secretOrm, update, @@ -252,6 +320,9 @@ export const secretDALFactory = (db: TDbClient) => { getSecretTags, findByFolderId, findByFolderIds, - findByBlindIndexes + findByBlindIndexes, + upsertSecretReferences, + findReferencedSecretReferences, + findAllProjectSecretValues }; }; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index fb2b90ba9..3cd6c4e6e 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -32,6 +32,8 @@ import { TCreateManySecretsRawFn, TCreateManySecretsRawFnFactory, TFnSecretBlindIndexCheck, + TFnSecretBlindIndexCheckV2, + TFnSecretBulkDelete, TFnSecretBulkInsert, TFnSecretBulkUpdate, TUpdateManySecretsRawFn, @@ -149,7 +151,8 @@ export const recursivelyGetSecretPaths = ({ // Fetch all folders in env once with a single query const folders = await folderDAL.find({ - envId: env.id + envId: env.id, + isReserved: false }); // Build the folder hierarchy map @@ -194,6 +197,7 @@ type TInterpolateSecretArg = { folderDAL: Pick; }; +const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g; export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderDAL }: TInterpolateSecretArg) => { const fetchSecretsCrossEnv = () => { const fetchCache: Record> = {}; @@ -235,7 +239,6 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD }; }; - const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g; const recursivelyExpandSecret = async ( expandedSec: Record, interpolatedSec: Record, @@ -353,7 +356,7 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD }; export const decryptSecretRaw = ( - secret: TSecrets & { workspace: string; environment: string; secretPath?: string }, + secret: TSecrets & { workspace: string; environment: string; secretPath: string }, key: string ) => { const secretKey = decryptSymmetric128BitHexKeyUTF8({ @@ -396,6 +399,61 @@ export const decryptSecretRaw = ( }; }; +// this is used when secret blind index already exist +// mainly for secret approval +export const fnSecretBlindIndexCheckV2 = async ({ + inputSecrets, + folderId, + userId, + secretDAL +}: TFnSecretBlindIndexCheckV2) => { + if (inputSecrets.some(({ type }) => type === SecretType.Personal) && !userId) { + throw new BadRequestError({ message: "Missing user id for personal secret" }); + } + const secrets = await secretDAL.findByBlindIndexes( + folderId, + inputSecrets.map(({ secretBlindIndex, type }) => ({ + blindIndex: secretBlindIndex, + type: type || SecretType.Shared + })), + userId + ); + const secsGroupedByBlindIndex = groupBy(secrets, (i) => i.secretBlindIndex as string); + + return { secsGroupedByBlindIndex, secrets }; +}; + +/** + * Grabs and processes nested secret references from a string + * + * This function looks for patterns that match the interpolation syntax in the input string. + * It filters out references that include nested paths, splits them into environment and + * secret path parts, and then returns an array of objects with the environment and the + * joined secret path. + * + * @param {string} maybeSecretReference - The string that has the potential secret references. + * @returns {Array<{ environment: string, secretPath: string }>} - An array of objects + * with the environment and joined secret path. + * + * @example + * const value = "Hello ${dev.someFolder.OtherFolder.SECRET_NAME} and ${prod.anotherFolder.SECRET_NAME}"; + * const result = getAllNestedSecretReferences(value); + * // result will be: + * // [ + * // { environment: 'dev', secretPath: '/someFolder/OtherFolder' }, + * // { environment: 'prod', secretPath: '/anotherFolder' } + * // ] + */ +export const getAllNestedSecretReferences = (maybeSecretReference: string) => { + const references = Array.from(maybeSecretReference.matchAll(INTERPOLATION_SYNTAX_REG), (m) => m[1]); + return references + .filter((el) => el.includes(".")) + .map((el) => { + const [environment, ...secretPathList] = el.split("."); + return { environment, secretPath: path.join("/", ...secretPathList.slice(0, -1)) }; + }); +}; + /** * Checks and handles secrets using a blind index method. * The function generates mappings between secret names and their blind indexes, validates user IDs for personal secrets, and retrieves secrets from the database based on their blind indexes. @@ -467,7 +525,7 @@ export const fnSecretBulkInsert = async ({ tx }: TFnSecretBulkInsert) => { const newSecrets = await secretDAL.insertMany( - inputSecrets.map(({ tags, ...el }) => ({ ...el, folderId })), + inputSecrets.map(({ tags, references, ...el }) => ({ ...el, folderId })), tx ); const newSecretGroupByBlindIndex = groupBy(newSecrets, (item) => item.secretBlindIndex as string); @@ -478,13 +536,20 @@ export const fnSecretBulkInsert = async ({ })) ); const secretVersions = await secretVersionDAL.insertMany( - inputSecrets.map(({ tags, ...el }) => ({ + inputSecrets.map(({ tags, references, ...el }) => ({ ...el, folderId, secretId: newSecretGroupByBlindIndex[el.secretBlindIndex as string][0].id })), tx ); + await secretDAL.upsertSecretReferences( + inputSecrets.map(({ references = [], secretBlindIndex }) => ({ + secretId: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id, + references + })), + tx + ); if (newSecretTags.length) { const secTags = await secretTagDAL.saveTagsToSecret(newSecretTags, tx); const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId); @@ -509,7 +574,7 @@ export const fnSecretBulkUpdate = async ({ secretVersionTagDAL }: TFnSecretBulkUpdate) => { const newSecrets = await secretDAL.bulkUpdate( - inputSecrets.map(({ filter, data: { tags, ...data } }) => ({ + inputSecrets.map(({ filter, data: { tags, references, ...data } }) => ({ filter: { ...filter, folderId }, data })), @@ -522,6 +587,15 @@ export const fnSecretBulkUpdate = async ({ })), tx ); + await secretDAL.upsertSecretReferences( + inputSecrets + .filter(({ data: { references } }) => Boolean(references)) + .map(({ data: { references = [] } }, i) => ({ + secretId: newSecrets[i].id, + references + })), + tx + ); const secsUpdatedTag = inputSecrets.flatMap(({ data: { tags } }, i) => tags !== undefined ? { tags, secretId: newSecrets[i].id } : [] ); @@ -551,6 +625,35 @@ export const fnSecretBulkUpdate = async ({ return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); }; +export const fnSecretBulkDelete = async ({ + folderId, + inputSecrets, + tx, + actorId, + secretDAL, + secretQueueService +}: TFnSecretBulkDelete) => { + const deletedSecrets = await secretDAL.deleteMany( + inputSecrets.map(({ type, secretBlindIndex }) => ({ + blindIndex: secretBlindIndex, + type + })), + folderId, + actorId, + tx + ); + + await Promise.allSettled( + deletedSecrets + .filter(({ secretReminderRepeatDays }) => Boolean(secretReminderRepeatDays)) + .map(({ id, secretReminderRepeatDays }) => + secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }) + ) + ); + + return deletedSecrets; +}; + export const createManySecretsRawFnFactory = ({ projectDAL, projectBotDAL, @@ -561,7 +664,7 @@ export const createManySecretsRawFnFactory = ({ secretVersionTagDAL, folderDAL }: TCreateManySecretsRawFnFactory) => { - const getBotKeyFn = getBotKeyFnFactory(projectBotDAL); + const getBotKeyFn = getBotKeyFnFactory(projectBotDAL, projectDAL); const createManySecretsRawFn = async ({ projectId, environment, @@ -591,50 +694,39 @@ export const createManySecretsRawFnFactory = ({ folderId, isNew: true, blindIndexCfg, + userId, secretDAL }); - const inputSecrets = await Promise.all( - secrets.map(async (secret) => { - const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey); - const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey); - const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretComment || "", botKey); + const inputSecrets = secrets.map((secret) => { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey); + const secretReferences = getAllNestedSecretReferences(secret.secretValue || ""); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretComment || "", botKey); - if (secret.type === SecretType.Personal) { - if (!userId) throw new BadRequestError({ message: "Missing user id for personal secret" }); - const sharedExist = await secretDAL.findOne({ - secretBlindIndex: keyName2BlindIndex[secret.secretName], - folderId, - type: SecretType.Shared - }); + return { + type: secret.type, + userId: secret.type === SecretType.Personal ? userId : null, + secretName: secret.secretName, + secretKeyCiphertext: secretKeyEncrypted.ciphertext, + secretKeyIV: secretKeyEncrypted.iv, + secretKeyTag: secretKeyEncrypted.tag, + secretValueCiphertext: secretValueEncrypted.ciphertext, + secretValueIV: secretValueEncrypted.iv, + secretValueTag: secretValueEncrypted.tag, + secretCommentCiphertext: secretCommentEncrypted.ciphertext, + secretCommentIV: secretCommentEncrypted.iv, + secretCommentTag: secretCommentEncrypted.tag, + skipMultilineEncoding: secret.skipMultilineEncoding, + tags: secret.tags, + references: secretReferences + }; + }); - if (!sharedExist) - throw new BadRequestError({ - message: "Failed to create personal secret override for no corresponding shared secret" - }); - } - - const tags = secret.tags ? await secretTagDAL.findManyTagsById(projectId, secret.tags) : []; - if ((secret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); - - return { - type: secret.type, - userId: secret.type === SecretType.Personal ? userId : null, - secretName: secret.secretName, - secretKeyCiphertext: secretKeyEncrypted.ciphertext, - secretKeyIV: secretKeyEncrypted.iv, - secretKeyTag: secretKeyEncrypted.tag, - secretValueCiphertext: secretValueEncrypted.ciphertext, - secretValueIV: secretValueEncrypted.iv, - secretValueTag: secretValueEncrypted.tag, - secretCommentCiphertext: secretCommentEncrypted.ciphertext, - secretCommentIV: secretCommentEncrypted.iv, - secretCommentTag: secretCommentEncrypted.tag, - skipMultilineEncoding: secret.skipMultilineEncoding, - tags: secret.tags - }; - }) - ); + // get all tags + const tagIds = inputSecrets.flatMap(({ tags = [] }) => tags); + const tags = tagIds.length ? await secretTagDAL.findManyTagsById(projectId, tagIds) : []; + if (tags.length !== tagIds.length) throw new BadRequestError({ message: "Tag not found" }); const newSecrets = await secretDAL.transaction(async (tx) => fnSecretBulkInsert({ @@ -670,7 +762,7 @@ export const updateManySecretsRawFnFactory = ({ secretVersionTagDAL, folderDAL }: TUpdateManySecretsRawFnFactory) => { - const getBotKeyFn = getBotKeyFnFactory(projectBotDAL); + const getBotKeyFn = getBotKeyFnFactory(projectBotDAL, projectDAL); const updateManySecretsRawFn = async ({ projectId, environment, @@ -703,56 +795,35 @@ export const updateManySecretsRawFnFactory = ({ userId }); - const inputSecrets = await Promise.all( - secrets.map(async (secret) => { - if (secret.newSecretName === "") { - throw new BadRequestError({ message: "New secret name cannot be empty" }); - } + const inputSecrets = secrets.map((secret) => { + if (secret.newSecretName === "") { + throw new BadRequestError({ message: "New secret name cannot be empty" }); + } - const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey); - const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey); - const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretComment || "", botKey); + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey); + const secretReferences = getAllNestedSecretReferences(secret.secretValue || ""); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretComment || "", botKey); - if (secret.type === SecretType.Personal) { - if (!userId) throw new BadRequestError({ message: "Missing user id for personal secret" }); - - const sharedExist = await secretDAL.findOne({ - secretBlindIndex: keyName2BlindIndex[secret.secretName], - folderId, - type: SecretType.Shared - }); - - if (!sharedExist) - throw new BadRequestError({ - message: "Failed to update personal secret override for no corresponding shared secret" - }); - - if (secret.newSecretName) - throw new BadRequestError({ message: "Personal secret cannot change the key name" }); - } - - const tags = secret.tags ? await secretTagDAL.findManyTagsById(projectId, secret.tags) : []; - if ((secret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); - - return { - type: secret.type, - userId: secret.type === SecretType.Personal ? userId : null, - secretName: secret.secretName, - newSecretName: secret.newSecretName, - secretKeyCiphertext: secretKeyEncrypted.ciphertext, - secretKeyIV: secretKeyEncrypted.iv, - secretKeyTag: secretKeyEncrypted.tag, - secretValueCiphertext: secretValueEncrypted.ciphertext, - secretValueIV: secretValueEncrypted.iv, - secretValueTag: secretValueEncrypted.tag, - secretCommentCiphertext: secretCommentEncrypted.ciphertext, - secretCommentIV: secretCommentEncrypted.iv, - secretCommentTag: secretCommentEncrypted.tag, - skipMultilineEncoding: secret.skipMultilineEncoding, - tags: secret.tags - }; - }) - ); + return { + type: secret.type, + userId: secret.type === SecretType.Personal ? userId : null, + secretName: secret.secretName, + newSecretName: secret.newSecretName, + secretKeyCiphertext: secretKeyEncrypted.ciphertext, + secretKeyIV: secretKeyEncrypted.iv, + secretKeyTag: secretKeyEncrypted.tag, + secretValueCiphertext: secretValueEncrypted.ciphertext, + secretValueIV: secretValueEncrypted.iv, + secretValueTag: secretValueEncrypted.tag, + secretCommentCiphertext: secretCommentEncrypted.ciphertext, + secretCommentIV: secretCommentEncrypted.iv, + secretCommentTag: secretCommentEncrypted.tag, + skipMultilineEncoding: secret.skipMultilineEncoding, + tags: secret.tags, + references: secretReferences + }; + }); const tagIds = inputSecrets.flatMap(({ tags = [] }) => tags); const tags = tagIds.length ? await secretTagDAL.findManyTagsById(projectId, tagIds) : []; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 1fc6b1109..d40a18e5e 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -28,7 +28,12 @@ import { TWebhookDALFactory } from "../webhook/webhook-dal"; import { fnTriggerWebhook } from "../webhook/webhook-fns"; import { TSecretDALFactory } from "./secret-dal"; import { interpolateSecrets } from "./secret-fns"; -import { TCreateSecretReminderDTO, THandleReminderDTO, TRemoveSecretReminderDTO } from "./secret-types"; +import { + TCreateSecretReminderDTO, + THandleReminderDTO, + TRemoveSecretReminderDTO, + TSyncSecretsDTO +} from "./secret-types"; export type TSecretQueueFactory = ReturnType; type TSecretQueueFactoryDep = { @@ -59,7 +64,10 @@ export type TGetSecrets = { }; const MAX_SYNC_SECRET_DEPTH = 5; +export const uniqueSecretQueueKey = (environment: string, secretPath: string) => + `secret-queue-dedupe-${environment}-${secretPath}`; +type TIntegrationSecret = Record; export const secretQueueFactory = ({ queueService, integrationDAL, @@ -80,61 +88,6 @@ export const secretQueueFactory = ({ secretTagDAL, secretVersionTagDAL }: TSecretQueueFactoryDep) => { - const createManySecretsRawFn = createManySecretsRawFnFactory({ - projectDAL, - projectBotDAL, - secretDAL, - secretVersionDAL, - secretBlindIndexDAL, - secretTagDAL, - secretVersionTagDAL, - folderDAL - }); - - const updateManySecretsRawFn = updateManySecretsRawFnFactory({ - projectDAL, - projectBotDAL, - secretDAL, - secretVersionDAL, - secretBlindIndexDAL, - secretTagDAL, - secretVersionTagDAL, - folderDAL - }); - - const syncIntegrations = async (dto: TGetSecrets) => { - await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, { - attempts: 5, - delay: 1000, - backoff: { - type: "exponential", - delay: 3000 - }, - removeOnComplete: true, - removeOnFail: { - count: 5 // keep the most recent jobs - } - }); - }; - - const syncSecrets = async (dto: TGetSecrets & { depth?: number }) => { - logger.info( - `syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environment}] [path=${dto.secretPath}]` - ); - await queueService.queue(QueueName.SecretWebhook, QueueJobs.SecWebhook, dto, { - jobId: `secret-webhook-${dto.environment}-${dto.projectId}-${dto.secretPath}`, - removeOnFail: { count: 5 }, - removeOnComplete: true, - delay: 1000, - attempts: 5, - backoff: { - type: "exponential", - delay: 3000 - } - }); - await syncIntegrations(dto); - }; - const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { const appCfg = getConfig(); await queueService.stopRepeatableJob( @@ -229,8 +182,27 @@ export const secretQueueFactory = ({ } } }; + const createManySecretsRawFn = createManySecretsRawFnFactory({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL + }); - type Content = Record; + const updateManySecretsRawFn = updateManySecretsRawFnFactory({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL + }); /** * Return the secrets in a given [folderId] including secrets from @@ -243,7 +215,7 @@ export const secretQueueFactory = ({ key: string; depth: number; }) => { - let content: Content = {}; + let content: TIntegrationSecret = {}; if (dto.depth > MAX_SYNC_SECRET_DEPTH) { logger.info( `getIntegrationSecrets: secret depth exceeded for [projectId=${dto.projectId}] [folderId=${dto.folderId}] [depth=${dto.depth}]` @@ -293,7 +265,7 @@ export const secretQueueFactory = ({ await expandSecrets(content); // check if current folder has any imports from other folders - const secretImport = await secretImportDAL.find({ folderId: dto.folderId }); + const secretImport = await secretImportDAL.find({ folderId: dto.folderId, isReplication: false }); // if no imports then return secrets in the current folder if (!secretImport) return content; @@ -318,15 +290,129 @@ export const secretQueueFactory = ({ }); // add the imported secrets to the current folder secrets - content = { ...content, ...importedSecrets }; + content = { ...importedSecrets, ...content }; } } return content; }; + const syncIntegrations = async (dto: TGetSecrets & { deDupeQueue?: Record }) => { + await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, { + attempts: 3, + delay: 1000, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnComplete: true, + removeOnFail: true + }); + }; + + const replicateSecrets = async (dto: Omit) => { + await queueService.queue(QueueName.SecretReplication, QueueJobs.SecretReplication, dto, { + attempts: 3, + backoff: { + type: "exponential", + delay: 2000 + }, + removeOnComplete: true, + removeOnFail: true + }); + }; + + const syncSecrets = async ({ + // seperate de-dupe queue for integration sync and replication sync + _deDupeQueue: deDupeQueue = {}, + _depth: depth = 0, + _deDupeReplicationQueue: deDupeReplicationQueue = {}, + ...dto + }: TSyncSecretsDTO) => { + logger.info( + `syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]` + ); + const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath); + if ( + !dto.excludeReplication + ? deDupeReplicationQueue?.[deDuplicationKey] + : deDupeQueue?.[deDuplicationKey] || depth > MAX_SYNC_SECRET_DEPTH + ) { + return; + } + // eslint-disable-next-line + deDupeQueue[deDuplicationKey] = true; + // eslint-disable-next-line + deDupeReplicationQueue[deDuplicationKey] = true; + await queueService.queue( + QueueName.SecretSync, + QueueJobs.SecretSync, + { + ...dto, + _deDupeQueue: deDupeQueue, + _deDupeReplicationQueue: deDupeReplicationQueue, + _depth: depth + } as TSyncSecretsDTO, + { + removeOnFail: true, + removeOnComplete: true, + delay: 1000, + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + } + } + ); + }; + + queueService.start(QueueName.SecretSync, async (job) => { + const { + _deDupeQueue: deDupeQueue, + _deDupeReplicationQueue: deDupeReplicationQueue, + _depth: depth, + secretPath, + projectId, + environmentSlug: environment, + excludeReplication, + actorId, + actor + } = job.data; + + await queueService.queue( + QueueName.SecretWebhook, + QueueJobs.SecWebhook, + { environment, projectId, secretPath }, + { + jobId: `secret-webhook-${environment}-${projectId}-${secretPath}`, + removeOnFail: { count: 5 }, + removeOnComplete: true, + delay: 1000, + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + } + } + ); + await syncIntegrations({ secretPath, projectId, environment, deDupeQueue }); + if (!excludeReplication) { + await replicateSecrets({ + _deDupeReplicationQueue: deDupeReplicationQueue, + _depth: depth, + projectId, + secretPath, + actorId, + actor, + excludeReplication, + environmentSlug: environment + }); + } + }); + queueService.start(QueueName.IntegrationSync, async (job) => { - const { environment, projectId, secretPath, depth = 1 } = job.data; + const { environment, projectId, secretPath, depth = 1, deDupeQueue = {} } = job.data; + if (depth > MAX_SYNC_SECRET_DEPTH) return; const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) { @@ -340,7 +426,8 @@ export const secretQueueFactory = ({ const linkSourceDto = { projectId, importEnv: folder.environment.id, - importPath: secretPath + importPath: secretPath, + isReplication: false }; const imports = await secretImportDAL.find(linkSourceDto); @@ -348,22 +435,71 @@ export const secretQueueFactory = ({ // keep calling sync secret for all the imports made const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId); const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds); - const foldersGroupedById = groupBy(importedFolders, (i) => i.child || i.id); + const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string); + logger.info( + `getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` + ); await Promise.all( imports - .filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0].path)) - .map(({ folderId }) => { - const syncDto = { - depth: depth + 1, + .filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string)) + // filter out already synced ones + .filter( + ({ folderId }) => + !deDupeQueue[ + uniqueSecretQueueKey( + foldersGroupedById[folderId][0]?.environmentSlug as string, + foldersGroupedById[folderId][0]?.path as string + ) + ] + ) + .map(({ folderId }) => + syncSecrets({ projectId, - secretPath: foldersGroupedById[folderId][0].path, - environment: foldersGroupedById[folderId][0].environmentSlug - }; - logger.info( - `getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` - ); - return syncSecrets(syncDto); - }) + secretPath: foldersGroupedById[folderId][0]?.path as string, + environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, + _deDupeQueue: deDupeQueue, + _depth: depth + 1, + excludeReplication: true + }) + ) + ); + } + + const secretReferences = await secretDAL.findReferencedSecretReferences( + projectId, + folder.environment.slug, + secretPath + ); + if (secretReferences.length) { + const referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId); + const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds); + const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string); + logger.info( + `getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` + ); + await Promise.all( + secretReferences + .filter(({ folderId }) => Boolean(referencedFoldersGroupedById[folderId][0]?.path)) + // filter out already synced ones + .filter( + ({ folderId }) => + !deDupeQueue[ + uniqueSecretQueueKey( + referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, + referencedFoldersGroupedById[folderId][0]?.path as string + ) + ] + ) + .map(({ folderId }) => + syncSecrets({ + projectId, + secretPath: referencedFoldersGroupedById[folderId][0]?.path as string, + environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, + _deDupeQueue: deDupeQueue, + _depth: depth + 1, + excludeReplication: true + }) + ) ); } } else { @@ -408,20 +544,37 @@ export const secretQueueFactory = ({ }); } - await syncIntegrationSecrets({ - createManySecretsRawFn, - updateManySecretsRawFn, - integrationDAL, - integration, - integrationAuth, - secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets, - accessId: accessId as string, - accessToken, - appendices: { - prefix: metadata?.secretPrefix || "", - suffix: metadata?.secretSuffix || "" - } - }); + try { + await syncIntegrationSecrets({ + createManySecretsRawFn, + updateManySecretsRawFn, + integrationDAL, + integration, + integrationAuth, + secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets, + accessId: accessId as string, + accessToken, + appendices: { + prefix: metadata?.secretPrefix || "", + suffix: metadata?.secretSuffix || "" + } + }); + + await integrationDAL.updateById(integration.id, { + lastSyncJobId: job.id, + lastUsed: new Date(), + syncMessage: "", + isSynced: true + }); + } catch (err: unknown) { + logger.info("Secret integration sync error:", err); + await integrationDAL.updateById(integration.id, { + lastSyncJobId: job.id, + lastUsed: new Date(), + syncMessage: (err as Error)?.message, + isSynced: false + }); + } } logger.info("Secret integration sync ended: %s", job.id); @@ -474,10 +627,11 @@ export const secretQueueFactory = ({ return { // depth is internal only field thus no need to make it available outside - syncSecrets: (dto: TGetSecrets) => syncSecrets(dto), + syncSecrets, syncIntegrations, addSecretReminder, removeSecretReminder, - handleSecretReminder + handleSecretReminder, + replicateSecrets }; }; diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 5c7b9bef9..5688f7f15 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -2,12 +2,22 @@ /* eslint-disable no-await-in-loop */ import { ForbiddenError, subject } from "@casl/ability"; -import { SecretEncryptionAlgo, SecretKeyEncoding, SecretsSchema, SecretType } from "@app/db/schemas"; +import { + ProjectMembershipRole, + SecretEncryptionAlgo, + SecretKeyEncoding, + SecretsSchema, + SecretType +} from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { getConfig } from "@app/lib/config/env"; -import { buildSecretBlindIndexFromName, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; +import { + buildSecretBlindIndexFromName, + decryptSymmetric128BitHexKeyUTF8, + encryptSymmetric128BitHexKeyUTF8 +} from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; import { groupBy, pick } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; @@ -25,13 +35,17 @@ import { TSecretDALFactory } from "./secret-dal"; import { decryptSecretRaw, fnSecretBlindIndexCheck, + fnSecretBulkDelete, fnSecretBulkInsert, fnSecretBulkUpdate, + getAllNestedSecretReferences, + interpolateSecrets, recursivelyGetSecretPaths } from "./secret-fns"; import { TSecretQueueFactory } from "./secret-queue"; import { TAttachSecretTagsDTO, + TBackFillSecretReferencesDTO, TCreateBulkSecretDTO, TCreateManySecretRawDTO, TCreateSecretDTO, @@ -40,8 +54,6 @@ import { TDeleteManySecretRawDTO, TDeleteSecretDTO, TDeleteSecretRawDTO, - TFnSecretBlindIndexCheckV2, - TFnSecretBulkDelete, TGetASecretDTO, TGetASecretRawDTO, TGetSecretsDTO, @@ -90,6 +102,22 @@ export const secretServiceFactory = ({ secretImportDAL, secretVersionTagDAL }: TSecretServiceFactoryDep) => { + const getSecretReference = async (projectId: string) => { + // if bot key missing means e2e still exist + const botKey = await projectBotService.getBotKey(projectId).catch(() => null); + return (el: { ciphertext?: string; iv: string; tag: string }) => + botKey + ? getAllNestedSecretReferences( + decryptSymmetric128BitHexKeyUTF8({ + ciphertext: el.ciphertext || "", + iv: el.iv, + tag: el.tag, + key: botKey + }) + ) + : undefined; + }; + // utility function to get secret blind index data const interalGenSecBlindIndexByName = async (projectId: string, secretName: string) => { const appCfg = getConfig(); @@ -110,53 +138,6 @@ export const secretServiceFactory = ({ return secretBlindIndex; }; - const fnSecretBulkDelete = async ({ folderId, inputSecrets, tx, actorId }: TFnSecretBulkDelete) => { - const deletedSecrets = await secretDAL.deleteMany( - inputSecrets.map(({ type, secretBlindIndex }) => ({ - blindIndex: secretBlindIndex, - type - })), - folderId, - actorId, - tx - ); - - for (const s of deletedSecrets) { - if (s.secretReminderRepeatDays) { - // eslint-disable-next-line no-await-in-loop - await secretQueueService - .removeSecretReminder({ - secretId: s.id, - repeatDays: s.secretReminderRepeatDays - }) - .catch((err) => { - logger.error(err, `Failed to delete secret reminder for secret with ID ${s?.id}`); - }); - } - } - - return deletedSecrets; - }; - - // this is used when secret blind index already exist - // mainly for secret approval - const fnSecretBlindIndexCheckV2 = async ({ inputSecrets, folderId, userId }: TFnSecretBlindIndexCheckV2) => { - if (inputSecrets.some(({ type }) => type === SecretType.Personal) && !userId) { - throw new BadRequestError({ message: "Missing user id for personal secret" }); - } - const secrets = await secretDAL.findByBlindIndexes( - folderId, - inputSecrets.map(({ secretBlindIndex, type }) => ({ - blindIndex: secretBlindIndex, - type: type || SecretType.Shared - })), - userId - ); - const secsGroupedByBlindIndex = groupBy(secrets, (i) => i.secretBlindIndex as string); - - return { secsGroupedByBlindIndex, secrets }; - }; - const createSecret = async ({ path, actor, @@ -224,6 +205,7 @@ export const secretServiceFactory = ({ if ((inputSecret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); const { secretName, type, ...el } = inputSecret; + const references = await getSecretReference(projectId); const secret = await secretDAL.transaction((tx) => fnSecretBulkInsert({ folderId, @@ -236,7 +218,12 @@ export const secretServiceFactory = ({ userId: inputSecret.type === SecretType.Personal ? actorId : null, algorithm: SecretEncryptionAlgo.AES_256_GCM, keyEncoding: SecretKeyEncoding.UTF8, - tags: inputSecret.tags + tags: inputSecret.tags, + references: references({ + ciphertext: inputSecret.secretValueCiphertext, + iv: inputSecret.secretValueIV, + tag: inputSecret.secretValueTag + }) } ], secretDAL, @@ -248,9 +235,14 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); - // TODO(akhilmhdh-pg): licence check, posthog service and snapshot - return { ...secret[0], environment, workspace: projectId, tags }; + await secretQueueService.syncSecrets({ + secretPath: path, + actorId, + actor, + projectId, + environmentSlug: folder.environment.slug + }); + return { ...secret[0], environment, workspace: projectId, tags, secretPath: path }; }; const updateSecret = async ({ @@ -334,6 +326,7 @@ export const secretServiceFactory = ({ const { secretName, ...el } = inputSecret; + const references = await getSecretReference(projectId); const updatedSecret = await secretDAL.transaction(async (tx) => fnSecretBulkUpdate({ folderId, @@ -359,7 +352,12 @@ export const secretServiceFactory = ({ "secretReminderRepeatDays", "tags" ]), - secretBlindIndex: newSecretNameBlindIndex || keyName2BlindIndex[secretName] + secretBlindIndex: newSecretNameBlindIndex || keyName2BlindIndex[secretName], + references: references({ + ciphertext: inputSecret.secretValueCiphertext, + iv: inputSecret.secretValueIV, + tag: inputSecret.secretValueTag + }) } } ], @@ -372,9 +370,14 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); - // TODO(akhilmhdh-pg): licence check, posthog service and snapshot - return { ...updatedSecret[0], workspace: projectId, environment }; + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath: path, + projectId, + environmentSlug: folder.environment.slug + }); + return { ...updatedSecret[0], workspace: projectId, environment, secretPath: path }; }; const deleteSecret = async ({ @@ -429,6 +432,8 @@ export const secretServiceFactory = ({ projectId, folderId, actorId, + secretDAL, + secretQueueService, inputSecrets: [ { type: inputSecret.type as SecretType, @@ -440,10 +445,15 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); - + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath: path, + projectId, + environmentSlug: folder.environment.slug + }); // TODO(akhilmhdh-pg): licence check, posthog service and snapshot - return { ...deletedSecret[0], _id: deletedSecret[0].id, workspace: projectId, environment }; + return { ...deletedSecret[0], _id: deletedSecret[0].id, workspace: projectId, environment, secretPath: path }; }; const getSecrets = async ({ @@ -510,7 +520,8 @@ export const secretServiceFactory = ({ if (includeImports) { const secretImports = await secretImportDAL.findByFolderIds(paths.map((p) => p.folderId)); - const allowedImports = secretImports.filter(({ importEnv, importPath }) => + const allowedImports = secretImports.filter(({ importEnv, importPath, isReplication }) => + !isReplication && // if its service token allow full access over imported one actor === ActorType.SERVICE ? true @@ -615,7 +626,7 @@ export const secretServiceFactory = ({ // then search for imported secrets // here we consider the import order also thus starting from bottom if (!secret && includeImports) { - const secretImports = await secretImportDAL.find({ folderId }); + const secretImports = await secretImportDAL.find({ folderId, isReplication: false }); const allowedImports = secretImports.filter(({ importEnv, importPath }) => // if its service token allow full access over imported one actor === ActorType.SERVICE @@ -640,7 +651,8 @@ export const secretServiceFactory = ({ return { ...importedSecrets[i].secrets[j], workspace: projectId, - environment: importedSecrets[i].environment + environment: importedSecrets[i].environment, + secretPath: importedSecrets[i].secretPath }; } } @@ -648,7 +660,7 @@ export const secretServiceFactory = ({ } if (!secret) throw new BadRequestError({ message: "Secret not found" }); - return { ...secret, workspace: projectId, environment }; + return { ...secret, workspace: projectId, environment, secretPath: path }; }; const createManySecret = async ({ @@ -699,6 +711,7 @@ export const secretServiceFactory = ({ const tags = tagIds.length ? await secretTagDAL.findManyTagsById(projectId, tagIds) : []; if (tags.length !== tagIds.length) throw new BadRequestError({ message: "Tag not found" }); + const references = await getSecretReference(projectId); const newSecrets = await secretDAL.transaction(async (tx) => fnSecretBulkInsert({ inputSecrets: inputSecrets.map(({ secretName, ...el }) => ({ @@ -707,7 +720,12 @@ export const secretServiceFactory = ({ secretBlindIndex: keyName2BlindIndex[secretName], type: SecretType.Shared, algorithm: SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: SecretKeyEncoding.UTF8 + keyEncoding: SecretKeyEncoding.UTF8, + references: references({ + ciphertext: el.secretValueCiphertext, + iv: el.secretValueIV, + tag: el.secretValueTag + }) })), folderId, secretDAL, @@ -719,7 +737,13 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath: path, + projectId, + environmentSlug: folder.environment.slug + }); return newSecrets; }; @@ -782,6 +806,8 @@ export const secretServiceFactory = ({ const tagIds = inputSecrets.flatMap(({ tags = [] }) => tags); const tags = tagIds.length ? await secretTagDAL.findManyTagsById(projectId, tagIds) : []; if (tagIds.length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); + + const references = await getSecretReference(projectId); const secrets = await secretDAL.transaction(async (tx) => fnSecretBulkUpdate({ folderId, @@ -798,7 +824,15 @@ export const secretServiceFactory = ({ ? newKeyName2BlindIndex[newSecretName] : keyName2BlindIndex[secretName], algorithm: SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: SecretKeyEncoding.UTF8 + keyEncoding: SecretKeyEncoding.UTF8, + references: + el.secretValueIV && el.secretValueTag + ? references({ + ciphertext: el.secretValueCiphertext, + iv: el.secretValueIV, + tag: el.secretValueTag + }) + : undefined } })), secretDAL, @@ -809,7 +843,13 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath: path, + projectId, + environmentSlug: folder.environment.slug + }); return secrets; }; @@ -859,6 +899,8 @@ export const secretServiceFactory = ({ const secretsDeleted = await secretDAL.transaction(async (tx) => fnSecretBulkDelete({ + secretDAL, + secretQueueService, inputSecrets: inputSecrets.map(({ type, secretName }) => ({ secretBlindIndex: keyName2BlindIndex[secretName], type @@ -871,7 +913,13 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath: path, + projectId, + environmentSlug: folder.environment.slug + }); return secretsDeleted; }; @@ -885,6 +933,7 @@ export const secretServiceFactory = ({ actorAuthMethod, environment, includeImports, + expandSecretReferences, recursive }: TGetSecretsRawDTO) => { const botKey = await projectBotService.getBotKey(projectId); @@ -902,17 +951,72 @@ export const secretServiceFactory = ({ recursive }); - return { - secrets: secrets.map((el) => decryptSecretRaw(el, botKey)), - imports: (imports || [])?.map(({ secrets: importedSecrets, ...el }) => ({ - ...el, - secrets: importedSecrets.map((sec) => - decryptSecretRaw( - { ...sec, environment: el.environment, workspace: projectId, secretPath: el.secretPath }, - botKey - ) + const decryptedSecrets = secrets.map((el) => decryptSecretRaw(el, botKey)); + const decryptedImports = (imports || [])?.map(({ secrets: importedSecrets, ...el }) => ({ + ...el, + secrets: importedSecrets.map((sec) => + decryptSecretRaw( + { ...sec, environment: el.environment, workspace: projectId, secretPath: el.secretPath }, + botKey ) - })) + ) + })); + + if (expandSecretReferences) { + const expandSecrets = interpolateSecrets({ + folderDAL, + projectId, + secretDAL, + secretEncKey: botKey + }); + + const batchSecretsExpand = async ( + secretBatch: { secretKey: string; secretValue: string; secretComment?: string; secretPath: string }[] + ) => { + // Group secrets by secretPath + const secretsByPath: Record = {}; + + secretBatch.forEach((secret) => { + if (!secretsByPath[secret.secretPath]) { + secretsByPath[secret.secretPath] = []; + } + secretsByPath[secret.secretPath].push(secret); + }); + + // Expand secrets for each group + for (const secPath in secretsByPath) { + if (!Object.hasOwn(secretsByPath, path)) { + // eslint-disable-next-line no-continue + continue; + } + + const secretRecord: Record = {}; + secretsByPath[secPath].forEach((decryptedSecret) => { + secretRecord[decryptedSecret.secretKey] = { + value: decryptedSecret.secretValue, + comment: decryptedSecret.secretComment + }; + }); + + await expandSecrets(secretRecord); + + secretsByPath[secPath].forEach((decryptedSecret) => { + // eslint-disable-next-line no-param-reassign + decryptedSecret.secretValue = secretRecord[decryptedSecret.secretKey].value; + }); + } + }; + + // expand secrets + await batchSecretsExpand(decryptedSecrets); + + // expand imports by batch + await Promise.all(decryptedImports.map((decryptedImport) => batchSecretsExpand(decryptedImport.secrets))); + } + + return { + secrets: decryptedSecrets, + imports: decryptedImports }; }; @@ -921,7 +1025,8 @@ export const secretServiceFactory = ({ path, actor, environment, - projectId, + projectId: workspaceId, + projectSlug, actorId, actorOrgId, actorAuthMethod, @@ -929,6 +1034,8 @@ export const secretServiceFactory = ({ includeImports, version }: TGetASecretRawDTO) => { + const projectId = workspaceId || (await projectDAL.findProjectBySlug(projectSlug as string, actorOrgId)).id; + const botKey = await projectBotService.getBotKey(projectId); if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); @@ -945,6 +1052,7 @@ export const secretServiceFactory = ({ includeImports, version }); + return decryptSecretRaw(secret, botKey); }; @@ -991,9 +1099,6 @@ export const secretServiceFactory = ({ skipMultilineEncoding }); - await snapshotService.performSnapshot(secret.folderId); - await secretQueueService.syncSecrets({ secretPath, projectId, environment }); - return decryptSecretRaw(secret, botKey); }; @@ -1032,8 +1137,6 @@ export const secretServiceFactory = ({ }); await snapshotService.performSnapshot(secret.folderId); - await secretQueueService.syncSecrets({ secretPath, projectId, environment }); - return decryptSecretRaw(secret, botKey); }; @@ -1063,9 +1166,6 @@ export const secretServiceFactory = ({ actorAuthMethod }); - await snapshotService.performSnapshot(secret.folderId); - await secretQueueService.syncSecrets({ secretPath, projectId, environment }); - return decryptSecretRaw(secret, botKey); }; @@ -1114,10 +1214,9 @@ export const secretServiceFactory = ({ }) }); - await snapshotService.performSnapshot(secrets[0].folderId); - await secretQueueService.syncSecrets({ secretPath, projectId, environment }); - - return secrets.map((secret) => decryptSecretRaw({ ...secret, workspace: projectId, environment }, botKey)); + return secrets.map((secret) => + decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) + ); }; const updateManySecretsRaw = async ({ @@ -1166,10 +1265,9 @@ export const secretServiceFactory = ({ }) }); - await snapshotService.performSnapshot(secrets[0].folderId); - await secretQueueService.syncSecrets({ secretPath, projectId, environment }); - - return secrets.map((secret) => decryptSecretRaw({ ...secret, workspace: projectId, environment }, botKey)); + return secrets.map((secret) => + decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) + ); }; const deleteManySecretsRaw = async ({ @@ -1200,10 +1298,9 @@ export const secretServiceFactory = ({ secrets: inputSecrets.map(({ secretKey }) => ({ secretName: secretKey, type: SecretType.Shared })) }); - await snapshotService.performSnapshot(secrets[0].folderId); - await secretQueueService.syncSecrets({ secretPath, projectId, environment }); - - return secrets.map((secret) => decryptSecretRaw({ ...secret, workspace: projectId, environment }, botKey)); + return secrets.map((secret) => + decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) + ); }; const getSecretVersions = async ({ @@ -1324,7 +1421,12 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folder.id); - await secretQueueService.syncSecrets({ secretPath, projectId: project.id, environment }); + await secretQueueService.syncSecrets({ + secretPath, + projectId: project.id, + environmentSlug: environment, + excludeReplication: true + }); return { ...updatedSecret[0], @@ -1426,7 +1528,12 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folder.id); - await secretQueueService.syncSecrets({ secretPath, projectId: project.id, environment }); + await secretQueueService.syncSecrets({ + secretPath, + projectId: project.id, + environmentSlug: environment, + excludeReplication: true + }); return { ...updatedSecret[0], @@ -1434,6 +1541,52 @@ export const secretServiceFactory = ({ }; }; + // this is a backfilling API for secret references + // what it does is it will go through all the secret values and parse all references + // populate the secret reference to do sync integrations + const backfillSecretReferences = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod + }: TBackFillSecretReferencesDTO) => { + const { hasRole } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + if (!hasRole(ProjectMembershipRole.Admin)) + throw new BadRequestError({ message: "Only admins are allowed to take this action" }); + + const botKey = await projectBotService.getBotKey(projectId); + if (!botKey) + throw new BadRequestError({ message: "Please upgrade your project first", name: "bot_not_found_error" }); + + await secretDAL.transaction(async (tx) => { + const secrets = await secretDAL.findAllProjectSecretValues(projectId, tx); + await secretDAL.upsertSecretReferences( + secrets.map(({ id, secretValueCiphertext, secretValueIV, secretValueTag }) => ({ + secretId: id, + references: getAllNestedSecretReferences( + decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secretValueCiphertext, + iv: secretValueIV, + tag: secretValueTag, + key: botKey + }) + ) + })), + tx + ); + }); + + return { message: "Successfully backfilled secret references" }; + }; + return { attachTags, detachTags, @@ -1454,11 +1607,6 @@ export const secretServiceFactory = ({ updateManySecretsRaw, deleteManySecretsRaw, getSecretVersions, - // external services function - fnSecretBulkDelete, - fnSecretBulkUpdate, - fnSecretBlindIndexCheck, - fnSecretBulkInsert, - fnSecretBlindIndexCheckV2 + backfillSecretReferences }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index c2a0d5cf6..18a0077fe 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -11,6 +11,8 @@ import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/se import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { ActorType } from "../auth/auth-type"; + type TPartialSecret = Pick; type TPartialInputSecret = Pick; @@ -138,6 +140,7 @@ export type TDeleteBulkSecretDTO = { } & TProjectPermission; export type TGetSecretsRawDTO = { + expandSecretReferences?: boolean; path: string; environment: string; includeImports?: boolean; @@ -151,7 +154,9 @@ export type TGetASecretRawDTO = { type: "shared" | "personal"; includeImports?: boolean; version?: number; -} & TProjectPermission; + projectSlug?: string; + projectId?: string; +} & Omit; export type TCreateSecretRawDTO = TProjectPermission & { secretPath: string; @@ -220,11 +225,13 @@ export type TGetSecretVersionsDTO = Omit & { secretId: string; }; +export type TSecretReference = { environment: string; secretPath: string }; + export type TFnSecretBulkInsert = { folderId: string; tx?: Knex; - inputSecrets: Array & { tags?: string[] }>; - secretDAL: Pick; + inputSecrets: Array & { tags?: string[]; references?: TSecretReference[] }>; + secretDAL: Pick; secretVersionDAL: Pick; secretTagDAL: Pick; secretVersionTagDAL: Pick; @@ -233,8 +240,11 @@ export type TFnSecretBulkInsert = { export type TFnSecretBulkUpdate = { folderId: string; projectId: string; - inputSecrets: { filter: Partial; data: TSecretsUpdate & { tags?: string[] } }[]; - secretDAL: Pick; + inputSecrets: { + filter: Partial; + data: TSecretsUpdate & { tags?: string[]; references?: TSecretReference[] }; + }[]; + secretDAL: Pick; secretVersionDAL: Pick; secretTagDAL: Pick; secretVersionTagDAL: Pick; @@ -256,6 +266,10 @@ export type TFnSecretBulkDelete = { inputSecrets: Array<{ type: SecretType; secretBlindIndex: string }>; actorId: string; tx?: Knex; + secretDAL: Pick; + secretQueueService: { + removeSecretReminder: (data: TRemoveSecretReminderDTO) => Promise; + }; }; export type TFnSecretBlindIndexCheck = { @@ -269,6 +283,7 @@ export type TFnSecretBlindIndexCheck = { // when blind index is already present export type TFnSecretBlindIndexCheckV2 = { + secretDAL: Pick; folderId: string; userId?: string; inputSecrets: Array<{ secretBlindIndex: string; type?: SecretType }>; @@ -291,6 +306,8 @@ export type TRemoveSecretReminderDTO = { repeatDays: number; }; +export type TBackFillSecretReferencesDTO = TProjectPermission; + // --- export type TCreateManySecretsRawFnFactory = { @@ -353,3 +370,27 @@ export type TUpdateManySecretsRawFn = { }[]; userId?: string; }; + +export enum SecretOperations { + Create = "create", + Update = "update", + Delete = "delete" +} + +export type TSyncSecretsDTO = { + _deDupeQueue?: Record; + _deDupeReplicationQueue?: Record; + _depth?: number; + secretPath: string; + projectId: string; + environmentSlug: string; + // cases for just doing sync integration and webhook + excludeReplication?: T; +} & (T extends true + ? object + : { + actor: ActorType; + actorId: string; + // used for import creation to trigger replication + pickOnlyImportIds?: string[]; + }); diff --git a/backend/src/services/secret/secret-version-dal.ts b/backend/src/services/secret/secret-version-dal.ts index 758352ed2..203406e30 100644 --- a/backend/src/services/secret/secret-version-dal.ts +++ b/backend/src/services/secret/secret-version-dal.ts @@ -89,6 +89,7 @@ export const secretVersionDALFactory = (db: TDbClient) => { const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => { try { + if (!secretIds.length) return {}; const docs: Array = await (tx || db)(TableName.SecretVersion) .where("folderId", folderId) .whereIn(`${TableName.SecretVersion}.secretId`, secretIds) diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 7ebeaa227..7d6b98b31 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -17,9 +17,12 @@ export type TSmtpSendMail = { export type TSmtpService = ReturnType; export enum SmtpTemplates { + SignupEmailVerification = "signupEmailVerification.handlebars", EmailVerification = "emailVerification.handlebars", SecretReminder = "secretReminder.handlebars", EmailMfa = "emailMfa.handlebars", + UnlockAccount = "unlockAccount.handlebars", + AccessApprovalRequest = "accessApprovalRequest.handlebars", HistoricalSecretList = "historicalSecretLeakIncident.handlebars", NewDeviceJoin = "newDevice.handlebars", OrgInvite = "organizationInvitation.handlebars", diff --git a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars new file mode 100644 index 000000000..82c66ce5f --- /dev/null +++ b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars @@ -0,0 +1,50 @@ + + + + + + Access Approval Request + + + +

Infisical

+

New access approval request pending your review

+

You have a new access approval request pending review in project "{{projectName}}".

+ +

+ {{requesterFullName}} + ({{requesterEmail}}) has requested + {{#if isTemporary}} + temporary + {{else}} + permanent + {{/if}} + access to + {{secretPath}} + in the + {{environment}} + environment. + + {{#if isTemporary}} +
+ This access will expire + {{expiresIn}} + after it has been approved. + {{/if}} +

+

+ The following permissions are requested: +

    + {{#each permissions}} +
  • {{this}}
  • + {{/each}} +
+

+ +

+ View the request and approve or deny it + here. +

+ + + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/emailVerification.handlebars b/backend/src/services/smtp/templates/emailVerification.handlebars index fc738d202..ad9694d5c 100644 --- a/backend/src/services/smtp/templates/emailVerification.handlebars +++ b/backend/src/services/smtp/templates/emailVerification.handlebars @@ -1,17 +1,15 @@ - - - - + + + Code - + - +

Confirm your email address

-

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

+

Your confirmation code is below — enter it in the browser window where you've started confirming your email.

{{code}}

-

Questions about setting up Infisical? Email us at support@infisical.com

- + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/signupEmailVerification.handlebars b/backend/src/services/smtp/templates/signupEmailVerification.handlebars new file mode 100644 index 000000000..fc738d202 --- /dev/null +++ b/backend/src/services/smtp/templates/signupEmailVerification.handlebars @@ -0,0 +1,17 @@ + + + + + + + Code + + + +

Confirm your email address

+

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

+

{{code}}

+

Questions about setting up Infisical? Email us at support@infisical.com

+ + + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/unlockAccount.handlebars b/backend/src/services/smtp/templates/unlockAccount.handlebars new file mode 100644 index 000000000..36664be87 --- /dev/null +++ b/backend/src/services/smtp/templates/unlockAccount.handlebars @@ -0,0 +1,16 @@ + + + + + + Your Infisical account has been locked + + + +

Unlock your Infisical account

+

Your account has been temporarily locked due to multiple failed login attempts. + To unlock your account, follow the link here +

If these attempts were not made by you, reset your password immediately.

+ + + \ No newline at end of file diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 07fc2e991..bec8f3f37 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -102,7 +102,8 @@ export const superAdminServiceFactory = ({ superAdmin: true, isGhost: false, isAccepted: true, - authMethods: [AuthMethod.EMAIL] + authMethods: [AuthMethod.EMAIL], + isEmailVerified: true }, tx ); diff --git a/backend/src/services/user-alias/user-alias-types.ts b/backend/src/services/user-alias/user-alias-types.ts index e69de29bb..09204644f 100644 --- a/backend/src/services/user-alias/user-alias-types.ts +++ b/backend/src/services/user-alias/user-alias-types.ts @@ -0,0 +1,4 @@ +export enum UserAliasType { + LDAP = "ldap", + SAML = "saml" +} diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 530ca3ad1..f2da0df0e 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -74,6 +74,17 @@ export const userDALFactory = (db: TDbClient) => { } }; + const findUsersByProjectMembershipIds = async (projectMembershipIds: string[]) => { + try { + return await db(TableName.ProjectMembership) + .whereIn(`${TableName.ProjectMembership}.id`, projectMembershipIds) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .select("*"); + } catch (error) { + throw new DatabaseError({ error, name: "Find users by project membership ids" }); + } + }; + const createUserEncryption = async (data: TUserEncryptionKeysInsert, tx?: Knex) => { try { const [userEnc] = await (tx || db)(TableName.UserEncryptionKey).insert(data).returning("*"); @@ -140,6 +151,7 @@ export const userDALFactory = (db: TDbClient) => { findUserEncKeyByUserId, updateUserEncryptionByUserId, findUserByProjectMembershipId, + findUsersByProjectMembershipIds, upsertUserEncryptionKey, createUserEncryption, findOneUserAction, diff --git a/backend/src/services/user/user-fns.ts b/backend/src/services/user/user-fns.ts index 23789df1b..639320e24 100644 --- a/backend/src/services/user/user-fns.ts +++ b/backend/src/services/user/user-fns.ts @@ -4,7 +4,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TUserDALFactory } from "@app/services/user/user-dal"; export const normalizeUsername = async (username: string, userDAL: Pick) => { - let attempt = slugify(username); + let attempt = slugify(`${username}-${alphaNumericNanoId(4)}`); let user = await userDAL.findOne({ username: attempt }); if (!user) return attempt; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index c85e40eb3..a82259db6 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,15 +1,151 @@ import { BadRequestError } from "@app/lib/errors"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +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 { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { AuthMethod } from "../auth/auth-type"; import { TUserDALFactory } from "./user-dal"; type TUserServiceFactoryDep = { - userDAL: TUserDALFactory; + userDAL: Pick< + TUserDALFactory, + | "find" + | "findOne" + | "findById" + | "transaction" + | "updateById" + | "update" + | "deleteById" + | "findOneUserAction" + | "createUserAction" + | "findUserEncKeyByUserId" + >; + userAliasDAL: Pick; + orgMembershipDAL: Pick; + tokenService: Pick; + smtpService: Pick; }; export type TUserServiceFactory = ReturnType; -export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { +export const userServiceFactory = ({ + userDAL, + userAliasDAL, + orgMembershipDAL, + tokenService, + smtpService +}: TUserServiceFactoryDep) => { + const sendEmailVerificationCode = async (username: string) => { + const user = await userDAL.findOne({ username }); + if (!user) throw new BadRequestError({ name: "Failed to find user" }); + if (!user.email) + throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); + if (user.isEmailVerified) + throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" }); + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.EmailVerification, + subjectLine: "Infisical confirmation code", + recipients: [user.email], + substitutions: { + code: token + } + }); + }; + + const verifyEmailVerificationCode = async (username: string, code: string) => { + const user = await userDAL.findOne({ username }); + if (!user) throw new BadRequestError({ name: "Failed to find user" }); + 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({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id, + code + }); + + const { email } = user; + + await userDAL.transaction(async (tx) => { + await userDAL.updateById( + user.id, + { + isEmailVerified: true + }, + tx + ); + + // check if there are users with the same email. + const users = await userDAL.find( + { + email, + isEmailVerified: true + }, + { tx } + ); + + if (users.length > 1) { + // merge users + const mergeUser = users.find((u) => u.id !== user.id); + if (!mergeUser) throw new BadRequestError({ name: "Failed to find merge user" }); + + const mergeUserOrgMembershipSet = new Set( + (await orgMembershipDAL.find({ userId: mergeUser.id }, { tx })).map((m) => m.orgId) + ); + const myOrgMemberships = (await orgMembershipDAL.find({ userId: user.id }, { tx })).filter( + (m) => !mergeUserOrgMembershipSet.has(m.orgId) + ); + + const userAliases = await userAliasDAL.find( + { + userId: user.id + }, + { tx } + ); + await userDAL.deleteById(user.id, tx); + + if (myOrgMemberships.length) { + await orgMembershipDAL.insertMany( + myOrgMemberships.map((orgMembership) => ({ + ...orgMembership, + userId: mergeUser.id + })), + tx + ); + } + + if (userAliases.length) { + await userAliasDAL.insertMany( + userAliases.map((userAlias) => ({ + ...userAlias, + userId: mergeUser.id + })), + tx + ); + } + } else { + // update current user's username to [email] + await userDAL.updateById( + user.id, + { + username: email + }, + tx + ); + } + }); + }; + const toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => { const user = await userDAL.findById(userId); @@ -71,13 +207,29 @@ export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { return userAction; }; + const unlockUser = async (userId: string, token: string) => { + await tokenService.validateTokenForUser({ + userId, + code: token, + type: TokenType.TOKEN_USER_UNLOCK + }); + + await userDAL.update( + { id: userId }, + { consecutiveFailedMfaAttempts: 0, isLocked: false, temporaryLockDateEnd: null } + ); + }; + return { + sendEmailVerificationCode, + verifyEmailVerificationCode, toggleUserMfa, updateUserName, updateAuthMethods, deleteMe, getMe, createUserAction, - getUserAction + getUserAction, + unlockUser }; }; diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index 03bf9af4d..f2b05d1f0 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -15,7 +15,6 @@ import ( "path" "runtime" "slices" - "strings" "sync" "syscall" "text/template" @@ -257,19 +256,6 @@ func WriteBytesToFile(data *bytes.Buffer, outputPath string) error { return err } -func appendAPIEndpoint(address string) string { - // Ensure the address does not already end with "/api" - if strings.HasSuffix(address, "/api") { - return address - } - - // Check if the address ends with a slash and append accordingly - if address[len(address)-1] == '/' { - return address + "api" - } - return address + "/api" -} - func ParseAgentConfig(configFile []byte) (*Config, error) { var rawConfig struct { Infisical InfisicalConfig `yaml:"infisical"` @@ -290,7 +276,7 @@ func ParseAgentConfig(configFile []byte) (*Config, error) { rawConfig.Infisical.Address = DEFAULT_INFISICAL_CLOUD_URL } - config.INFISICAL_URL = appendAPIEndpoint(rawConfig.Infisical.Address) + config.INFISICAL_URL = util.AppendAPIEndpoint(rawConfig.Infisical.Address) log.Info().Msgf("Infisical instance address set to %s", rawConfig.Infisical.Address) diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index bbb2c3a05..61e24b12f 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -101,7 +101,7 @@ var loginCmd = &cobra.Command{ //set domainQuery to false if !overrideDomain { domainQuery = false - config.INFISICAL_URL = config.INFISICAL_URL_MANUAL_OVERRIDE + config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL_MANUAL_OVERRIDE) } } diff --git a/cli/packages/cmd/root.go b/cli/packages/cmd/root.go index 06846260f..482c6f78a 100644 --- a/cli/packages/cmd/root.go +++ b/cli/packages/cmd/root.go @@ -43,6 +43,7 @@ func init() { rootCmd.PersistentFlags().Bool("silent", false, "Disable output of tip/info messages. Useful when running in scripts or CI/CD pipelines.") rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { silent, err := cmd.Flags().GetBool("silent") + config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL) if err != nil { util.HandleError(err) } diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index cd31d05e9..d5ad8b403 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -196,6 +196,11 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "Unable to get your local config details") } + secretType, err := cmd.Flags().GetString("type") + if err != nil || (secretType != util.SECRET_TYPE_SHARED && secretType != util.SECRET_TYPE_PERSONAL) { + util.HandleError(err, "Unable to parse secret type") + } + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() if err != nil { util.HandleError(err, "Unable to authenticate") @@ -205,6 +210,7 @@ var secretsSetCmd = &cobra.Command{ util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") } + httpClient := resty.New(). SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). SetHeader("Accept", "application/json") @@ -249,7 +255,16 @@ var secretsSetCmd = &cobra.Command{ secretsToModify := []api.Secret{} secretOperations := []SecretSetOperation{} - secretByKey := getSecretsByKeys(secrets) + sharedSecretMapByName := make(map[string]models.SingleEnvironmentVariable, len(secrets)) + personalSecretMapByName := make(map[string]models.SingleEnvironmentVariable, len(secrets)) + + for _, secret := range secrets { + if secret.Type == util.SECRET_TYPE_PERSONAL { + personalSecretMapByName[secret.Key] = secret + } else { + sharedSecretMapByName[secret.Key] = secret + } + } for _, arg := range args { splitKeyValueFromArg := strings.SplitN(arg, "=", 2) @@ -277,7 +292,16 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "unable to encrypt your secrets") } - if existingSecret, ok := secretByKey[key]; ok { + var existingSecret models.SingleEnvironmentVariable + var doesSecretExist bool + + if secretType == util.SECRET_TYPE_SHARED { + existingSecret, doesSecretExist = sharedSecretMapByName[key] + } else { + existingSecret, doesSecretExist = personalSecretMapByName[key] + } + + if doesSecretExist { // case: secret exists in project so it needs to be modified encryptedSecretDetails := api.Secret{ ID: existingSecret.ID, @@ -317,7 +341,7 @@ var secretsSetCmd = &cobra.Command{ SecretValueIV: base64.StdEncoding.EncodeToString(encryptedValue.Nonce), SecretValueTag: base64.StdEncoding.EncodeToString(encryptedValue.AuthTag), SecretValueHash: hashedValue, - Type: util.SECRET_TYPE_SHARED, + Type: secretType, PlainTextKey: key, } secretsToCreate = append(secretsToCreate, encryptedSecretDetails) @@ -807,6 +831,7 @@ func init() { secretsCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") secretsCmd.AddCommand(secretsSetCmd) secretsSetCmd.Flags().String("path", "/", "set secrets within a folder path") + secretsSetCmd.Flags().String("type", util.SECRET_TYPE_SHARED, "the type of secret to create: personal or shared") // Only supports logged in users (JWT auth) secretsSetCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { diff --git a/cli/packages/cmd/user.go b/cli/packages/cmd/user.go index 96b03a0bb..844213e18 100644 --- a/cli/packages/cmd/user.go +++ b/cli/packages/cmd/user.go @@ -237,7 +237,7 @@ func NewDomainPrompt() (string, error) { return "", err } - return domain, nil + return util.AppendAPIEndpoint(domain), nil } func LoggedInUsersPrompt(profiles []string) (string, error) { diff --git a/cli/packages/util/credentials.go b/cli/packages/util/credentials.go index af63aa917..4856de35a 100644 --- a/cli/packages/util/credentials.go +++ b/cli/packages/util/credentials.go @@ -88,7 +88,7 @@ func GetCurrentLoggedInUserDetails() (LoggedInUserDetails, error) { //configFile.LoggedInUserDomain //if not empty set as infisical url if configFile.LoggedInUserDomain != "" { - config.INFISICAL_URL = configFile.LoggedInUserDomain + config.INFISICAL_URL = AppendAPIEndpoint(configFile.LoggedInUserDomain) } isAuthenticated := api.CallIsAuthenticated(httpClient) diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 9a4d960db..9e5052530 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -233,3 +233,16 @@ func getCurrentBranch() (string, error) { } return path.Base(strings.TrimSpace(out.String())), nil } + +func AppendAPIEndpoint(address string) string { + // Ensure the address does not already end with "/api" + if strings.HasSuffix(address, "/api") { + return address + } + + // Check if the address ends with a slash and append accordingly + if address[len(address)-1] == '/' { + return address + "api" + } + return address + "/api" +} diff --git a/company/documentation/getting-started/introduction.mdx b/company/documentation/getting-started/introduction.mdx new file mode 100644 index 000000000..0f414c62a --- /dev/null +++ b/company/documentation/getting-started/introduction.mdx @@ -0,0 +1,97 @@ +--- +title: "What is Infisical?" +sidebarTitle: "What is Infisical?" +description: "An Introduction to the Infisical secret management platform." +--- + +Infisical is an [open-source](https://github.com/infisical/infisical) secret management platform for developers. +It provides capabilities for storing, managing, and syncing application configuration and secrets like API keys, database +credentials, and certificates across infrastructure. In addition, Infisical prevents secrets leaks to git and enables secure +sharing of secrets among engineers. + +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 Infisical? + +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: +- 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. + +## How does Infisical work? + +To make secret management effortless and secure, Infisical follows a certain structure for enabling secret management workflows as defined below. + +**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**. + +As a result, the 3 main concepts that are important to understand are: +- **[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, 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. + + diff --git a/company/favicon.png b/company/favicon.png new file mode 100644 index 000000000..45c9b868e Binary files /dev/null and b/company/favicon.png differ diff --git a/company/handbook/onboarding.mdx b/company/handbook/onboarding.mdx new file mode 100644 index 000000000..e85be9f04 --- /dev/null +++ b/company/handbook/onboarding.mdx @@ -0,0 +1,28 @@ +--- +title: "Onboarding" +sidebarTitle: "Onboarding" +description: "This guide explains the onboarding process for new joiners at Infisical." +--- + +Welcome to Infisical! + +The first few days of every new joiner are going to be packed with learning lots of new information, meeting new teammates, and understanding Infisical on a deeper level. + +Plus, our team is remote-first and spread across the globe (from San Francisco to Philippines), so having a great onboarding experience is very important for the new joiner to feel part of the team and be excited about what we're doing as a company. + +## Onboarding buddy + +Every new joiner has an onboarding buddy who should ideally be in the the same timezone. The onboarding buddy should be able to help with any questions that pop up during the first few weeks. Of course, everyone is available to help, but it's good to have a dedicated person that you can go to with any questions. + +## Onboarding Checklist + +1. Join the weekly all-hands meeting. It typically happens on Monday's at 8:30am PT. +2. Ship something together on day one – even if tiny! It feels great to hit the ground running, with a development environment all ready to go. +3. Check out the [Areas of Responsibility (AoR) Table](https://docs.google.com/spreadsheets/d/1RnXlGFg83Sgu0dh7ycuydsSobmFfI3A0XkGw7vrVxEI/edit?usp=sharing). This is helpful to know who you can ask about particular areas of Infisical. Feel free to add yourself to the areas you'd be most interesting to dive into. +4. Read the [Infisical Strategy Doc](https://docs.google.com/document/d/1oy_NP1Q_Zt1oqxLpyNkLIGmhAI3N28AmZq6dDIOONSQ/edit?usp=sharing). +5. Update your LinkedIn profile with one of [Infisical's official banners](https://drive.google.com/drive/u/0/folders/1oSNWjbpRl9oNYwxM_98IqzKs9fAskrb2) (if you want to). You can also coordinate your social posts in the #marketing Slack channel, so that we can boost it from Infisical's official social media accounts. +6. Over the first few weeks, feel free to schedule 1:1s with folks on the team to get to know them a bit better. +7. Change your Slack username in the users channel to `[NAME] (Infisical)`. +8. Go through the [technical overview](https://infisical.com/docs/internals/overview) of Infisical. + + diff --git a/company/handbook/overview.mdx b/company/handbook/overview.mdx new file mode 100644 index 000000000..c7067612d --- /dev/null +++ b/company/handbook/overview.mdx @@ -0,0 +1,11 @@ +--- +title: "Infisical Company Handbook" +sidebarTitle: "Welcome" +description: "This handbook explains how we work at Infisical." +--- + +Welcome! This handbook explains how we work and what we stand for at Infisical. + +Given that Infisical's core is open source, we decided to make this handbook also availably publicly to everyone. + +You can treat it as a living document as more pages and information will be added over time. diff --git a/company/handbook/spending-money.mdx b/company/handbook/spending-money.mdx new file mode 100644 index 000000000..667ed5ef2 --- /dev/null +++ b/company/handbook/spending-money.mdx @@ -0,0 +1,27 @@ +--- +title: "Spenging Money" +sidebarTitle: "Spending Money" +description: "The guide to spending money at Infisical." +--- + +Fairly frequently, you might run into situations when you need to spend company money. + +**Please spend money in a way that you think is in the best interest of the company.** + +## Trivial expenses + +We don't want you to be slowed down because you're waiting for an approval to purchase some SaaS. For trivial expenses – **Just do it**. + +This means expenses that are: +1. Non-recurring AND less than $75/month in total. +2. Recurring AND less than $20/month. + +## Saving receipts + +Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay. + +You should default to using your company card in all cases - it has no transaction fees. If using your personal card is unavoidable, please reach out to Maidul to get it reimbursed manually. + +## Brex + +We use Brex as our primary credit card provider. Don't have a company card yet? Reach out to Maidul. \ No newline at end of file diff --git a/company/handbook/time-off.mdx b/company/handbook/time-off.mdx new file mode 100644 index 000000000..a80721440 --- /dev/null +++ b/company/handbook/time-off.mdx @@ -0,0 +1,13 @@ +--- +title: "Time Off" +sidebarTitle: "Time Off" +description: "The guide to taking time off at Infisical." +--- + +We offer eveyone at Infisical unlimited time off. We care about your results, not how long you work. + +To request time off, just submit a request in Rippling and let Maidul know at least a week in advance. + +## National holidays + +Since Infisical's team is globally distributed, it is hard for us to keep track of all the various national holidays across many different countries. Whether you'd like to celebrate Christmas or National Brisket Day (which, by the way, is on May 28th), you are welcome to take PTO on those days – just let Maidul know at least a week ahead so that we can adjust our planning. \ No newline at end of file diff --git a/company/logo/dark.svg b/company/logo/dark.svg new file mode 100644 index 000000000..f88594746 --- /dev/null +++ b/company/logo/dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/company/logo/light.svg b/company/logo/light.svg new file mode 100644 index 000000000..16fc09e5e --- /dev/null +++ b/company/logo/light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/company/mint.json b/company/mint.json new file mode 100644 index 000000000..0ae63107b --- /dev/null +++ b/company/mint.json @@ -0,0 +1,68 @@ +{ + "name": "Infisical", + "logo": { + "dark": "/logo/dark.svg", + "light": "/logo/light.svg", + "href": "https://infisical.com" + }, + "favicon": "/favicon.png", + "colors": { + "primary": "#26272b", + "light": "#97b31d", + "dark": "#A1B659", + "ultraLight": "#E7F256", + "ultraDark": "#8D9F4C", + "background": { + "light": "#ffffff", + "dark": "#0D1117" + }, + "anchors": { + "from": "#000000", + "to": "#707174" + } + }, + "modeToggle": { + "default": "light", + "isHidden": true + }, + "feedback": { + "suggestEdit": true, + "raiseIssue": true, + "thumbsRating": true + }, + "api": { + "baseUrl": ["https://app.infisical.com", "http://localhost:8080"] + }, + "topbarLinks": [ + { + "name": "Log In", + "url": "https://app.infisical.com/login" + } + ], + "topbarCtaButton": { + "name": "Start for Free", + "url": "https://app.infisical.com/signup" + }, + "primaryTab": { + "name": "About" + }, + "navigation": [ + { + "group": "Handbook", + "pages": [ + "handbook/overview" + ] + }, + { + "group": "How we work", + "pages": [ + "handbook/onboarding", + "handbook/spending-money", + "handbook/time-off" + ] + } + ], + "integrations": { + "intercom": "hsg644ru" + } +} diff --git a/company/style.css b/company/style.css new file mode 100644 index 000000000..ea8c60dc9 --- /dev/null +++ b/company/style.css @@ -0,0 +1,156 @@ +#navbar .max-w-8xl { + max-width: 100%; + border-bottom: 1px solid #ebebeb; + background-color: #F4F3EF; +} + +.max-w-8xl { + /* background-color: #f5f5f5; */ +} + +#sidebar { + left: 0; + padding-left: 48px; + padding-right: 30px; + border-right: 1px; + border-color: #cdd64b; + background-color: #F4F3EF; + border-right: 1px solid #ebebeb; +} + +#sidebar .relative .sticky { + opacity: 0; +} + +#sidebar li > div.mt-2 { + border-radius: 0; + padding: 5px; +} + +#sidebar li > a.mt-2 { + border-radius: 0; + padding: 5px; +} + +#sidebar li > a.leading-6 { + border-radius: 0; + padding: 0px; +} + +#sidebar li > a.text-primary { + border-radius: 0; + background-color: #FBFFCC; + border-left: 4px solid #EFFF33; + padding: 5px; +} + +/* #sidebar ul > div.mt-12 { + padding-top: 30px; + position: relative; +} + +#sidebar ul > div.mt-12 h5 { + position: absolute; + left: -12px; + top: -0px; +} */ + +#header { + border-left: 4px solid #EFFF33; + padding-left: 16px; + padding-right: 16px; + background-color: #FDFFE5; + padding-bottom: 10px; + padding-top: 10px; +} + +#content-area .mt-8 .block{ + border-radius: 0; + border-width: 1px; + border-color: #ebebeb; +} + +#content-area:hover .mt-8 .block:hover{ + border-radius: 0; + border-width: 1px; + background-color: #FDFFE5; + border-color: #EFFF33; +} + +#content-area .mt-8 .rounded-xl{ + border-radius: 0; +} + +#content-area .mt-8 .rounded-lg{ + border-radius: 0; +} + +#content-area .mt-6 .rounded-xl{ + border-radius: 0; +} + +#content-area .mt-6 .rounded-lg{ + border-radius: 0; +} + +#content-area .mt-6 .rounded-md{ + border-radius: 0; +} + +#content-area .mt-8 .rounded-md{ + border-radius: 0; +} + +#content-area div.my-4{ + border-radius: 0; + border-width: 1px; +} + +#content-area div.flex-1 { + /* text-transform: uppercase; */ + opacity: 0.8; + font-weight: 400; +} + +#content-area button { + border-radius: 0; +} + +#content-area a { + border-radius: 0; +} + +#content-area .not-prose { + border-radius: 0; +} + +/* .eyebrow { + text-transform: uppercase; + font-weight: 400; + color: red; +} */ + +#content-container { + /* background-color: #f5f5f5; */ + margin-top: 2rem; +} + +#topbar-cta-button .group .absolute { + background-color: black; + border-radius: 0px; +} + +/* #topbar-cta-button .group .absolute:hover { + background-color: white; + border-radius: 0px; +} */ + +#topbar-cta-button .group .flex { + margin-top: 5px; + margin-bottom: 5px; + font-size: medium; +} + +.flex-1 .flex .items-center { + /* background-color: #f5f5f5; */ +} \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 764761098..422fe43f3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -91,6 +91,8 @@ services: - TELEMETRY_ENABLED=false volumes: - ./backend/src:/app/src + extra_hosts: + - "host.docker.internal:host-gateway" frontend: container_name: infisical-dev-frontend @@ -128,7 +130,7 @@ services: ports: - 1025:1025 # SMTP server - 8025:8025 # Web UI - + openldap: # note: more advanced configuration is available image: osixia/openldap:1.5.0 restart: always diff --git a/docker-swarm/haproxy.cfg b/docker-swarm/haproxy.cfg index 3717fedac..984943c25 100644 --- a/docker-swarm/haproxy.cfg +++ b/docker-swarm/haproxy.cfg @@ -24,16 +24,16 @@ resolvers hostdns timeout retry 1s hold valid 5s -frontend master +frontend postgres_master bind *:5433 - default_backend master_backend + default_backend postgres_master_backend -frontend replicas +frontend postgres_replicas bind *:5434 - default_backend replica_backend + default_backend postgres_replica_backend -backend master_backend +backend postgres_master_backend option httpchk GET /master http-check expect status 200 default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions @@ -41,7 +41,7 @@ backend master_backend server postgres-2 postgres-2:5432 check port 8008 resolvers hostdns server postgres-3 postgres-3:5432 check port 8008 resolvers hostdns -backend replica_backend +backend postgres_replica_backend option httpchk GET /replica http-check expect status 200 default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions @@ -50,11 +50,11 @@ backend replica_backend server postgres-3 postgres-3:5432 check port 8008 resolvers hostdns -frontend redis_frontend +frontend redis_master_frontend bind *:6379 - default_backend redis_backend + default_backend redis_master_backend -backend redis_backend +backend redis_master_backend option tcp-check tcp-check send AUTH\ 123456\r\n tcp-check expect string +OK diff --git a/docker-swarm/stack.yaml b/docker-swarm/stack.yaml index 11d5f5c62..4087c7836 100644 --- a/docker-swarm/stack.yaml +++ b/docker-swarm/stack.yaml @@ -5,8 +5,8 @@ services: image: haproxy:latest ports: - '7001:7000' - - '5002:5433' - - '5003:5434' + - '5002:5433' # Postgres master + - '5003:5434' # Postgres read - '6379:6379' - '8080:8080' networks: @@ -15,22 +15,18 @@ services: - source: haproxy-config target: /usr/local/etc/haproxy/haproxy.cfg deploy: - placement: - constraints: - - node.labels.name == node1 + mode: global infisical: container_name: infisical-backend - image: infisical/infisical:latest-postgres + image: infisical/infisical:v0.60.1-postgres env_file: .env - ports: - - 80:8080 - environment: - - NODE_ENV=production networks: - infisical secrets: - env_file + deploy: + replicas: 5 etcd1: image: ghcr.io/zalando/spilo-16:3.2-p2 @@ -103,6 +99,8 @@ services: hostname: postgres-1 environment: ETCD_HOSTS: etcd1:2379,etcd2:2379,etcd3:2379 + PGPASSWORD_SUPERUSER: "postgres" + PGUSER_SUPERUSER: "postgres" SCOPE: infisical volumes: - postgres_data1:/home/postgres/pgdata @@ -119,6 +117,8 @@ services: hostname: postgres-2 environment: ETCD_HOSTS: etcd1:2379,etcd2:2379,etcd3:2379 + PGPASSWORD_SUPERUSER: "postgres" + PGUSER_SUPERUSER: "postgres" SCOPE: infisical volumes: - postgres_data2:/home/postgres/pgdata @@ -135,6 +135,8 @@ services: hostname: postgres-3 environment: ETCD_HOSTS: etcd1:2379,etcd2:2379,etcd3:2379 + PGPASSWORD_SUPERUSER: "postgres" + PGUSER_SUPERUSER: "postgres" SCOPE: infisical volumes: - postgres_data3:/home/postgres/pgdata @@ -256,4 +258,4 @@ configs: secrets: env_file: - file: .env \ No newline at end of file + file: .env diff --git a/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx b/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx new file mode 100644 index 000000000..285b1d1c4 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Identity Membership" +openapi: "POST /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/workspaces/delete-identity-membership.mdx b/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/delete-identity-membership.mdx rename to docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx diff --git a/docs/api-reference/endpoints/project-identities/get-by-id.mdx b/docs/api-reference/endpoints/project-identities/get-by-id.mdx new file mode 100644 index 000000000..37f4192d7 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Identity by ID" +openapi: "GET /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/workspaces/list-identity-memberships.mdx b/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/list-identity-memberships.mdx rename to docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx diff --git a/docs/api-reference/endpoints/workspaces/update-identity-membership.mdx b/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/update-identity-membership.mdx rename to docs/api-reference/endpoints/project-identities/update-identity-membership.mdx diff --git a/docs/api-reference/endpoints/project-roles/create.mdx b/docs/api-reference/endpoints/project-roles/create.mdx new file mode 100644 index 000000000..2220b9309 --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/workspace/{projectSlug}/roles" +--- diff --git a/docs/api-reference/endpoints/project-roles/delete.mdx b/docs/api-reference/endpoints/project-roles/delete.mdx new file mode 100644 index 000000000..6362c2154 --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/workspace/{projectSlug}/roles/{roleId}" +--- diff --git a/docs/api-reference/endpoints/project-roles/get-by-slug.mdx b/docs/api-reference/endpoints/project-roles/get-by-slug.mdx new file mode 100644 index 000000000..18817bca9 --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/get-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By Slug" +openapi: "GET /api/v1/workspace/{projectSlug}/roles/slug/{slug}" +--- diff --git a/docs/api-reference/endpoints/project-roles/list.mdx b/docs/api-reference/endpoints/project-roles/list.mdx new file mode 100644 index 000000000..ca83d6e7d --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/workspace/{projectSlug}/roles" +--- diff --git a/docs/api-reference/endpoints/project-roles/update.mdx b/docs/api-reference/endpoints/project-roles/update.mdx new file mode 100644 index 000000000..5a3d9668e --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/workspace/{projectSlug}/roles/{roleId}" +--- diff --git a/docs/api-reference/endpoints/workspaces/delete-membership.mdx b/docs/api-reference/endpoints/project-users/delete-membership.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/delete-membership.mdx rename to docs/api-reference/endpoints/project-users/delete-membership.mdx diff --git a/docs/api-reference/endpoints/project-users/get-by-username.mdx b/docs/api-reference/endpoints/project-users/get-by-username.mdx new file mode 100644 index 000000000..ec69d4947 --- /dev/null +++ b/docs/api-reference/endpoints/project-users/get-by-username.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By Username" +openapi: "POST /api/v1/workspace/{workspaceId}/memberships/details" +--- diff --git a/docs/api-reference/endpoints/workspaces/invite-member-to-workspace.mdx b/docs/api-reference/endpoints/project-users/invite-member-to-workspace.mdx similarity index 95% rename from docs/api-reference/endpoints/workspaces/invite-member-to-workspace.mdx rename to docs/api-reference/endpoints/project-users/invite-member-to-workspace.mdx index a09c586ca..28acd3336 100644 --- a/docs/api-reference/endpoints/workspaces/invite-member-to-workspace.mdx +++ b/docs/api-reference/endpoints/project-users/invite-member-to-workspace.mdx @@ -1,4 +1,4 @@ --- title: "Invite Member" openapi: "POST /api/v2/workspace/{projectId}/memberships" ---- \ No newline at end of file +--- diff --git a/docs/api-reference/endpoints/workspaces/memberships.mdx b/docs/api-reference/endpoints/project-users/memberships.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/memberships.mdx rename to docs/api-reference/endpoints/project-users/memberships.mdx diff --git a/docs/api-reference/endpoints/workspaces/remove-member-from-workspace.mdx b/docs/api-reference/endpoints/project-users/remove-member-from-workspace.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/remove-member-from-workspace.mdx rename to docs/api-reference/endpoints/project-users/remove-member-from-workspace.mdx diff --git a/docs/api-reference/endpoints/workspaces/update-membership.mdx b/docs/api-reference/endpoints/project-users/update-membership.mdx similarity index 100% rename from docs/api-reference/endpoints/workspaces/update-membership.mdx rename to docs/api-reference/endpoints/project-users/update-membership.mdx diff --git a/docs/api-reference/endpoints/service-tokens/get.mdx b/docs/api-reference/endpoints/service-tokens/get.mdx index 593da8a92..921e62d90 100644 --- a/docs/api-reference/endpoints/service-tokens/get.mdx +++ b/docs/api-reference/endpoints/service-tokens/get.mdx @@ -4,7 +4,7 @@ openapi: "GET /api/v2/service-token" --- - This endpoint will be deprecated in the near future with the removal of service tokens in Q1/Q2 2024. + This endpoint is deprecated and will be removed in the future. - We recommend switching to using [identities](/documentation/platform/identities/overview) if your client supports it. + We recommend switching to using [Machine Identities](/documentation/platform/identities/machine-identities). diff --git a/docs/api-reference/endpoints/universal-auth/revoke-access-token.mdx b/docs/api-reference/endpoints/universal-auth/revoke-access-token.mdx new file mode 100644 index 000000000..082a76544 --- /dev/null +++ b/docs/api-reference/endpoints/universal-auth/revoke-access-token.mdx @@ -0,0 +1,4 @@ +--- +title: "Revoke Access Token" +openapi: "POST /api/v1/auth/token/revoke" +--- diff --git a/docs/cli/commands/export.mdx b/docs/cli/commands/export.mdx index 91ed215ef..16c226084 100644 --- a/docs/cli/commands/export.mdx +++ b/docs/cli/commands/export.mdx @@ -16,36 +16,48 @@ Export environment variables from the platform into a file format. Use this command to export environment variables from the platform into a raw file formats - ```bash - $ infisical export +```bash +$ infisical export - # Export variables to a .env file - infisical export > .env +# Export variables to a .env file +infisical export > .env - # Export variables to a .env file (with export keyword) - infisical export --format=dotenv-export > .env +# Export variables to a .env file (with export keyword) +infisical export --format=dotenv-export > .env - # Export variables to a CSV file - infisical export --format=csv > secrets.csv +# Export variables to a CSV file +infisical export --format=csv > secrets.csv - # Export variables to a JSON file - infisical export --format=json > secrets.json +# Export variables to a JSON file +infisical export --format=json > secrets.json - # Export variables to a YAML file - infisical export --format=yaml > secrets.yaml +# Export variables to a YAML file +infisical export --format=yaml > secrets.yaml - # Render secrets using a custom template file - infisical export --template= - ``` +# Render secrets using a custom template file +infisical export --template= +``` + +### Environment variables - ### Environment variables - Used to fetch secrets via a [service token](/documentation/platform/token) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. + Used to fetch secrets via a [machine identities](/documentation/platform/identities/machine-identities) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. ```bash - # Example - export INFISICAL_TOKEN=st.63e03c4a97cb4a747186c71e.ed5b46a34c078a8f94e8228f4ab0ff97.4f7f38034811995997d72badf44b42ec + # Example + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages. ``` + + + Alternatively, you may use service tokens. + + Please note, however, that service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + ```bash + # Example + export INFISICAL_TOKEN= + ``` + + @@ -54,16 +66,18 @@ Export environment variables from the platform into a file format. To use, simply export this variable in the terminal before running this command. ```bash - # Example + # Example export INFISICAL_DISABLE_UPDATE_CHECK=true ``` + - ### flags +### flags + The `--template` flag specifies the path to the template file used for rendering secrets. When using templates, you can omit the other format flags. - ```text my-template-file + ```text my-template-file {{$secrets := secret "" "" ""}} {{$length := len $secrets}} {{- "{"}} @@ -73,24 +87,26 @@ Export environment variables from the platform into a file format. {{- end }} {{- end }} {{ "}" -}} - ``` + ``` ```bash # Example infisical export --template="/path/to/template/file" ``` + - Used to set the environment that secrets are pulled from. + Used to set the environment that secrets are pulled from. ```bash - # Example - infisical export --env=prod + # Example + infisical export --env=prod ``` Note: this flag only accepts environment slug names not the fully qualified name. To view the slug name of an environment, visit the project settings page. default value: `dev` + @@ -98,28 +114,38 @@ Export environment variables from the platform into a file format. This flag allows you to override this behavior by explicitly defining the project to fetch your secrets from. ```bash - # Example - + # Example + infisical export --projectId=XXXXXXXXXXXXXX ``` + Parse shell parameter expansions in your secrets (e.g., `${DOMAIN}`) + Default value: `true` + + + + + By default imported secrets are available, you can disable it by setting this option to false. + Default value: `true` - Format of the output file. Accepted values: `dotenv`, `dotenv-export`, `csv`, `json` and `yaml` + Format of the output file. Accepted values: `dotenv`, `dotenv-export`, `csv`, `json` and `yaml` Default value: `dotenv` + Prioritizes personal secrets with the same name over shared secrets Default value: `true` + @@ -129,19 +155,21 @@ Export environment variables from the platform into a file format. # Example infisical export --path="/path/to/folder" --env=dev ``` + When working with tags, you can use this flag to filter and retrieve only secrets that are associated with a specific tag(s). ```bash - # Example + # Example infisical run --tags=tag1,tag2,tag3 -- npm run dev ``` Note: you must reference the tag by its slug name not its fully qualified name. Go to project settings to view all tag slugs. By default, all secrets are fetched + diff --git a/docs/cli/commands/run.mdx b/docs/cli/commands/run.mdx index 8078342a5..74aa84947 100644 --- a/docs/cli/commands/run.mdx +++ b/docs/cli/commands/run.mdx @@ -11,6 +11,7 @@ description: "The command that injects your secrets into local environment" # Example infisical run [options] -- npm run dev ``` + @@ -20,6 +21,7 @@ description: "The command that injects your secrets into local environment" # Example infisical run [options] --command "npm run bootstrap && npm run dev start; other-bash-command" ``` + @@ -27,27 +29,38 @@ description: "The command that injects your secrets into local environment" Inject secrets from Infisical into your application process. - ## Subcommands & flags Use this command to inject secrets into your applications process - ```bash - $ infisical run -- +```bash +$ infisical run -- - # Example - $ infisical run -- npm run dev - ``` +# Example +$ infisical run -- npm run dev +``` + +### Environment variables - ### Environment variables - Used to fetch secrets via a [service token](/documentation/platform/token) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. + Used to fetch secrets via a [machine identity](/documentation/platform/identities/machine-identities) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. ```bash - # Example - export INFISICAL_TOKEN=st.63e03c4a97cb4a747186c71e.ed5b46a34c078a8f94e8228f4ab0ff97.4f7f38034811995997d72badf44b42ec + # Example + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages. ``` + + + Alternatively, you may use service tokens. + + Please note, however, that service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + ```bash + # Example + export INFISICAL_TOKEN= + ``` + + @@ -56,71 +69,96 @@ Inject secrets from Infisical into your application process. To use, simply export this variable in the terminal before running this command. ```bash - # Example + # Example export INFISICAL_DISABLE_UPDATE_CHECK=true ``` + - ### Flags - +### Flags + - Explicitly set the directory where the .infisical.json resides. This is useful for some monorepo setups. + Explicitly set the directory where the .infisical.json resides. This is useful for some monorepo setups. ```bash - # Example + # Example infisical run --project-config-dir=/some-dir -- printenv ``` + Pass secrets into multiple commands at once ```bash - # Example + # Example infisical run --command="npm run build && npm run dev; more-commands..." ``` + + + + + The project ID to fetch secrets from. This is required when using a machine identity to authenticate. + + ```bash + # Example + infisical run --projectId= -- npm run dev + ``` + - If you are using a [service token](/documentation/platform/token) to authenticate, you can pass the token as a flag + If you are using a [machine identity](/documentation/platform/identities/machine-identities) to authenticate, you can pass the token as a flag ```bash - # Example - infisical run --token="st.63e03c4a97cb4a747186c71e.ed5b46a34c078a8f94e8228f4ab0ff97.4f7f38034811995997d72badf44b42ec" -- npm run start + # Example + infisical run --token="" --projectId= -- npm run start ``` - You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the run command. This will have the same effect as setting the token with `--token` flag + You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the run command. This will have the same effect as setting the token with `--token` flag + Turn on or off the shell parameter expansion in your secrets. If you have used shell parameters in your secret(s), activating this feature will populate them before injecting them into your application process. Default value: `true` + - - This is used to specify the environment from which secrets should be retrieved. The accepted values are the environment slugs defined for your project, such as `dev`, `staging`, `test`, and `prod`. - - Default value: `dev` + + By default imported secrets are available, you can disable it by setting this option to false. + + Default value: `true` +{" "} + + + This is used to specify the environment from which secrets should be + retrieved. The accepted values are the environment slugs defined for your + project, such as `dev`, `staging`, `test`, and `prod`. Default value: `dev` + + Prioritizes personal secrets with the same name over shared secrets Default value: `true` + When working with tags, you can use this flag to filter and retrieve only secrets that are associated with a specific tag(s). ```bash - # Example + # Example infisical run --tags=tag1,tag2,tag3 -- npm run dev ``` Note: you must reference the tag by its slug name not its fully qualified name. Go to project settings to view all tag slugs. By default, all secrets are fetched + diff --git a/docs/cli/commands/secrets.mdx b/docs/cli/commands/secrets.mdx index cf76f1842..c279b1a15 100644 --- a/docs/cli/commands/secrets.mdx +++ b/docs/cli/commands/secrets.mdx @@ -23,13 +23,23 @@ $ infisical secrets ### Environment variables - Used to fetch secrets via a [service token](/documentation/platform/token) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. + Used to fetch secrets via a [machine identity](/documentation/platform/identities/machine-identities) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. ```bash # Example - export INFISICAL_TOKEN=st.63e03c4a97cb4a747186c71e.ed5b46a34c078a8f94e8228f4ab0ff97.4f7f38034811995997d72badf44b42ec + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages. ``` + + Alternatively, you may use service tokens. + + Please note, however, that service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + ```bash + # Example + export INFISICAL_TOKEN= + ``` + + @@ -53,6 +63,16 @@ $ infisical secrets + + The project ID to fetch secrets from. This is required when using a machine identity to authenticate. + + ```bash + # Example + infisical secrets --projectId= + ``` + + + Used to select the environment name on which actions should be taken on @@ -133,6 +153,16 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb ``` + + + Used to select the type of secret to create. This could be either personal or shared (defaults to shared) + + ```bash + # Example + infisical secrets set DOMAIN=example.com --type=personal + ``` + + @@ -186,7 +216,7 @@ $ infisical secrets folders - Fetch folders using the Infisical service token + Fetch folders using a [machine identity](/documentation/platform/identities/machine-identities) access token. Default value: `` diff --git a/docs/cli/commands/service-token.mdx b/docs/cli/commands/service-token.mdx index 007f845dc..4971b3db0 100644 --- a/docs/cli/commands/service-token.mdx +++ b/docs/cli/commands/service-token.mdx @@ -3,37 +3,47 @@ title: "infisical service-token" description: "Manage Infisical service tokens" --- -```bash + + This command is deprecated and will be removed in the near future. Please + switch to using [Machine + Identities](/documentation/platform/identities/machine-identities) for + authenticating with Infisical. + + +```bash infisical service-token create --scope=dev:/global --scope=dev:/backend --access-level=read --access-level=write ``` ## Description -The Infisical `service-token` command allows you to manage service tokens for a given Infisical project. + +The Infisical `service-token` command allows you to manage service tokens for a given Infisical project. With this command, you can create, view, and delete service tokens. Use this command to create a service token - ```bash - $ infisical service-token create --scope=dev:/backend/** --access-level=read --access-level=write - ``` +```bash +$ infisical service-token create --scope=dev:/backend/** --access-level=read --access-level=write +``` + +### Flags - ### Flags ```bash infisical service-token create --scope=dev:/global --scope=dev:/backend/** --access-level=read ``` Use the scope flag to define which environments and paths your service token should be authorized to access. - - The value of your scope flag should be in the following `:`. + + The value of your scope flag should be in the following `:`. Here, `environment slug` refers to the slug name of the environment, and `path` indicates the folder path where your secrets are stored. For specifying multiple scopes, you can use multiple --scope flags. - + The `path` can be a Glob pattern + @@ -41,8 +51,9 @@ With this command, you can create, view, and delete service tokens. infisical service-token create --scope=dev:/global --access-level=read --projectId=63cefb15c8d3175601cfa989 ``` - The project ID you'd like to create the service token for. + The project ID you'd like to create the service token for. By default, the CLI will attempt to use the linked Infisical project in `.infisical.json` generated by `infisical init` command. + ```bash @@ -52,6 +63,7 @@ With this command, you can create, view, and delete service tokens. Service token name Default: `Service token generated via CLI` + ```bash @@ -61,6 +73,7 @@ With this command, you can create, view, and delete service tokens. Set the service token's expiration time in seconds from now. To never expire set to zero. Default: `1 day` + ```bash @@ -68,6 +81,7 @@ With this command, you can create, view, and delete service tokens. ``` The type of access the service token should have. Can be `read` and or `write` + ```bash @@ -77,5 +91,6 @@ With this command, you can create, view, and delete service tokens. When true, only the service token will be printed Default: `false` + diff --git a/docs/cli/faq.mdx b/docs/cli/faq.mdx index cf95457c9..47e89a48f 100644 --- a/docs/cli/faq.mdx +++ b/docs/cli/faq.mdx @@ -13,6 +13,7 @@ If none of the available stores work for you, you can try using the `file` store If you are still experiencing trouble, please seek support. [Learn more about vault command](./commands/vault) + diff --git a/docs/cli/token.mdx b/docs/cli/token.mdx deleted file mode 100644 index b5a8dc6a9..000000000 --- a/docs/cli/token.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: "Infisical Token" -description: "How to use Infisical service token within the CLI." ---- - -Prerequisite: [Infisical Token and How to Generate One](/documentation/platform/token). - -It's possible to use the CLI to sync environment variables without manually entering login credentials by using a service token in the prerequisite link above. - -## Feeding Infisical Token to the CLI - -The CLI looks out for an environment variable called the `INFISICAL_TOKEN` which you can set depending on where you run the CLI. If `INFISICAL_TOKEN` is detected by the CLI, it will authenticate and retrieve the environment variables which the token is authorized for. - -A common use-case is to use the Infisical Token to fetch environment variables with Docker. More specifically, a token can be passed to a container as an environment variable for the CLI to authenticate and pull its corresponding secrets. Check out the integration guides for that: - -- [Docker](../../integrations/platforms/docker) -- [Docker Compose](../../integrations/platforms/docker-compose) - - - Once the token is expired, the CLI using it will no longer be able to make - requests with it. - diff --git a/docs/cli/usage.mdx b/docs/cli/usage.mdx index aa60c3db1..e372bd8cf 100644 --- a/docs/cli/usage.mdx +++ b/docs/cli/usage.mdx @@ -1,141 +1,125 @@ --- -title: "Quick usage" +title: "Quickstart" description: "Manage secrets with Infisical CLI" --- -The CLI is designed for a variety of applications, ranging from local secret management to CI/CD and production scenarios. -The distinguishing factor, however, is the authentication method used. +The CLI is designed for a variety of secret management applications ranging from local development to CI/CD and production scenarios. - - To use the Infisical CLI in your local development environment, simply run the command below and follow the interactive guide. + + In the following steps, we explore how to use the Infisical CLI to fetch back environment variables from Infisical + and inject them into your local development process. + + + + Start by running the `infisical login` command to authenticate with Infisical. + + ```bash + infisical login + ``` + + If you are in a containerized environment such as WSL 2 or Codespaces, run `infisical login -i` to avoid browser based login + + + + Next, navigate to your project and initialize Infisical. + + ```bash + # navigate to your project + cd /path/to/project - ```bash - infisical login - ``` + # initialize infisical + infisical init + ``` - - If you are in a containerized environment such as WSL 2 or Codespaces, run `infisical login -i` to avoid browser based login - + The `infisical init` command creates a `.infisical.json` file, containing [local project settings](./project-config), at the location where the command is executed. - ## Initialize Infisical for your project + + The `.infisical.json` file does not contain any sensitive data, so you may commit it to your git repository. + + + + Finally, pass environment variables from Infisical into your application. - ```bash - # navigate to your project - cd /path/to/project + + + ```bash + infisical run --env=dev --path=/apps/firefly -- [your application start command] # e.g. npm run dev - # initialize infisical - infisical init - ``` + # example with node (nodemon) + infisical run --env=staging --path=/apps/spotify -- nodemon index.js + + # example with flask + infisical run --env=prod --path=/apps/backend -- flask run + + # example with spring boot - maven + infisical run --env=dev --path=/apps/ -- ./mvnw spring-boot:run --quiet + ``` + + + + Custom aliases can utilize secrets from Infisical. Suppose there is a custom alias `yd` in `custom.sh` that runs `yarn dev` and needs the secrets provided by Infisical. + ```bash + #!/bin/sh + + yd() { + yarn dev + } + ``` + + To make the secrets available from Infisical to `yd`, you can run the following command: + + ```bash + infisical run --env=prod --path=/apps/reddit --command="source custom.sh && yd" + ``` + + + + View all available options for `run` command [here](./commands/run) + + - This will create `.infisical.json` file at the location the command was executed. This file contains your [local project settings](./project-config). It does not contain any sensitive data. - - - To use Infisical for non local development scenarios, please create a [service token](../documentation/platform/token). The service token will allow you to authenticate and interact with Infisical. - Once you have created a service token with the required permissions, you'll need to feed the token to the CLI. + + In the following steps, we explore how to use the Infisical CLI in a non-local development scenario + to fetch back environment variables and export them to a file. + + + Follow the steps listed [here](/documentation/platform/identities/universal-auth) to create a machine identity and obtain a **client ID** and **client secret** for it. + + + Run the following command to authenticate with Infisical using the **client ID** and **client secret** credentials from step 1 and set the `INFISICAL_TOKEN` environment variable to the retrieved access token. + + ```bash + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages. + ``` - #### Pass as flag - You may use the --token flag to set the token + The CLI is configured to look out for the `INFISICAL_TOKEN` environment variable, so going forward any command used will be authenticated. - ``` - infisical export --token=<> - infisical secrets --token=<> - infisical run --token=<> -- npm run dev - ``` + Alternatively, assuming you have an access token on hand, you can also pass it directly to the CLI using the `--token` flag in conjunction with other CLI commands. - #### Pass via shell environment variable - The CLI is configured to look for an environment variable named `INFISICAL_TOKEN`. If set, it'll attempt to use it for authentication. + + Keep in mind that the machine identity access token has a limited lifetime. It is recommended to use it only for the duration of the task at hand. + You can [refresh the token](./commands/token) if needed. + + + + Finally, export the environment variables from Infisical to a file of choice. - ``` - export INFISICAL_TOKEN=<> - ``` - + ```bash + # export variables to a .env file (with export keyword) + infisical export --format=dotenv-export > .env + + # export variables to a YAML file + infisical export --format=yaml > secrets.yaml + ``` + + - -## Inject environment variables - - - ```bash - infisical run --env=dev --path=/apps/firefly -- [your application start command] - - # example with node (nodemon) - infisical run --env=staging --path=/apps/spotify -- nodemon index.js - - # example with flask - infisical run --env=prod --path=/apps/backend -- flask run - - # example with spring boot - maven - infisical run --env=dev --path=/apps/ -- ./mvnw spring-boot:run --quiet - ``` - - - Custom aliases can utilize secrets from Infisical. Suppose there is a custom alias `yd` in `custom.sh` that runs `yarn dev` and needs the secrets provided by Infisical. - ```bash - #!/bin/sh - - yd() { - yarn dev - } - ``` - - To make the secrets available from Infisical to `yd`, you can run the following command: - - ```bash - infisical run --env=prod --path=/apps/reddit --command="source custom.sh && yd" - ``` - - - -View all available options for `run` command [here](./commands/run) - -## Connect CLI to self hosted Infisical - - -The CLI is set to connect to Infisical Cloud by default, but if you're running your own instance of Infisical, you can direct the CLI to it using one of the methods provided below. - -#### Method 1: Use the updated CLI -Beginning with CLI version V0.4.0, it is now possible to choose between logging in through the Infisical cloud or your own self-hosted instance. Simply execute the `infisical login` command and follow the on-screen instructions. - -#### Method 2: Export environment variable -You can point the CLI to the self hosted Infisical instance by exporting the environment variable `INFISICAL_API_URL` in your terminal. - - - - ```bash - # Set backend host - export INFISICAL_API_URL="https://your-self-hosted-infisical.com/api" - - # Remove backend host - unset INFISICAL_API_URL - ``` - - - ```bash - # Set backend host - setx INFISICAL_API_URL "https://your-self-hosted-infisical.com/api" - - # Remove backend host - setx INFISICAL_API_URL "" - - # NOTE: Once set or removed, please restart powershell for the change to take effect - ``` - - - -#### Method 3: Set manually on every command -Another option to point the CLI to your self hosted Infisical instance is to set it via a flag on every command you run. - -```bash -# Example -infisical --domain="https://your-self-hosted-infisical.com/api" -``` - - ## History Your terminal keeps a history with the commands you run. When you create Infisical secrets directly from your terminal, they'll stay there for a while. @@ -143,30 +127,101 @@ Your terminal keeps a history with the commands you run. When you create Infisic For security and privacy concerns, we recommend you to configure your terminal to ignore those specific Infisical commands. + + + + `$HOME/.profile` is pretty common but, you could place it under `$HOME/.profile.d/infisical.sh` or any profile file run at login + - - - - `$HOME/.profile` is pretty common but, you could place it under `$HOME/.profile.d/infisical.sh` or any profile file run at login - + ```bash + cat <> $HOME/.profile && source $HOME/.profile + # Ignoring specific Infisical CLI commands + DEFAULT_HISTIGNORE=$HISTIGNORE + export HISTIGNORE="*infisical secrets set*:$DEFAULT_HISTIGNORE" + EOF + ``` + + + + If you're on WSL, then you can use the Unix/Linux method. + + + Here's some [documentation](https://superuser.com/a/1658331) about how to clear the terminal history, in PowerShell and CMD + + + + + + + +## FAQ + + + + Yes. The CLI is set to connect to Infisical Cloud by default, but if you're running your own instance of Infisical, you can direct the CLI to it using one of the methods provided below. + + #### Method 1: Use the updated CLI + + Beginning with CLI version V0.4.0, it is now possible to choose between logging in through the Infisical cloud or your own self-hosted instance. Simply execute the `infisical login` command and follow the on-screen instructions. + + #### Method 2: Export environment variable + + You can point the CLI to the self hosted Infisical instance by exporting the environment variable `INFISICAL_API_URL` in your terminal. + + + ```bash - cat <> $HOME/.profile && source $HOME/.profile + # set backend host + export INFISICAL_API_URL="https://your-self-hosted-infisical.com/api" - # Ignoring specific Infisical CLI commands - DEFAULT_HISTIGNORE=$HISTIGNORE - export HISTIGNORE="*infisical secrets set*:$DEFAULT_HISTIGNORE" - EOF + # remove backend host + unset INFISICAL_API_URL ``` - - - If you're on WSL, then you can use the Unix/Linux method. + + + ```bash + # set backend host + setx INFISICAL_API_URL "https://your-self-hosted-infisical.com/api" - - Here's some [documentation](https://superuser.com/a/1658331) about how to clear the terminal history, in PowerShell and CMD - + # remove backend host + setx INFISICAL_API_URL "" - - - \ No newline at end of file + # NOTE: Once set or removed, please restart powershell for the change to take effect + ``` + + + + + +#### Method 3: Set manually on every command + +Another option to point the CLI to your self hosted Infisical instance is to set it via a flag on every command you run. + +```bash +# Example +infisical --domain="https://your-self-hosted-infisical.com/api" +``` + + + + Yes. Please note, however, that service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + To use Infisical for non local development scenarios, please create a service token. The service token will allow you to authenticate and interact with Infisical. Once you have created a service token with the required permissions, you’ll need to feed the token to the CLI. + + ```bash + infisical export --token= + infisical secrets --token= + infisical run --token= -- npm run dev + ``` + + #### Pass via shell environment variable + The CLI is configured to look for an environment variable named `INFISICAL_TOKEN`. If set, it’ll attempt to use it for authentication. + + ```bash + export INFISICAL_TOKEN= + ``` + + + diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index 0f414c62a..06455092d 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -4,59 +4,66 @@ sidebarTitle: "What is Infisical?" description: "An Introduction to the Infisical secret management platform." --- -Infisical is an [open-source](https://github.com/infisical/infisical) secret management platform for developers. -It provides capabilities for storing, managing, and syncing application configuration and secrets like API keys, database -credentials, and certificates across infrastructure. In addition, Infisical prevents secrets leaks to git and enables secure +Infisical is an [open-source](https://github.com/infisical/infisical) secret management platform for developers. +It provides capabilities for storing, managing, and syncing application configuration and secrets like API keys, database +credentials, and certificates across infrastructure. In addition, Infisical prevents secrets leaks to git and enables secure sharing of secrets among engineers. 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. - + + Get started with Infisical Cloud in just a few minutes. + + + Self-host Infisical on your own infrastructure. + -## Why Infisical? +## Why Infisical? + +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: -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: - 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)**. +- **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. -## How does Infisical work? +## How does Infisical work? -To make secret management effortless and secure, Infisical follows a certain structure for enabling secret management workflows as defined below. +To make secret management effortless and secure, Infisical follows a certain structure for enabling secret management workflows as defined below. -**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**. +**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**. -As a result, the 3 main concepts that are important to understand are: -- **[Identities](/documentation/platform/identities/overview)**: users or machines with a set permissions assigned to them. +As a result, the 3 main concepts that are important to understand are: + +- **[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, etc.). +- **[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? +## 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. - - Install the Infisical Helm repository - - ```console - helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' - - helm repo update - ``` - - Install the Helm chart - ```console - helm install --generate-name infisical-helm-charts/secrets-operator - ``` - - - - The operator will be installed in `infisical-operator-system` namespace - ``` - kubectl apply -f https://raw.githubusercontent.com/Infisical/infisical/main/k8-operator/kubectl-install/install-secrets-operator.yaml - ``` - - - - -## Usage - -**Step 1: Create Kubernetes secret containing service token** - -Once you have generated the service token, create a Kubernetes secret containing the service token you generated by running the command below. - -``` bash -kubectl create secret generic service-token --from-literal=infisicalToken= -``` - -**Step 2: Fill out the InfisicalSecrets CRD and apply it to your cluster** - -```yaml infisical-secrets-config.yaml -apiVersion: secrets.infisical.com/v1alpha1 -kind: InfisicalSecret -metadata: - # Name of of this InfisicalSecret resource - name: infisicalsecret-sample -spec: - # The host that should be used to pull secrets from. If left empty, the value specified in Global configuration will be used - hostAPI: https://app.infisical.com/api - resyncInterval: - authentication: - serviceToken: - serviceTokenSecretReference: - secretName: service-token - secretNamespace: option - secretsScope: - envSlug: dev - secretsPath: "/" - managedSecretReference: - secretName: managed-secret # <-- the name of kubernetes secret that will be created - secretNamespace: default # <-- where the kubernetes secret should be created -``` - -``` -kubectl apply -f infisical-secrets-config.yaml -``` - -You should now see a new kubernetes secret automatically created in the namespace you defined in the `managedSecretReference` property above. - -See also: - -- [Documentation for the Infisical Kubernetes Operator](../../integrations/platforms/kubernetes) - diff --git a/docs/documentation/guides/node.mdx b/docs/documentation/guides/node.mdx index 8b78cde5e..d1b8fe5e8 100644 --- a/docs/documentation/guides/node.mdx +++ b/docs/documentation/guides/node.mdx @@ -36,7 +36,7 @@ Initialize a new Node.js project with a default `package.json` file. npm init -y ``` -Install `express` and [infisical-node](https://github.com/Infisical/infisical-node), the client Node SDK for Infisical. +Install `express` and [@infisical/sdk](https://www.npmjs.com/package/@infisical/sdk), the client Node SDK for Infisical. ```console npm install express @infisical/sdk @@ -46,16 +46,19 @@ Finally, create an index.js file containing the application code. ```js const express = require('express'); -const { InfisicalClient, LogLevel } = require("@infisical/sdk"); +const { InfisicalClient } = require("@infisical/sdk"); const app = express(); const PORT = 3000; const client = new InfisicalClient({ - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", - logLevel: LogLevel.Error + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET", + } + } }); app.get("/", async (req, res) => { diff --git a/docs/documentation/guides/python.mdx b/docs/documentation/guides/python.mdx index 696f0a7ee..00b3d6089 100644 --- a/docs/documentation/guides/python.mdx +++ b/docs/documentation/guides/python.mdx @@ -5,7 +5,7 @@ title: "Python" This guide demonstrates how to use Infisical to manage secrets for your Python stack from local development to production. 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. -- The [infisical-python](https://github.com/Infisical/sdk/tree/main/crates/infisical-py) Python client SDK to fetch secrets back to your Python application on demand. +- The [infisical-python](https://pypi.org/project/infisical-python/) Python client SDK to fetch secrets back to your Python application on demand. ## Project Setup @@ -36,23 +36,27 @@ python3 -m venv env source env/bin/activate ``` -Install Flask and [infisical-python](https://github.com/Infisical/sdk/tree/main/crates/infisical-py), the client Python SDK for Infisical. +Install Flask and [infisical-python](https://pypi.org/project/infisical-python/), the client Python SDK for Infisical. ```console -pip install Flask infisical-python +pip install flask infisical-python ``` Finally, create an `app.py` file containing the application code. ```py from flask import Flask -from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions +from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions, AuthenticationOptions, UniversalAuthMethod app = Flask(__name__) client = InfisicalClient(ClientSettings( - client_id="MACHINE_IDENTITY_CLIENT_ID", - client_secret="MACHINE_IDENTITY_CLIENT_SECRET", + auth=AuthenticationOptions( + universal_auth=UniversalAuthMethod( + client_id="CLIENT_ID", + client_secret="CLIENT_SECRET", + ) + ) )) @app.route("/") diff --git a/docs/documentation/platform/audit-log-streams.mdx b/docs/documentation/platform/audit-log-streams.mdx new file mode 100644 index 000000000..2a69780bc --- /dev/null +++ b/docs/documentation/platform/audit-log-streams.mdx @@ -0,0 +1,82 @@ +--- +title: "Audit Log Streams" +description: "Learn how to stream Infisical Audit Logs to external logging providers." +--- + + + Audit log streams is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + +Infisical Audit Log Streaming enables you to transmit your organization's Audit Logs to external logging providers for monitoring and analysis. + +The logs are formatted in JSON, requiring your logging provider to support JSON-based log parsing. + + +## Overview + + + + + ![stream create](../../images/platform/audit-log-streams/stream-create.png) + + + ![stream create](../../images/platform/audit-log-streams/stream-inputs.png) + + 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 listt](../../images/platform/audit-log-streams/stream-list.png) +Your Audit Logs are now ready to be streamed. + +## Example Providers + +### Better Stack + + + + ![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) + + 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 \**. + + + +### Datadog + + + + ![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) + + 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) + + 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**. + + diff --git a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx new file mode 100644 index 000000000..6ec5b48b9 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx @@ -0,0 +1,151 @@ +--- +title: "AWS IAM" +description: "How to dynamically generate AWS IAM Users." +--- + +The Infisical AWS IAM dynamic secret allows you to generate AWS IAM Users on demand based on configured AWS policy. + +## Prerequisite + +Infisical needs an initial AWS IAM user with the required permissions to create sub IAM users. This IAM user will be responsible for managing the lifecycle of new IAM users. + + + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:AttachUserPolicy", + "iam:CreateAccessKey", + "iam:CreateUser", + "iam:DeleteAccessKey", + "iam:DeleteUser", + "iam:DeleteUserPolicy", + "iam:DetachUserPolicy", + "iam:GetUser", + "iam:ListAccessKeys", + "iam:ListAttachedUserPolicies", + "iam:ListGroupsForUser", + "iam:ListUserPolicies", + "iam:PutUserPolicy", + "iam:AddUserToGroup", + "iam:RemoveUserFromGroup" + ], + "Resource": ["*"] + } + ] +} +``` + +To minimize managing user access you can attach a resource in format + +> arn:aws:iam::\:user/\ + +Replace **\** with your AWS account id and **\** with a path to minimize managing user access. + + + +## Set up Dynamic Secrets with AWS IAM + + + + Navigate to the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret to. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret + + + + The managing AWS IAM User Access Key + + + + The managing AWS IAM User Secret Key + + + + [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + + + + The AWS data center region. + + + + The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. + + + + The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas + + + + The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas + + + + The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png) + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret in step 4. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values-aws-iam.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the lease details and delete the lease ahead of its expiration time. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases +To extend the life of the generated dynamic secret lease past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/documentation/platform/dynamic-secrets/overview.mdx b/docs/documentation/platform/dynamic-secrets/overview.mdx index 84477f1c2..81ab42656 100644 --- a/docs/documentation/platform/dynamic-secrets/overview.mdx +++ b/docs/documentation/platform/dynamic-secrets/overview.mdx @@ -24,7 +24,7 @@ This approach offers several advantages in terms of security and management: - **Scalability**: Dynamic secret management systems can scale more effectively to handle a large number of services and applications, as they automate much of the overhead associated with manual secret management. -Dynamic secrets are particularly useful in environments with stringent security requirements, such as cloud environments, distributed systems, and microservices architectures, where they help to manage database credentials, API keys, service tokens, and other types of secrets. +Dynamic secrets are particularly useful in environments with stringent security requirements, such as cloud environments, distributed systems, and microservices architectures, where they help to manage database credentials, API keys, tokens, and other types of secrets. ## Infisical Dynamic Secret Templates @@ -32,3 +32,4 @@ Dynamic secrets are particularly useful in environments with stringent security 2. [MySQL](./mysql) 3. [Cassandra](./cassandra) 4. [Oracle](./oracle) +5. [AWS IAM](./aws-iam) diff --git a/docs/documentation/platform/identities/aws-auth.mdx b/docs/documentation/platform/identities/aws-auth.mdx new file mode 100644 index 000000000..3eb094cc4 --- /dev/null +++ b/docs/documentation/platform/identities/aws-auth.mdx @@ -0,0 +1,315 @@ +--- +title: AWS Auth +description: "Learn how to authenticate with Infisical for EC2 instances, Lambda functions, and other IAM principals." +--- + +**AWS Auth** is an AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to access Infisical. + +## Diagram + +The following sequence digram illustrates the AWS Auth workflow for authenticating AWS IAM principals with Infisical. + +```mermaid +sequenceDiagram + participant Client as Client + participant Infis as Infisical + participant AWS as AWS STS + + Note over Client,Client: Step 1: Sign GetCallerIdentityQuery + + Note over Client,Infis: Step 2: Login Operation + Client->>Infis: Send signed query details /api/v1/auth/aws-auth/login + + Note over Infis,AWS: Step 3: Query verification + Infis->>AWS: Forward signed GetCallerIdentity query + AWS-->>Infis: Return IAM user/role details + + Note over Infis: Step 4: Identity Property Validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 5: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates an IAM principal by verifying its identity and checking that it meets specific requirements (e.g. it is an allowed IAM principal ARN) at the `/api/v1/auth/aws-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client IAM principal signs a `GetCallerIdentity` query using the [AWS Signature v4 algorithm](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html); this is done using the credentials from the AWS environment where the IAM principal is running. +2. The client sends the signed query data to Infisical including the request method, request body, and request headers at the `/api/v1/auth/aws-auth/login` endpoint. +3. Infisical reconstructs the query and sends it to AWS STS API via the [sts:GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) method for verification and obtains the identity associated with the IAM principal. +4. Infisical checks the identity's properties against set criteria such **Allowed Principal ARNs**. +5. If all is well, Infisical returns a short-lived access token that the IAM principal can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using AWS Auth as they handle the +authentication process including the signed `GetCallerIdentity` query +construction for you. + +Also, note that Infisical needs network-level access to send requests to the AWS STS API +as part of the AWS Auth workflow. + + + +## Guide + +In the following steps, we explore how to create and use identities for your workloads and applications on AWS to +access the Infisical API using the AWS Auth authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **AWS Auth**. + + ![identities create aws auth method](/images/platform/identities/identities-org-create-aws-auth-method.png) + + Here's some more guidance on each field: + + - Allowed Principal ARNs: A comma-separated list of trusted IAM principal ARNs that are allowed to authenticate with Infisical. The values should take one of three forms: `arn:aws:iam::123456789012:user/MyUserName`, `arn:aws:iam::123456789012:role/MyRoleName`, or `arn:aws:iam::123456789012:*`. Using a wildcard in this case allows any IAM principal in the account `123456789012` to authenticate with Infisical under the identity. + - Allowed Account IDs: A comma-separated list of trusted AWS account IDs that are allowed to authenticate with Infisical. + - STS Endpoint (default is `https://sts.amazonaws.com/`): The endpoint URL for the AWS STS API. This value should be adjusted based on the AWS region you are operating in (e.g. `https://sts.us-east-1.amazonaws.com/`); refer to the list of regional STS endpoints [here](https://docs.aws.amazon.com/general/latest/gr/sts.html). + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + To access the Infisical API as the identity, you need to construct a signed `GetCallerIdentity` query using the [AWS Signature v4 algorithm](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html) and make a request to the `/api/v1/auth/aws-auth/login` endpoint containing the query data + in exchange for an access token. + + We provide a few code examples below of how you can authenticate with Infisical from inside a Lambda function, EC2 instance, etc. and obtain an access token to access the [Infisical API](/api-reference/overview/introduction). + + + + The following query construction is an example of how you can authenticate with Infisical from inside a Lambda function. + + The shown example uses Node.js but you can use other languages supported by AWS Lambda. + + ```javascript + import AWS from "aws-sdk"; + import axios from "axios"; + + export const handler = async (event, context) => { + try { + const region = process.env.AWS_REGION; + AWS.config.update({ region }); + + const iamRequestURL = `https://sts.${region}.amazonaws.com/`; + const iamRequestBody = "Action=GetCallerIdentity&Version=2011-06-15"; + const iamRequestHeaders = { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + Host: `sts.${region}.amazonaws.com`, + }; + + // Create the request + const request = new AWS.HttpRequest(iamRequestURL, region); + request.method = "POST"; + request.headers = iamRequestHeaders; + request.headers["X-Amz-Date"] = AWS.util.date + .iso8601(new Date()) + .replace(/[:-]|\.\d{3}/g, ""); + request.body = iamRequestBody; + request.headers["Content-Length"] = + Buffer.byteLength(iamRequestBody).toString(); + + // Sign the request + const signer = new AWS.Signers.V4(request, "sts"); + signer.addAuthorization(AWS.config.credentials, new Date()); + + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const identityId = ""; + + const { data } = await axios.post( + `${infisicalUrl}/api/v1/auth/aws-auth/login`, + { + identityId, + iamHttpRequestMethod: "POST", + iamRequestUrl: Buffer.from(iamRequestURL).toString("base64"), + iamRequestBody: Buffer.from(iamRequestBody).toString("base64"), + iamRequestHeaders: Buffer.from( + JSON.stringify(iamRequestHeaders) + ).toString("base64"), + } + ); + + console.log("result data: ", data); // access token here + } catch (err) { + console.error(err); + } + }; + ```` + + + The following query construction is an example of how you can authenticate with Infisical from inside a EC2 instance. + + The shown example uses Node.js but you can use other language you wish. + + ```javascript + import AWS from "aws-sdk"; + import axios from "axios"; + + const main = async () => { + try { + // obtain region from EC2 instance metadata + const tokenResponse = await axios.put("http://169.254.169.254/latest/api/token", null, { + headers: { + "X-aws-ec2-metadata-token-ttl-seconds": "21600" + } + }); + + const url = "http://169.254.169.254/latest/dynamic/instance-identity/document"; + const response = await axios.get(url, { + headers: { + "X-aws-ec2-metadata-token": tokenResponse.data + } + }); + + const region = response.data.region; + + AWS.config.update({ + region + }); + + const iamRequestURL = `https://sts.${region}.amazonaws.com/`; + const iamRequestBody = "Action=GetCallerIdentity&Version=2011-06-15"; + const iamRequestHeaders = { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + Host: `sts.${region}.amazonaws.com` + }; + + const request = new AWS.HttpRequest(new AWS.Endpoint(iamRequestURL), AWS.config.region); + request.method = "POST"; + request.headers = iamRequestHeaders; + request.headers["X-Amz-Date"] = AWS.util.date.iso8601(new Date()).replace(/[:-]|\.\d{3}/g, ""); + request.body = iamRequestBody; + request.headers["Content-Length"] = Buffer.byteLength(iamRequestBody); + + const signer = new AWS.Signers.V4(request, "sts"); + signer.addAuthorization(AWS.config.credentials, new Date()); + + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const identityId = ""; + + const { data } = await axios.post(`${infisicalUrl}/api/v1/auth/aws-auth/login`, { + identityId, + iamHttpRequestMethod: "POST", + iamRequestUrl: Buffer.from(iamRequestURL).toString("base64"), + iamRequestBody: Buffer.from(iamRequestBody).toString("base64"), + iamRequestHeaders: Buffer.from(JSON.stringify(iamRequestHeaders)).toString("base64") + }); + + console.log("result data: ", data); // access token here + } catch (err) { + console.error(err); + } + } + + main(); + ```` + + + The following query construction provides a generic example of how you can construct a signed `GetCallerIdentity` query and obtain the required payload components. + + The shown example uses Node.js but you can use any language you wish. + + ```javascript + const AWS = require("aws-sdk"); + + const region = ""; + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + + const iamRequestURL = `https://sts.${region}.amazonaws.com/`; + const iamRequestBody = "Action=GetCallerIdentity&Version=2011-06-15"; + const iamRequestHeaders = { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + Host: `sts.${region}.amazonaws.com` + }; + + const request = new AWS.HttpRequest(new AWS.Endpoint(iamRequestURL), region); + request.method = "POST"; + request.headers = iamRequestHeaders; + request.headers["X-Amz-Date"] = AWS.util.date.iso8601(new Date()).replace(/[:-]|\.\d{3}/g, ""); + request.body = iamRequestBody; + request.headers["Content-Length"] = Buffer.byteLength(iamRequestBody); + + const signer = new AWS.Signers.V4(request, "sts"); + signer.addAuthorization(AWS.config.credentials, new Date()); + ```` + + #### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/auth/aws-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'identityId=...' \ + --data-urlencode 'iamHttpRequestMethod=...' \ + --data-urlencode 'iamRequestBody=...' \ + --data-urlencode 'iamRequestHeaders=...' + ``` + + + Note that you should replace `` with the ID of the identity you created in step 1. + + + #### Sample response + + ```bash Response + { + "accessToken": "...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43244 + "tokenType": "Bearer" + } + ``` + + Next, you can use the access token to access the [Infisical API](/api-reference/overview/introduction) + + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using AWS Auth as they handle the authentication process including the signed `GetCallerIdentity` query construction for you. + + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + diff --git a/docs/documentation/platform/identities/azure-auth.mdx b/docs/documentation/platform/identities/azure-auth.mdx new file mode 100644 index 000000000..3ac957752 --- /dev/null +++ b/docs/documentation/platform/identities/azure-auth.mdx @@ -0,0 +1,176 @@ +--- +title: Azure Auth +description: "Learn how to authenticate with Infisical for services on Azure" +--- + +**Azure Auth** is an Azure-native authentication method for Azure resources like Azure VMs, Azure App Services, Azure Functions, Azure Kubernetes Service, etc. to access Infisical. + +## Diagram + +The following sequence digram illustrates the Azure Auth workflow for authenticating Azure [service principals](https://learn.microsoft.com/en-us/entra/identity-platform/app-objects-and-service-principals?tabs=browser) with Infisical. + +```mermaid +sequenceDiagram + participant Client as Client + participant Infis as Infisical + participant Azure as Azure AD OpenID + + Note over Client,Azure: Step 1: Instance Identity Token Retrieval + Client->>Azure: Request managed identity access token + Azure-->>Client: Return managed identity access token + + Note over Client,Infis: Step 2: Identity Token Login Operation + Client->>Infis: Send managed identity access token to /api/v1/auth/azure-auth/login + Infis->>Azure: Request public key + Azure-->>Infis: Return public key + + Note over Infis: Step 3: Identity Token Verification + Note over Infis: Step 4: Identity Property Validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 4: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates an Azure service by verifying its identity and checking that it meets specific requirements (e.g. it is bound to an allowed service principal) at the `/api/v1/auth/azure-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client running on an Azure service obtains an [access token](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http) that is a JWT token representing the managed identity for the Azure resource such as a Virtual Machine; the managed identity is associated with a service principal in Azure AD. +2. The client sends the access token to Infisical. +3. Infisical verifies the token against the corresponding public key at the [public Azure AD OpenID configuration endpoint](https://learn.microsoft.com/en-us/answers/questions/793793/azure-ad-validate-access-token). +4. Infisical checks if the entity behind the access token is allowed to authenticate with Infisical based on set criteria such as **Allowed Service Principal IDs**. +5. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using Azure Auth as they handle the +authentication process including generating the client access token for you. + +Also, note that Infisical needs network-level access to send requests to the Google Cloud API +as part of the Azure Auth workflow. + + + +## Guide + +In the following steps, we explore how to create and use identities for your applications in Azure to +access the Infisical API using the Azure Auth authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **Azure Auth**. + + ![identities create azure auth method](/images/platform/identities/identities-org-create-azure-auth-method.png) + + Here's some more guidance on each field: + + - Tenant ID: The [tenant ID](https://learn.microsoft.com/en-us/entra/fundamentals/how-to-find-tenant) for the Azure AD organization. + - Resource / Audience: The resource URL for the application registered in Azure AD. The value is expected to match the `aud` claim of the access token JWT later used in the login operation against Infisical. See the [resource](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http) parameter for how the audience is set when requesting a JWT access token from the Azure Instance Metadata Service (IMDS) endpoint. In most cases, this value should be `https://management.azure.com/` which is the default. + - Allowed Service Principal IDs: A comma-separated list of Azure AD service principal IDs that are allowed to authenticate with Infisical. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + To access the Infisical API as the identity, you need to generate a managed identity [access token](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http) that is a JWT token representing the managed identity for the Azure resource such as a Virtual Machine. The client token must be sent to the `/api/v1/auth/azure-auth/login` endpoint in exchange for a separate access token to access the Infisical API. + + We provide a few code examples below of how you can authenticate with Infisical to access the [Infisical API](/api-reference/overview/introduction). + + + + Start by making a request from your Azure client such as Virtual Machine to obtain a managed identity access token. + + For more examples of how to obtain the managed identity access token, refer to the [official documentation](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http). + + #### Sample request + ```bash curl + curl 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F' -H Metadata:true -s + ``` + + #### Sample response + ```bash + { + "access_token": "eyJ0eXAi...", + "refresh_token": "", + "expires_in": "3599", + "expires_on": "1506484173", + "not_before": "1506480273", + "resource": "https://management.azure.com/", + "token_type": "Bearer" + } + ``` + + Next use send the obtained managed identity access token (i.e. the token from the `access_token` field above) to authenticate with Infisical and obtain a separate access token. + + #### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/auth/gcp-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'identityId=...' \ + --data-urlencode 'jwt=...' + ``` + + + Note that you should replace `` with the ID of the identity you created in step 1. + + + #### Sample response + + ```bash Response + { + "accessToken": "...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43244 + "tokenType": "Bearer" + } + ``` + + Next, you can use this access token to access the [Infisical API](/api-reference/overview/introduction) + + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using Azure Auth as they handle the authentication process including retrieving the client access token. + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + diff --git a/docs/documentation/platform/identities/gcp-auth.mdx b/docs/documentation/platform/identities/gcp-auth.mdx new file mode 100644 index 000000000..c836a946d --- /dev/null +++ b/docs/documentation/platform/identities/gcp-auth.mdx @@ -0,0 +1,351 @@ +--- +title: GCP Auth +description: "Learn how to authenticate with Infisical for services on Google Cloud Platform" +--- + +**GCP Auth** is a GCP-native authentication method for GCP resources to access Infisical. It consists of two sub-methods/approaches: + +- GCP ID Token Auth: For GCP services including [Compute Engine](https://cloud.google.com/compute/docs/instances/verifying-instance-identity#request_signature), [App Engine standard environment](https://cloud.google.com/appengine/docs/standard/python3/runtime#metadata_server), [App Engine flexible environment](https://cloud.google.com/appengine/docs/flexible/python/runtime#metadata_server), [Cloud Functions](https://cloud.google.com/functions/docs/securing/function-identity#using_the_metadata_server_to_acquire_tokens), [Cloud Run](https://cloud.google.com/run/docs/container-contract#metadata-server), [Google Kubernetes Engine](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity#instance_metadata), and [Cloud Build](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity#instance_metadata) to authenticate with Infisical. +- GCP IAM Auth: For Google Cloud Platform (GCP) service accounts to authenticate with Infisical. + + + + + ## Diagram + + The following sequence digram illustrates the GCP ID Token Auth workflow for authenticating GCP resources with Infisical. + +```mermaid +sequenceDiagram + participant GCE as GCP Service + participant Infis as Infisical + participant Google as OAuth2 API + + Note over GCE,Google: Step 1: Instance Identity Token Retrieval + GCE->>Google: Request instance identity metadata token + Google-->>GCE: Return JWT token with RS256 signature + + Note over GCE,Infis: Step 2: Identity Token Login Operation + GCE->>Infis: Send JWT token to /api/v1/auth/gcp-auth/login + Infis->>Google: Request OAuth2 certificates + Google-->>Infis: Return certificates + + Note over Infis: Step 3: Identity Token Verification + Note over Infis: Step 4: Identity Property Validation + Infis->>GCE: Return short-lived access token + + Note over GCE,Infis: Step 4: Access Infisical API with Token + GCE->>Infis: Make authenticated requests using the short-lived access token +``` + + ## Concept + +At a high-level, Infisical authenticates a GCP resource by verifying its identity and checking that it meets specific requirements (e.g. it is an allowed GCE instance) at the `/api/v1/auth/gcp-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client running on a GCP service obtains an [ID token](https://cloud.google.com/docs/authentication/get-id-token) constituting the identity for a GCP resource such as a GCE instance or Cloud Function; this is a unique JWT token that includes details about the instance as well as Google's [RS256 signature](https://datatracker.ietf.org/doc/html/rfc7518#section-3.3). +2. The client sends the ID token to Infisical at the `/api/v1/auth/gcp-auth/login` endpoint. +3. Infisical verifies the token against Google's [public OAuth2 certificates](https://www.googleapis.com/oauth2/v3/certs). +4. Infisical checks if the entity behind the ID token is allowed to authenticate with Infisical based on set criteria such as **Allowed Service Account Emails**. +5. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using GCP ID Token Auth as they handle the +authentication process including generating the instance ID token for you. + +Also, note that Infisical needs network-level access to send requests to the Google Cloud API +as part of the GCP Auth workflow. + + + +## Guide + +In the following steps, we explore how to create and use identities for your workloads and applications on GCP to +access the Infisical API using the GCP ID Token authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **GCP Auth** and set the **Type** to **GCP ID Token Auth**. + + ![identities create gcp auth method](/images/platform/identities/identities-org-create-gcp-gce-auth-method.png) + + Here's some more guidance on each field: + + - Allowed Service Account Emails: A comma-separated list of trusted service account emails corresponding to the GCE resource(s) allowed to authenticate with Infisical; this could be something like `test@project.iam.gserviceaccount.com`, `12345-compute@developer.gserviceaccount.com`, etc. + - Allowed Projects: A comma-separated list of trusted GCP projects that the GCE instance must belong to authenticate with Infisical. Note that this validation property will only work for GCE instances. + - Allowed Zones: A comma-separated list of trusted zones that the GCE instances must belong to authenticate with Infisical; this should be the fully-qualified zone name in the format `-`like `us-central1-a`, `us-west1-b`, etc. Note that this validation property will only work for GCE instances. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + To access the Infisical API as the identity, you need to generate an [ID token](https://cloud.google.com/docs/authentication/get-id-token) constituting the identity of the present GCE instance and make a request to the `/api/v1/auth/gcp-auth/login` endpoint containing the token in exchange for an access token. + + We provide a few code examples below of how you can authenticate with Infisical to access the [Infisical API](/api-reference/overview/introduction). + + + + Start by making a request from the GCE instance to obtain the ID token. + For more examples of how to obtain the token in Java, Go, Node.js, etc. refer to the [official documentation](https://cloud.google.com/docs/authentication/get-id-token#curl). + + #### Sample request + + ```bash curl + curl -H "Metadata-Flavor: Google" \ + 'http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=&format=full' + ``` + + + + Note that you should replace `` with the ID of the identity you created in step 1. + + + Next use send the obtained JWT token along to authenticate with Infisical and obtain an access token. + + #### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/auth/gcp-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'identityId=...' \ + --data-urlencode 'jwt=...' + ``` + + #### Sample response + + ```bash Response + { + "accessToken": "...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43244 + "tokenType": "Bearer" + } + ``` + + Next, you can use the access token to access the [Infisical API](/api-reference/overview/introduction) + + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using GCP IAM Auth as they handle the authentication process including generating the signed JWT token. + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + + + + + + ## Diagram + + The following sequence digram illustrates the GCP IAM Auth workflow for authenticating GCP IAM service accounts with Infisical. + +```mermaid +sequenceDiagram + participant GCE as Client + participant Infis as Infisical + participant Google as Cloud IAM + + Note over GCE,Google: Step 1: Signed JWT Token Generation + GCE->>Google: Request to generate signed JWT token + Google-->>GCE: Return signed JWT token + + Note over GCE,Infis: Step 2: JWT Token Login Operation + GCE->>Infis: Send signed JWT token to /api/v1/auth/gcp-auth/login + Infis->>Google: Request public key + Google-->>Infis: Return public key + + Note over Infis: Step 3: JWT Token Verification + Note over Infis: Step 4: JWT Property Validation + Infis->>GCE: Return short-lived access token + + Note over GCE,Infis: Step 5: Access Infisical API with Token + GCE->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates an IAM service account by verifying its identity and checking that it meets specific requirements (e.g. it is an allowed service account) at the `/api/v1/auth/gcp-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client generates a signed JWT token using the `projects.serviceAccounts.signJwt` [API method](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt); this is done using the service account credentials associated with the client. +2. The client sends the signed JWT token to Infisical at the `/api/v1/auth/gcp-auth/login` endpoint. +3. Infisical verifies the signed JWT token. +4. Infisical checks if the service account behind the JWT token is allowed to authenticate with Infisical based **Allowed Service Account Emails**. +5. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using GCP IAM Auth as they handle the +authentication process including generating the signed JWT token. + +Also, note that Infisical needs network-level access to send requests to the Google Cloud API +as part of the GCP Auth workflow. + + + +## Guide + +In the following steps, we explore how to create and use identities for your workloads and applications on GCP to +access the Infisical API using the GCP IAM authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **GCP IAM Auth** and set the **Type** to **GCP IAM Auth**. + + ![identities create gcp auth method](/images/platform/identities/identities-org-create-gcp-iam-auth-method.png) + + Here's some more guidance on each field: + + - Allowed Service Account Emails: A comma-separated list of trusted IAM service account emails that are allowed to authenticate with Infisical; this could be something like `test@project.iam.gserviceaccount.com`, `12345-compute@developer.gserviceaccount.com`, etc. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + To access the Infisical API as the identity, you need to generate a signed JWT token using the `projects.serviceAccounts.signJwt` [API method](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt) and make a request to the `/api/v1/auth/gcp-auth/login` endpoint containing the signed JWT token in exchange for an access token. + + + Make sure that the service account has the `iam.serviceAccounts.signJwt` permission or the `roles/iam.serviceAccountTokenCreator` role. + + + We provide a few code examples below of how you can authenticate with Infisical to access the [Infisical API](/api-reference/overview/introduction). + + + + The following code provides a generic example of how you can generate a signed JWT token against the `projects.serviceAccounts.signJwt` API method. + + The shown example uses Node.js and the official [google-auth-library](https://github.com/googleapis/google-auth-library-nodejs#readme) package but you can use any language you wish. + + + ```javascript + const { GoogleAuth } = require("google-auth-library"); + + const auth = new GoogleAuth({ + scopes: "https://www.googleapis.com/auth/cloud-platform", + }); + + const credentials = await auth.getCredentials(); + + const identityId = ""; + + const jwtPayload = { + sub: credentials.client_email, + aud: identityId, + }; + + const { data } = await client.request({ + url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${credentials.client_email}:signJwt`, + method: "POST", + data: { payload: JSON.stringify(jwtPayload) }, + }); + + const jwt = data.signedJwt // send this jwt to Infisical in the next step + ``` + + #### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/auth/gcp-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'identityId=...' \ + --data-urlencode 'jwt=...' + ``` + + #### Sample response + + ```bash Response + { + "accessToken": "...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43244 + "tokenType": "Bearer" + } + ``` + + Next, you can use the access token to access the [Infisical API](/api-reference/overview/introduction) + + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using GCP IAM Auth as they handle the authentication process including generating the signed JWT token. + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + + + + diff --git a/docs/documentation/platform/identities/kubernetes-auth.mdx b/docs/documentation/platform/identities/kubernetes-auth.mdx new file mode 100644 index 000000000..b154f36f6 --- /dev/null +++ b/docs/documentation/platform/identities/kubernetes-auth.mdx @@ -0,0 +1,247 @@ +--- +title: Kubernetes Auth +description: "Learn how to authenticate with Infisical in Kubernetes" +--- + +**Kubernetes Auth** is a Kubernetes-native authentication method for applications (e.g. pods) to access Infisical. + +## Diagram + + The following sequence digram illustrates the Kubernetes Auth workflow for authenticating applications running in pods with Infisical. + +```mermaid +sequenceDiagram + participant Pod as Pod + participant Infis as Infisical + participant KubernetesServer as K8s API Server + + Note over Pod: Step 1: Service Account JWT Token Retrieval + + Note over Pod,Infis: Step 2: JWT Token Login Operation + Pod->>Infis: Send JWT token to /api/v1/auth/kubernetes-auth/login + Infis->>KubernetesServer: Forward JWT token for validation + KubernetesServer-->>Infis: Return identity info for JWT + + Note over Infis: Step 3: Identity Property Verification + Infis->>Pod: Return short-lived access token + + Note over Pod,Infis: Step 4: Access Infisical API with Token + Pod->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates an application in Kubernetes by verifying its identity and checking that it meets specific requirements (e.g. it is bound to an allowed service account) at the `/api/v1/auth/kubernetes-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The application deployed on Kubernetes retrieves its [service account credential](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#opt-out-of-api-credential-automounting) that is a JWT token at the `/var/run/secrets/kubernetes.io/serviceaccount/token` pod path. +2. The application sends the JWT token to Infisical at the `/api/v1/auth/kubernetes-auth/login` endpoint after which Infisical forwards the JWT token to the Kubernetes API Server at the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/) for verification and to obtain the service account information associated with the JWT token. Infisical is able to authenticate and interact with the TokenReview API by using a long-lived service account JWT token itself (referred to onward as the token reviewer JWT token). +3. Infisical checks the service account properties against set criteria such **Allowed Service Account Names** and **Allowed Namespaces**. +4. If all is well, Infisical returns a short-lived access token that the application can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using Kubernetes Auth as they handle the +authentication process including service account credential retrieval for you. + + +## Guide + +In the following steps, we explore how to create and use identities for your applications in Kubernetes to access the Infisical API using the Kubernetes Auth authentication method. + + + + 1.1. Start by creating a service account in your Kubernetes cluster that will be used by Infisical to authenticate with the Kubernetes API Server. + + ```yaml infisical-service-account.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-auth + namespace: default + + ``` + + ``` + kubectl apply -f infisical-service-account.yaml + ``` + + 1.2. Bind the service account to the `system:auth-delegator` cluster role. As described [here](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#other-component-roles), this role allows delegated authentication and authorization checks, specifically for Infisical to access the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/). You can apply the following configuration file: + + ```yaml cluster-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: role-tokenreview-binding + namespace: default + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: infisical-auth + namespace: default + ``` + + ``` + kubectl apply -f cluster-role-binding.yaml + ``` + + 1.3. Next, create a long-lived service account JWT token (i.e. the token reviewer JWT token) for the service account using this configuration file for a new `Secret` resource: + + ```yaml service-account-token.yaml + apiVersion: v1 + kind: Secret + type: kubernetes.io/service-account-token + metadata: + name: infisical-auth-token + annotations: + kubernetes.io/service-account.name: "infisical-auth" + ``` + + + ``` + kubectl apply -f service-account-token.yaml + ``` + + 1.4. Link the secret in step 1.3 to the service account in step 1.1: + + ```bash + kubectl patch serviceaccount infisical-auth -p '{"secrets": [{"name": "infisical-auth-token"}]}' -n default + ``` + + 1.5. Finally, retrieve the token reviewer JWT token from the secret. + + ```bash + kubectl get secret infisical-auth-token -n default -o=jsonpath='{.data.token}' | base64 --decode + ``` + + Keep this JWT token handy as you will need it for the **Token Reviewer JWT** field when configuring the Kubernetes Auth authentication method for the identity in step 2. + + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **Kubernetes Auth**. + + ![identities organization create auth method](/images/platform/identities/identities-org-create-kubernetes-auth-method.png) + + Here's some more guidance on each field: + + - Kubernetes Host / Base Kubernetes API URL: The host string, host:port pair, or URL to the base of the Kubernetes API server. This can usually be obtained by running `kubectl cluster-info`. + - Token Reviewer JWT: A long-lived service account JWT token for Infisical to access the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/) to validate other service account JWT tokens submitted by applications/pods. This is the JWT token obtained from step 1.5. + - Allowed Service Account Names: A comma-separated list of trusted service account names that are allowed to authenticate with Infisical. + - Allowed Namespaces: A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical. + - Allowed Audience: An optional audience claim that the service account JWT token must have to authenticate with Infisical. + - CA Certificate: The PEM-encoded CA cert for the Kubernetes API server. This is used by the TLS client for secure communication with the Kubernetes API server. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + + To access the Infisical API as the identity, you should first make sure that the pod running your application is bound to a service account specified in the **Allowed Service Account Names** field of the identity's Kubernetes Auth authentication method configuration in step 2. + + Once bound, the pod will receive automatically mounted service account credentials that is a JWT token at the `/var/run/secrets/kubernetes.io/serviceaccount/token` path. This token should be used to authenticate with Infisical at the `/api/v1/auth/kubernetes-auth/login` endpoint. + + For information on how to configure sevice accounts for pods, refer to the guide [here](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/). + + We provide a code example below of how you might retrieve the JWT token and use it to authenticate with Infisical to gain access to the [Infisical API](/api-reference/overview/introduction). + + The shown example uses Node.js but you can use any other language to retrieve the service account JWT token and use it to authenticate with Infisical. + + ```javascript + const fs = require("fs"); + try { + const tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"; + const jwtToken = fs.readFileSync(tokenPath, "utf8"); + + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const identityId = ""; + + const { data } = await axios.post( + `{infisicalUrl}/api/v1/auth/kubernetes-auth/login`, + { + identityId, + jwt, + } + ); + + console.log("result data: ", data); // access token here + } catch(err) { + console.error(err); + } + ``` + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using Kubernetes Auth as they handle the authentication process including service account credential retrieval for you. + + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + + If an identity access token exceeds its max ttl, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + + +**FAQ** + + + + There are a few reasons for why this might happen: + - The Kubernetes Auth authentication method configuration is invalid. + - The service account JWT token has expired is malformed or invalid. + - The service account associated with the JWT token does not meet the criteria set forth in the Kubernetes Auth authentication method configuration such as **Allowed Service Account Names** and **Allowed Namespaces**. + + + There are a few reasons for why this might happen: + + - The access token has expired. + - The identity is insufficently permissioned to interact with the resources you wish to access. + - The client access token is being used from an untrusted IP. + + + A identity access token can have a time-to-live (TTL) or incremental lifetime after which it expires. + + In certain cases, you may want to extend the lifespan of an access token; to do so, you must set a max TTL parameter. + +A token can be renewed any number of time and each call to renew it will extend the toke life by increments of access token TTL. +Regardless of how frequently an access token is renewed, its lifespan remains bound to the maximum TTL determined at its creation + + + diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index 77999effe..9cc6c4c3d 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -1,5 +1,5 @@ --- -title: Machine Identities +title: Machine Identities description: "Learn how to use Machine Identities to programmatically interact with Infisical." --- @@ -7,9 +7,9 @@ description: "Learn how to use Machine Identities to programmatically interact w An Infisical machine identity is an entity that represents a workload or application that require access to various resources in Infisical. This is conceptually similar to an IAM user in AWS or service account in Google Cloud Platform (GCP). -Each identity must authenticate with the API using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth) to get back a short-lived access token to be used in subsequent requests. +Each identity must authenticate with the Infisical API using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth), [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth), [AWS Auth](/documentation/platform/identities/aws-auth), [Azure Auth](/documentation/platform/identities/azure-auth), or [GCP Auth](/documentation/platform/identities/gcp-auth) to get back a short-lived access token to be used in subsequent requests. -![organization identities](/images/platform/organization/organization-machine-identities.png) +![Organization Identities](/images/platform/organization/organization-machine-identities.png) Key Features: @@ -21,30 +21,41 @@ Key Features: A typical workflow for using identities consists of four steps: 1. Creating the identity with a name and [role](/documentation/platform/role-based-access-controls) in Organization Access Control > Machine Identities. -This step also involves configuring an authentication method for it such as [Universal Auth](/documentation/platform/identities/universal-auth). + This step also involves configuring an authentication method for it. 2. Adding the identity to the project(s) you want it to have access to. 3. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back. 4. Authenticating subsequent requests with the Infisical API using the short-lived access token. - Currently, identities can only be used to make authenticated requests to the Infisical API, SDKs, Terraform, Kubernetes Operator, and Infisical Agent. They do not work with clients such as CLI, Ansible look up plugin, etc. - Machine Identity support for the rest of the clients is planned to be released in the current quarter. - +Machine Identity support for the rest of the clients is planned to be released in the current quarter. + ## Authentication Methods To interact with various resources in Infisical, Machine Identities are able to authenticate using: -- [Universal Auth](/documentation/platform/identities/universal-auth): the most versatile authentication method that can be configured on an identity from any platform/environment to access Infisical. +- [Universal Auth](/documentation/platform/identities/universal-auth): A platform-agnostic authentication method that can be configured on an identity suitable to authenticate from any platform/environment. +- [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth): A Kubernetes-native authentication method for applications (e.g. pods) to authenticate with Infisical. +- [AWS Auth](/documentation/platform/identities/aws-auth): An AWS-native authentication method for AWS services (e.g. EC2, Lambda functions, etc.) to authenticate with Infisical. +- [Azure Auth](/documentation/platform/identities/azure-auth): An Azure-native authentication method for Azure resources (e.g. Azure VMs, Azure App Services, Azure Functions, Azure Kubernetes Service, etc.) to authenticate with Infisical. +- [GCP Auth](/documentation/platform/identities/gcp-auth): A GCP-native authentication method for GCP resources (e.g. Compute Engine, App Engine, Cloud Run, Google Kubernetes Engine, IAM service accounts, etc.) to authenticate with Infisical. ## FAQ + + +Yes - Identities can be used with the CLI. + +You can learn more about how to do this in the CLI quickstart [here](/cli/usage). + + + - A service token is a project-level authentication method that is being phased out in favor of identities. + A service token is a project-level authentication method that is being deprecated in favor of identities. The service token method will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). Amongst many differences, identities provide broader access over the Infisical API, utilizes the same permission system as user identities, and come with a significantly larger number of configurable authentication and security features. diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index a9f4dffae..09ed1cb7b 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -3,19 +3,39 @@ title: Universal Auth description: "Learn how to authenticate to Infisical from any platform or environment." --- -**Universal Auth** is the most versatile authentication method that can be configured for a [machine identity](/documentation/platform/identities/machine-identities) to access Infisical from any platform or environment. +**Universal Auth** is a platform-agnostic authentication method that can be configured for a [machine identity](/documentation/platform/identities/machine-identities) suitable to authenticate from any platform/environment. -In this method, each identity is given a **Client ID** for which you can generate one or more **Client Secret(s)**. Together, a **Client ID** and **Client Secret** can be exchanged for an access token to authenticate with the Infisical API. +## Diagram -## Properties +The following sequence digram illustrates the Universal Auth workflow for authenticating clients with Infisical. -Universal Auth supports many settings that can be beneficial for tightening your workflow security configuration: +```mermaid +sequenceDiagram + participant Client as Client + participant Infis as Infisical -- Support for restrictions on the number of times that the **Client Secret(s)** and access token(s) can be used. -- Support for expiration, so, if specified, the **Client Secret** of the identity will automatically be defunct after a period of time. -- Support for IP allowlisting; this means you can restrict the usage of **Client Secret(s)** and access token to a specific IP or CIDR range. + Note over Client,Infis: Step 1: Login Operation + Client->>Infis: Send Client ID and Client Secret -## Workflow + Note over Infis: Step 2: Client ID and Client Secret validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 3: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +In this method, Infisical authenticates a client by verifying the credentials issued for it at the `/api/v1/auth/universal-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client submits a **Client ID** and **Client Secret** to Infisical at the `/api/v1/auth/universal-auth/login` endpoint. +2. Infisical verifies the credential pair. +3. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + +## Guide In the following steps, we explore how to create and use identities for your workloads and applications to access the Infisical API using the Universal Auth authentication method. @@ -27,18 +47,18 @@ using the Universal Auth authentication method. ![identities organization](/images/platform/identities/identities-org.png) When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. - + ![identities organization create](/images/platform/identities/identities-org-create.png) Now input a few details for your new identity. Here's some guidance for each field: - Name (required): A friendly name for the identity. - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. - + Once you've created an identity, you'll be prompted to configure the **Universal Auth** authentication method for it. - + ![identities organization create auth method](/images/platform/identities/identities-org-create-auth-method.png) - + Here's some more guidance on each field: - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. @@ -78,8 +98,9 @@ using the Universal Auth authentication method. Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. ![identities project](/images/platform/identities/identities-project.png) - + ![identities project create](/images/platform/identities/identities-project-create.png) + To access the Infisical API as the identity, you should first perform a login operation @@ -88,16 +109,16 @@ using the Universal Auth authentication method. #### Sample request - ``` + ```bash Request curl --location --request POST 'https://app.infisical.com/api/v1/auth/universal-auth/login' \ --header 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'clientSecret=...' \ - --data-urlencode 'clientId=...' + --data-urlencode 'clientId=...' \ + --data-urlencode 'clientSecret=...' ``` - + #### Sample response - - ``` + + ```bash Response { "accessToken": "...", "expiresIn": 7200, @@ -107,7 +128,7 @@ using the Universal Auth authentication method. ``` Next, you can use the access token to authenticate with the [Infisical API](/api-reference/overview/introduction) - + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted. @@ -115,6 +136,7 @@ using the Universal Auth authentication method. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, a new access token should be obtained by performing another login operation. + @@ -130,11 +152,12 @@ using the Universal Auth authentication method. - The client secret/access token is being used from an untrusted IP. - A identity access token can have a time-to-live (TTL) or incremental lifetime afterwhich it expires. + A identity access token can have a time-to-live (TTL) or incremental lifetime after which it expires. In certain cases, you may want to extend the lifespan of an access token; to do so, you must set a max TTL parameter. - A token can be renewed any number of time and each call to renew it will extend the toke life by increments of access token TTL. - Regardless of how frequently an access token is renewed, its lifespan remains bound to the maximum TTL determined at its creation +A token can be renewed any number of time and each call to renew it will extend the toke life by increments of access token TTL. +Regardless of how frequently an access token is renewed, its lifespan remains bound to the maximum TTL determined at its creation + - \ No newline at end of file + diff --git a/docs/documentation/platform/identities/user-identities.mdx b/docs/documentation/platform/identities/user-identities.mdx index 2d4791127..bcb470a3e 100644 --- a/docs/documentation/platform/identities/user-identities.mdx +++ b/docs/documentation/platform/identities/user-identities.mdx @@ -17,7 +17,6 @@ Upon being added to an organization and projects, users assume a certain set of 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. -- **Service Tokens**: Service tokens allow users authenticate into CLI and other clients under their own identity. For the majority of use cases, it is not a recommended approach. Instead, it is often a good idea to utilize [Machine Identities](./machine-identities) with [Universal Authentication](/documentation/platform/identities/universal-auth). - **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/ip-allowlisting.mdx b/docs/documentation/platform/ip-allowlisting.mdx deleted file mode 100644 index 7f787fff8..000000000 --- a/docs/documentation/platform/ip-allowlisting.mdx +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: "IP Allowlisting" -description: "Restrict access to your secrets in Infisical using trusted IPs" ---- - - - IP allowlisting at the project-level is being replaced with IP allowlisting at the token-level now available with the Service Token V3 authentication method. - - Instead of providing trusted IPs (specific IPs and CIDR ranges) to be applied across all service tokens, - you can now specify trusted IPs at the token-level. - - - - Note that IP Allowlisting is a paid feature. - - If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, - then you should contact sales@infisical.com to purchase an enterprise license to use it. - - -Projects in Infisical can be configured to restrict client access to specific IP addresses or CIDR ranges. This applies to any client using service tokens and -can be useful, for example, for limiting access to traffic coming from corporate networks. - -By default, each project is initialized with the `0.0.0.0/0` entry, representing all possible IPv4 addresses. -For enhanced security, we strongly recommend replacing the default entry with your client IPs to tighten access to your secrets. - - - You must be a project `admin` to manage your project's IP whitelist. - - -![IP whitelist](../../images/platform/ip-allowlisting/ip-allowlisting-table.png) - -## Creating a trusted IP entry - -To create a trusted IP entry, head over to the **IP Whitelist** tab in your project. When creating an entry, -you can specify either a specific IP address like `192.0.2.1` or a CIDR range like `2001:db8::/32`; both IPv4 and IPv6 -formats are accepted. - -![IP whitelist add](../../images/platform/ip-allowlisting/ip-allowlisting-modal.png) diff --git a/docs/documentation/platform/ldap/general.mdx b/docs/documentation/platform/ldap/general.mdx index aa4841625..5e4253a34 100644 --- a/docs/documentation/platform/ldap/general.mdx +++ b/docs/documentation/platform/ldap/general.mdx @@ -12,6 +12,10 @@ description: "Learn how to log in to Infisical with LDAP." You can configure your organization in Infisical to have members authenticate with the platform via [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) +Prerequisites: + +- You must have an email address to use LDAP, regardless of whether or not you use that email address to sign in. + In Infisical, head to your Organization Settings > Security > LDAP and select **Manage**. diff --git a/docs/documentation/platform/ldap/jumpcloud.mdx b/docs/documentation/platform/ldap/jumpcloud.mdx index 0b40d8b3a..b92b52bb9 100644 --- a/docs/documentation/platform/ldap/jumpcloud.mdx +++ b/docs/documentation/platform/ldap/jumpcloud.mdx @@ -10,6 +10,10 @@ description: "Learn how to configure JumpCloud LDAP for authenticating into Infi it. +Prerequisites: + +- You must have an email address to use LDAP, regardless of whether or not you use that email address to sign in. + In JumpCloud, head to USER MANAGEMENT > Users and create a new user via the **Manual user entry** option. This user diff --git a/docs/documentation/platform/ldap/overview.mdx b/docs/documentation/platform/ldap/overview.mdx index 2423be8c0..4d6c75e15 100644 --- a/docs/documentation/platform/ldap/overview.mdx +++ b/docs/documentation/platform/ldap/overview.mdx @@ -3,11 +3,13 @@ title: "LDAP Overview" sidebarTitle: "Overview" description: "Learn how to authenticate into Infisical with LDAP." --- + LDAP 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 sales@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 sales@infisical.com to purchase an enterprise license to use it. + You can configure your organization in Infisical to have members authenticate with the platform via [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol). @@ -25,3 +27,18 @@ Read the general instructions for configuring LDAP [here](/documentation/platfor If the documentation for your required identity provider is not shown in the list above, please reach out to [team@infisical.com](mailto:team@infisical.com) for assistance. +## FAQ + + + + By default, Infisical Cloud is configured to not trust emails from external + identity providers to prevent any malicious account takeover attempts via + email spoofing. Accordingly, Infisical creates a new user for anyone provisioned + through an external identity provider and requires an additional email + verification step upon their first login. + + If you're running a self-hosted instance of Infisical and would like it to trust emails from external identity providers, + you can configure this behavior in the admin panel. + + + diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index 042f7525b..119a8cefa 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -9,17 +9,9 @@ description: "Learn the fundamentals of secret referencing and importing in Infi Infisical's secret referencing functionality makes it possible to reference the value of a "base" secret when defining the value of another secret. This means that updating the value of a base secret propagates directly to other secrets whose values depend on the base secret. - - Currently, the secret referencing feature is only supported by the - [Infisical CLI](/cli/overview), [native integrations](/integrations/overview) and [Infisical Agent](/infisical-agent/overview). - - We intend to add support for it to the [Node SDK](https://infisical.com/docs/sdks/languages/node), - [Python SDK](https://infisical.com/docs/sdks/languages/python), and [Java SDK](https://infisical.com/docs/sdks/languages/java) this quarter. - - ![secret referencing](../../images/platform/secret-references-imports/secret-reference.png) -Since secret referencing works by reconstructing values back on the client side, the client, be it a user or service token, fetching back secrets +Since secret referencing works by reconstructing values back on the client side, the client, be it a user, service token, or a machine identity, fetching back secrets must be permissioned access to all base and dependent secrets. For example, to access some secret `A` whose values depend on secrets `B` and `C` from different scopes, a client must have `read` access to the scopes of secrets `A`, `B`, and `C`. diff --git a/docs/documentation/platform/secret-sharing.mdx b/docs/documentation/platform/secret-sharing.mdx new file mode 100644 index 000000000..680751820 --- /dev/null +++ b/docs/documentation/platform/secret-sharing.mdx @@ -0,0 +1,45 @@ +--- +title: "Secret Sharing" +sidebarTitle: "Secret Sharing" +description: "Learn how to share time & view-count bound secrets securely with anyone on the internet." +--- + +Developers frequently need to share secrets with team members, contractors, or other third parties, which can be risky due to potential leaks or misuse. +Infisical offers a secure solution for sharing secrets over the internet in a time and view count bound manner. + +With its zero-knowledge architecture, secrets shared via Infisical remain unreadable even to Infisical itself. + +## Share a Secret + +1. Navigate to the **Organization** page. +2. Click on the **Secret Sharing** tab from the sidebar. + +![Secret Sharing](../../images/platform/secret-sharing/overview.png) + + + Infisical does not have access to the shared secrets. This is a part of our + zero knowledge architecture. + + +3. Click on the **Share Secret** button. Set the secret, its expiration time as well as the number of views allowed. It expires as soon as any of the conditions are met. + + ![Add View-Bound Sharing Secret](../../images/platform/secret-sharing/create-new-secret.png) + + + Secret once set cannot be changed. This is to ensure that the secret is not + tampered with. + + +5. Copy the link and share it with the intended recipient. Anyone with the link can access the secret before its expiration condition. Hence, it is recommended to share the link only with the intended recipient. + +![Copy URL](../../images/platform/secret-sharing/copy-url.png) + +## Access a Shared Secret + +Just click on the link you received to access the secret. The secret will be displayed on the screen & for how long it is valid. + +![Access Shared Secret](../../images/platform/secret-sharing/public-view.png) + +## Delete a Shared Secret + +In the **Secret Sharing** tab, click on the **Delete** button next to the secret you want to delete. This will delete the secret immediately & the link will no longer be accessible. diff --git a/docs/documentation/platform/secret-versioning.mdx b/docs/documentation/platform/secret-versioning.mdx index 741bbc308..6a0efb8b8 100644 --- a/docs/documentation/platform/secret-versioning.mdx +++ b/docs/documentation/platform/secret-versioning.mdx @@ -3,9 +3,9 @@ title: "Secret Versioning" description: "Learn how secret versioning works in Infisical." --- -Every time a secret change is persformed, a new version of the same secret is created. +Every time a secret change is performed, a new version of the same secret is created. -Such versions can be accessed visually by opening up the [secret sidebar](/documentation/platform/project#drawer) (as seen below) or [retrived via API](/api-reference/endpoints/secrets/read) +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](../../images/platform/secret-versioning.png) diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx index c81141c92..b0ac046d0 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -4,10 +4,10 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." --- - Okta SAML SSO is a paid feature. - - If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, - then you should contact sales@infisical.com to purchase an enterprise license to use it. + Okta SAML SSO is a paid feature. If you're using Infisical Cloud, then it is + available under the **Pro Tier**. If you're self-hosting Infisical, then you + should contact sales@infisical.com to purchase an enterprise license to use + it. @@ -22,24 +22,24 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." button. ![SAML Okta create app integration](../../../images/sso/okta/create-app-integration.png) - + In the Create a New Application Integration dialog, select the **SAML 2.0** radio button: ![SAML Okta create SAML 2.0 integration](../../../images/sso/okta/create-saml-app.png) - + On the General Settings screen, give the application a unique name like Infisical and select **Next**. - + ![SAML Okta create SAML 2.0 integration](../../../images/sso/okta/general-settings.png) - + On the Configure SAML screen, set the **Single sign-on URL** and **Audience URI (SP Entity ID)** from step 1. ![SAML Okta configure IdP fields](../../../images/sso/okta/configure-saml.png) - + If you're self-hosting Infisical, then you will want to replace `https://app.infisical.com` with your own domain. - + Also on the Configure SAML screen, configure the **Attribute Statements** to map: - `id -> user.id`, @@ -50,6 +50,7 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." ![SAML Okta attribute statements](../../../images/sso/okta/attribute-statements.png) Once configured, select **Next** to proceed to the Feedback screen and select **Finish**. + Once your application is created, select the **Sign On** tab for the app and select the **View Setup Instructions** button located on the right side of the screen: @@ -59,12 +60,14 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." Copy the **Identity Provider Single Sign-On URL**, the **Identity Provider Issuer**, and the **X.509 Certificate** to use when finishing configuring Okta SAML in Infisical. ![SAML Okta IdP values](../../../images/sso/okta/idp-values.png) + Back in Infisical, set **Identity Provider Single Sign-On URL**, **Identity Provider Issuer**, and **Certificate** to **X.509 Certificate** from step 3. Once you've done that, press **Update** to complete the required configuration. ![SAML Okta paste values into Infisical](../../../images/sso/okta/idp-values-2.png) + Back in Okta, navigate to the **Assignments** tab and select **Assign**. You can assign access to the application on a user-by-user basis using the Assign to People option, or in-bulk using the Assign to Groups option. @@ -72,11 +75,13 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." ![SAML Okta assignment](../../../images/sso/okta/assignment.png) At this point, you have configured everything you need within the context of the Okta Admin Portal. + Enabling SAML SSO allows members in your organization to log into Infisical via Okta. ![SAML Okta enable SAML](../../../images/sso/okta/enable-saml.png) + Enforcing SAML SSO ensures that members in your organization can only access Infisical @@ -89,13 +94,15 @@ description: "Learn how to configure Okta SAML 2.0 for Infisical SSO." We recommend ensuring that your account is provisioned the application in Okta prior to enforcing SAML SSO to prevent any unintended issues. + - If you're configuring SAML SSO on a self-hosted instance of Infisical, make sure to - set the `AUTH_SECRET` and `SITE_URL` environment variable for it to work: - - - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This can be a random 32-byte base64 string generated with `openssl rand -base64 32`. - - `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com) - \ No newline at end of file + If you're configuring SAML SSO on a self-hosted instance of Infisical, make + sure to set the `AUTH_SECRET` and `SITE_URL` environment variable for it to + work: - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This + can be a random 32-byte base64 string generated with `openssl rand -base64 + 32`. - `SITE_URL`: The URL of your self-hosted instance of Infisical - should + be an absolute URL including the protocol (e.g. https://app.infisical.com) + diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index 6064f26e8..9ab0acc3a 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -5,11 +5,12 @@ description: "Learn how to log in to Infisical via SSO protocols." --- - Infisical offers Google SSO and GitHub SSO for free across both Infisical Cloud and Infisical Self-hosted. - - Infisical also offers SAML SSO authentication but as paid features that can be unlocked on Infisical Cloud's **Pro** tier - or via enterprise license on self-hosted instances of Infisical. On this front, we support industry-leading providers including - Okta, Azure AD, and JumpCloud; with any questions, please reach out to team@infisical.com. + Infisical offers Google SSO and GitHub SSO for free across both Infisical + Cloud and Infisical Self-hosted. Infisical also offers SAML SSO authentication + but as paid features that can be unlocked on Infisical Cloud's **Pro** tier or + via enterprise license on self-hosted instances of Infisical. On this front, + we support industry-leading providers including Okta, Azure AD, and JumpCloud; + with any questions, please reach out to team@infisical.com. You can configure your organization in Infisical to have members authenticate with the platform via protocols like [SAML 2.0](https://en.wikipedia.org/wiki/SAML_2.0). @@ -31,3 +32,19 @@ Infisical supports these and many other identity providers: - [Google SAML](/documentation/platform/sso/google-saml) If your required identity provider is not shown in the list above, please reach out to [team@infisical.com](mailto:team@infisical.com) for assistance. + +## FAQ + + + + By default, Infisical Cloud is configured to not trust emails from external + identity providers to prevent any malicious account takeover attempts via + email spoofing. Accordingly, Infisical creates a new user for anyone provisioned + through an external identity provider and requires an additional email + verification step upon their first login. + + If you're running a self-hosted instance of Infisical and would like it to trust emails from external identity providers, + you can configure this behavior in the admin panel. + + + diff --git a/docs/documentation/platform/token.mdx b/docs/documentation/platform/token.mdx index 78445f4f1..13bd4cd19 100644 --- a/docs/documentation/platform/token.mdx +++ b/docs/documentation/platform/token.mdx @@ -3,6 +3,13 @@ title: "Service Token" description: "Infisical service tokens allow users to programmatically interact with Infisical." --- + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). + +They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + + Service tokens are authentication credentials that services can use to access designated endpoints in the Infisical API to manage project resources like secrets. Each service token can be provisioned scoped access to select environment(s) and path(s) within them. @@ -17,8 +24,8 @@ Service Token (ST) is the current widely-used authentication method for managing Here's a few pointers to get you acquainted with it: - When you create a ST, you get a token prefixed with `st`. The part after the last `.` delimiter is a symmetric key; everything -before it is an access token. When authenticating with the Infisical API, it is important to send in only the access token portion -of the token. + before it is an access token. When authenticating with the Infisical API, it is important to send in only the access token portion + of the token. - ST supports expiration; it gets deleted automatically upon expiration. - ST supports provisioning `read` and/or `write` permissions broadly applied to all accessible environment(s) and path(s). - ST is not editable. @@ -35,7 +42,7 @@ the token access to. Here's some guidance for each field: - Name: A friendly name for the token. - Scopes: The environment(s) and path(s) the token should have access to. - Permissions: You can indicate whether or not the token should have `read/write` access to the paths. -Also, note that Infisical supports [glob patterns](https://www.malikbrowne.com/blog/a-beginners-guide-glob-patterns/) when defining access scopes to path(s). + Also, note that Infisical supports [glob patterns](https://www.malikbrowne.com/blog/a-beginners-guide-glob-patterns/) when defining access scopes to path(s). - Expiration: The time when this token should be rendered inactive. ![token add](../../images/project-token-old-permissions.png) @@ -44,28 +51,31 @@ In the above screenshot, you can see that we are creating a token token with `re of the `/common` path within the development environment of the project; the token expires in 6 months and can be used from any IP address. -For a deeper understanding of service tokens, it is recommended to read [this guide](https://infisical.com/docs/internals/service-tokens). + For a deeper understanding of service tokens, it is recommended to read [this + guide](https://infisical.com/docs/internals/service-tokens). **FAQ** - - There are a few reasons for why this might happen: + + There are a few reasons for why this might happen: - - The service token has expired. - - The service token is insufficiently permissioned to interact with the secrets in the given environment and path. - - You are attempting to access a `/raw` secrets endpoint that requires your project to disable E2EE. - - (If using ST V3) The service token has not been activated yet. - - (If using ST V3) The service token is being used from an untrusted IP. - - - 1. `/**`: This pattern matches all folders at any depth in the directory structure. For example, it would match folders like `/folder1/`, `/folder1/subfolder/`, and so on. + - The service token has expired. + - The service token is insufficiently permissioned to interact with the secrets in the given environment and path. + - You are attempting to access a `/raw` secrets endpoint that requires your project to disable E2EE. + - (If using ST V3) The service token has not been activated yet. + - (If using ST V3) The service token is being used from an untrusted IP. - 2. `/*`: This pattern matches all immediate subfolders in the current directory. It does not match any folders at a deeper level. For example, it would match folders like `/folder1/`, `/folder2/`, but not `/folder1/subfolder/`. + + + 1. `/**`: This pattern matches all folders at any depth in the directory structure. For example, it would match folders like `/folder1/`, `/folder1/subfolder/`, and so on. - 3. `/*/*`: This pattern matches all subfolders at a depth of two levels in the current directory. It does not match any folders at a shallower or deeper level. For example, it would match folders like `/folder1/subfolder/`, `/folder2/subfolder/`, but not `/folder1/` or `/folder1/subfolder/subsubfolder/`. + 2. `/*`: This pattern matches all immediate subfolders in the current directory. It does not match any folders at a deeper level. For example, it would match folders like `/folder1/`, `/folder2/`, but not `/folder1/subfolder/`. - 4. `/folder1/*`: This pattern matches all immediate subfolders within the `/folder1/` directory. It does not match any folders outside of `/folder1/`, nor does it match any subfolders within those immediate subfolders. For example, it would match folders like `/folder1/subfolder1/`, `/folder1/subfolder2/`, but not `/folder2/subfolder/`. - + 3. `/*/*`: This pattern matches all subfolders at a depth of two levels in the current directory. It does not match any folders at a shallower or deeper level. For example, it would match folders like `/folder1/subfolder/`, `/folder2/subfolder/`, but not `/folder1/` or `/folder1/subfolder/subsubfolder/`. + + 4. `/folder1/*`: This pattern matches all immediate subfolders within the `/folder1/` directory. It does not match any folders outside of `/folder1/`, nor does it match any subfolders within those immediate subfolders. For example, it would match folders like `/folder1/subfolder1/`, `/folder1/subfolder2/`, but not `/folder2/subfolder/`. + + diff --git a/docs/images/integrations/aws/integrations-amplify-env-console-identity.png b/docs/images/integrations/aws/integrations-amplify-env-console-identity.png new file mode 100644 index 000000000..f3e975ad2 Binary files /dev/null and b/docs/images/integrations/aws/integrations-amplify-env-console-identity.png differ diff --git a/docs/images/integrations/aws/integrations-aws-secret-manager-create.png b/docs/images/integrations/aws/integrations-aws-secret-manager-create.png index 21f2213ef..e43cfbf9e 100644 Binary files a/docs/images/integrations/aws/integrations-aws-secret-manager-create.png and b/docs/images/integrations/aws/integrations-aws-secret-manager-create.png differ diff --git a/docs/images/integrations/jenkins/jenkins_10_identity.png b/docs/images/integrations/jenkins/jenkins_10_identity.png new file mode 100644 index 000000000..e2a578860 Binary files /dev/null and b/docs/images/integrations/jenkins/jenkins_10_identity.png differ diff --git a/docs/images/integrations/jenkins/jenkins_11_identity.png b/docs/images/integrations/jenkins/jenkins_11_identity.png new file mode 100644 index 000000000..34325277a Binary files /dev/null and b/docs/images/integrations/jenkins/jenkins_11_identity.png differ diff --git a/docs/images/integrations/jenkins/jenkins_4_identity_id.png b/docs/images/integrations/jenkins/jenkins_4_identity_id.png new file mode 100644 index 000000000..b7f4eb116 Binary files /dev/null and b/docs/images/integrations/jenkins/jenkins_4_identity_id.png differ diff --git a/docs/images/integrations/jenkins/jenkins_4_identity_secret.png b/docs/images/integrations/jenkins/jenkins_4_identity_secret.png new file mode 100644 index 000000000..fe6acea77 Binary files /dev/null and b/docs/images/integrations/jenkins/jenkins_4_identity_secret.png differ diff --git a/docs/images/integrations/jenkins/jenkins_5_identity.png b/docs/images/integrations/jenkins/jenkins_5_identity.png new file mode 100644 index 000000000..22eb83adc Binary files /dev/null and b/docs/images/integrations/jenkins/jenkins_5_identity.png differ diff --git a/docs/images/integrations/jenkins/jenkins_9_identity.png b/docs/images/integrations/jenkins/jenkins_9_identity.png new file mode 100644 index 000000000..b3cbdfc76 Binary files /dev/null and b/docs/images/integrations/jenkins/jenkins_9_identity.png differ diff --git a/docs/images/integrations/jenkins/plugin/add-infisical-secret.png b/docs/images/integrations/jenkins/plugin/add-infisical-secret.png new file mode 100644 index 000000000..6a9bc56a1 Binary files /dev/null and b/docs/images/integrations/jenkins/plugin/add-infisical-secret.png differ diff --git a/docs/images/integrations/jenkins/plugin/install-plugin.png b/docs/images/integrations/jenkins/plugin/install-plugin.png new file mode 100644 index 000000000..c08e618bd Binary files /dev/null and b/docs/images/integrations/jenkins/plugin/install-plugin.png differ diff --git a/docs/images/integrations/jenkins/plugin/pipeline-configuration.png b/docs/images/integrations/jenkins/plugin/pipeline-configuration.png new file mode 100644 index 000000000..9880164ea Binary files /dev/null and b/docs/images/integrations/jenkins/plugin/pipeline-configuration.png differ diff --git a/docs/images/integrations/jenkins/plugin/pipeline-syntax-highlight.png b/docs/images/integrations/jenkins/plugin/pipeline-syntax-highlight.png new file mode 100644 index 000000000..a38d64be8 Binary files /dev/null and b/docs/images/integrations/jenkins/plugin/pipeline-syntax-highlight.png differ diff --git a/docs/images/integrations/jenkins/plugin/plugin-checked.png b/docs/images/integrations/jenkins/plugin/plugin-checked.png new file mode 100644 index 000000000..1fd92127f Binary files /dev/null and b/docs/images/integrations/jenkins/plugin/plugin-checked.png differ diff --git a/docs/images/integrations/jenkins/plugin/universal-auth-credential.png b/docs/images/integrations/jenkins/plugin/universal-auth-credential.png new file mode 100644 index 000000000..26c2bc307 Binary files /dev/null and b/docs/images/integrations/jenkins/plugin/universal-auth-credential.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck-auth.png b/docs/images/integrations/rundeck/integrations-rundeck-auth.png new file mode 100644 index 000000000..8ffa69365 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck-auth.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck-create.png b/docs/images/integrations/rundeck/integrations-rundeck-create.png new file mode 100644 index 000000000..691c346f9 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck-create.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck-token.png b/docs/images/integrations/rundeck/integrations-rundeck-token.png new file mode 100644 index 000000000..70ae704d1 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck-token.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck.png b/docs/images/integrations/rundeck/integrations-rundeck.png new file mode 100644 index 000000000..170e77a3f Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck.png differ diff --git a/docs/images/platform/audit-log-streams/betterstack-create-source.png b/docs/images/platform/audit-log-streams/betterstack-create-source.png new file mode 100644 index 000000000..bee4513ea Binary files /dev/null and b/docs/images/platform/audit-log-streams/betterstack-create-source.png differ diff --git a/docs/images/platform/audit-log-streams/betterstack-source-details.png b/docs/images/platform/audit-log-streams/betterstack-source-details.png new file mode 100644 index 000000000..d67980ae8 Binary files /dev/null and b/docs/images/platform/audit-log-streams/betterstack-source-details.png differ diff --git a/docs/images/platform/audit-log-streams/data-create-api-key.png b/docs/images/platform/audit-log-streams/data-create-api-key.png new file mode 100644 index 000000000..d25a2c64e Binary files /dev/null and b/docs/images/platform/audit-log-streams/data-create-api-key.png differ diff --git a/docs/images/platform/audit-log-streams/data-dog-api-key.png b/docs/images/platform/audit-log-streams/data-dog-api-key.png new file mode 100644 index 000000000..8e49e89e7 Binary files /dev/null and b/docs/images/platform/audit-log-streams/data-dog-api-key.png differ diff --git a/docs/images/platform/audit-log-streams/datadog-api-sidebar.png b/docs/images/platform/audit-log-streams/datadog-api-sidebar.png new file mode 100644 index 000000000..d95cb9b2d Binary files /dev/null and b/docs/images/platform/audit-log-streams/datadog-api-sidebar.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 new file mode 100644 index 000000000..7960b1145 Binary files /dev/null and b/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png 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 new file mode 100644 index 000000000..5ae25b0b3 Binary files /dev/null and b/docs/images/platform/audit-log-streams/datadog-source-details.png differ diff --git a/docs/images/platform/audit-log-streams/stream-create.png b/docs/images/platform/audit-log-streams/stream-create.png new file mode 100644 index 000000000..949278e3d Binary files /dev/null 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 new file mode 100644 index 000000000..6b9d7c57b Binary files /dev/null and b/docs/images/platform/audit-log-streams/stream-inputs.png differ diff --git a/docs/images/platform/audit-log-streams/stream-list.png b/docs/images/platform/audit-log-streams/stream-list.png new file mode 100644 index 000000000..c5cc5598b Binary files /dev/null and b/docs/images/platform/audit-log-streams/stream-list.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png new file mode 100644 index 000000000..3ae9155a3 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png new file mode 100644 index 000000000..d412109fa Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png differ diff --git a/docs/images/platform/dynamic-secrets/lease-values-aws-iam.png b/docs/images/platform/dynamic-secrets/lease-values-aws-iam.png new file mode 100644 index 000000000..4764eceb2 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/lease-values-aws-iam.png differ diff --git a/docs/images/platform/identities/identities-org-create-aws-auth-method.png b/docs/images/platform/identities/identities-org-create-aws-auth-method.png new file mode 100644 index 000000000..4b902c048 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-aws-auth-method.png differ diff --git a/docs/images/platform/identities/identities-org-create-azure-auth-method.png b/docs/images/platform/identities/identities-org-create-azure-auth-method.png new file mode 100644 index 000000000..fc0fd1665 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-azure-auth-method.png differ diff --git a/docs/images/platform/identities/identities-org-create-gcp-gce-auth-method.png b/docs/images/platform/identities/identities-org-create-gcp-gce-auth-method.png new file mode 100644 index 000000000..899130c42 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-gcp-gce-auth-method.png differ diff --git a/docs/images/platform/identities/identities-org-create-gcp-iam-auth-method.png b/docs/images/platform/identities/identities-org-create-gcp-iam-auth-method.png new file mode 100644 index 000000000..9dacf9f89 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-gcp-iam-auth-method.png differ diff --git a/docs/images/platform/identities/identities-org-create-kubernetes-auth-method.png b/docs/images/platform/identities/identities-org-create-kubernetes-auth-method.png new file mode 100644 index 000000000..0c2fe072d Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-kubernetes-auth-method.png differ diff --git a/docs/images/platform/secret-sharing/copy-url.png b/docs/images/platform/secret-sharing/copy-url.png new file mode 100644 index 000000000..89d86ede4 Binary files /dev/null and b/docs/images/platform/secret-sharing/copy-url.png differ diff --git a/docs/images/platform/secret-sharing/create-new-secret.png b/docs/images/platform/secret-sharing/create-new-secret.png new file mode 100644 index 000000000..335fca2b2 Binary files /dev/null and b/docs/images/platform/secret-sharing/create-new-secret.png differ diff --git a/docs/images/platform/secret-sharing/overview.png b/docs/images/platform/secret-sharing/overview.png new file mode 100644 index 000000000..428110517 Binary files /dev/null and b/docs/images/platform/secret-sharing/overview.png differ diff --git a/docs/images/platform/secret-sharing/public-view.png b/docs/images/platform/secret-sharing/public-view.png new file mode 100644 index 000000000..8b4077c65 Binary files /dev/null and b/docs/images/platform/secret-sharing/public-view.png differ diff --git a/docs/images/self-hosting/deployment-options/docker-swarm/ha-proxy-ha.png b/docs/images/self-hosting/deployment-options/docker-swarm/ha-proxy-ha.png new file mode 100644 index 000000000..bfd2bb520 Binary files /dev/null and b/docs/images/self-hosting/deployment-options/docker-swarm/ha-proxy-ha.png differ diff --git a/docs/integrations/cicd/githubactions.mdx b/docs/integrations/cicd/githubactions.mdx index 10caabc2f..936c8974a 100644 --- a/docs/integrations/cicd/githubactions.mdx +++ b/docs/integrations/cicd/githubactions.mdx @@ -3,9 +3,15 @@ title: "GitHub Actions" description: "How to sync secrets from Infisical to GitHub Actions" --- + + Alternatively, you can use Infisical's official Github Action + [here](https://github.com/Infisical/secrets-action). + + Infisical lets you sync secrets to GitHub at the organization-level, repository-level, and repository environment-level. Prerequisites: + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - Ensure that you have admin privileges to the repository you want to sync secrets to. diff --git a/docs/integrations/cicd/jenkins.mdx b/docs/integrations/cicd/jenkins.mdx index 9184aef44..a83d90700 100644 --- a/docs/integrations/cicd/jenkins.mdx +++ b/docs/integrations/cicd/jenkins.mdx @@ -1,139 +1,273 @@ --- -title: "Jenkins" +title: "Jenkins Plugin" description: "How to effectively and securely manage secrets in Jenkins using Infisical" --- **Objective**: Fetch secrets from Infisical to Jenkins pipelines In this guide, we'll outline the steps to deliver secrets from Infisical to Jenkins via the Infisical CLI. -At a high level, the Infisical CLI will be executed within your build environment and use a service token to authenticate with Infisical. +At a high level, the Infisical CLI will be executed within your build environment and use a machine identity to authenticate with Infisical. This token must be added as a Jenkins Credential and then passed to the Infisical CLI as an environment variable, enabling it to access and retrieve secrets within your workflows. Prerequisites: - Set up and add secrets to [Infisical](https://app.infisical.com). +- Create a [machine identity](/documentation/platform/identities/machine-identities) (Recommended), or a service token in Infisical. - You have a working Jenkins installation with the [credentials plugin](https://plugins.jenkins.io/credentials/) installed. - You have the [Infisical CLI](/cli/overview) installed on your Jenkins executor nodes or container images. + -## Add Infisical Service Token to Jenkins + + + ## Jenkins Infisical Plugin -After setting up your project in Infisical and installing the Infisical CLI to the environment where your Jenkins builds will run, you will need to add the Infisical Service Token to Jenkins. + This plugin adds a build wrapper to set environment variables from [Infisical](https://infisical.com). Secrets are generally masked in the build log, so you can't accidentally print them. -To generate a Infisical service token, follow the guide [here](/documentation/platform/token). -Once you have generated the token, navigate to **Manage Jenkins > Manage Credentials** in your Jenkins instance. + ## Installation -![Jenkins step 1](../../images/integrations/jenkins/jenkins_1.png) + To install the plugin, navigate to `Manage Jenkins -> Plugins -> Available plugins` and search for `Infisical`. Install the plugin and restart Jenkins. -Click on the credential store you want to store the Infisical Service Token in. In this case, we're using the default Jenkins global store. + ![Install Plugin](../../images/integrations/jenkins/plugin/install-plugin.png) - - Each of your projects will have a different `INFISICAL_TOKEN`. - As a result, it may make sense to spread these out into separate credential domains depending on your use case. - + ## Infisical Authentication -![Jenkins step 2](../../images/integrations/jenkins/jenkins_2.png) + Authenticating with Infisical is done through the use of [Machine Identities](https://infisical.com/docs/documentation/platform/identities/machine-identities). + Currently the Jenkins plugin only supports [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) for authentication. More methods will be added soon. -Now, click Add Credentials. - -![Jenkins step 3](../../images/integrations/jenkins/jenkins_3.png) - -Choose **Secret text** for the **Kind** option from the dropdown list and enter the Infisical Service Token in the **Secret** field. -Although the **ID** can be any value, we'll set it to `infisical-service-token` for the sake of this guide. -The description is optional and can be any text you prefer. + ### How does Universal Auth work? + To use Universal Auth, you'll need to create a new Credential _(Infisical Universal Auth Credential)_. The credential should contain your Universal Auth client ID, and your Universal Auth client secret. + Please [read more here](https://infisical.com/docs/documentation/platform/identities/universal-auth) on how to setup a Machine Identity to use universal auth. -![Jenkins step 4](../../images/integrations/jenkins/jenkins_4.png) + ### Creating a Universal Auth credential -When you're done, you should see a credential similar to the one below: + Creating a universal auth credential inside Jenkins is very straight forward. -![Jenkins step 5](../../images/integrations/jenkins/jenkins_5.png) + Simply navigate to
+ `Dashboard -> Manage Jenkins -> Credentials -> System -> Global credentials (unrestricted)`. + Press the `Add Credentials` button and select `Infisical Universal Auth Credential` in the `Kind` field. -## Use Infisical in a Freestyle Project + The `ID` and `Description` field doesn't matter much in this case, as they won't be read anywhere. The description field will be displayed as the credential name during the plugin configuration. -To fetch secrets with Infisical in a Freestyle Project job, you'll need to expose the credential you created above as an environment variable to the Infisical CLI. -To do so, first click **New Item** from the dashboard navigation sidebar: - -![Jenkins step 6](../../images/integrations/jenkins/jenkins_6.png) - -Enter the name of the job, choose the **Freestyle Project** option, and click **OK**. - -![Jenkins step 7](../../images/integrations/jenkins/jenkins_7.png) - -Scroll down to the **Build Environment** section and enable the **Use secret text(s) or file(s)** option. Then click **Add** under the **Bindings** section and choose **Secret text** from the dropdown menu. - -![Jenkins step 8](../../images/integrations/jenkins/jenkins_8.png) - -Enter `INFISICAL_TOKEN` in the **Variable** field then click the **Specific credentials** option from the Credentials section and select the credential you created earlier. -In this case, we saved it as `Infisical service token` so we'll choose that from the dropdown menu. - -![Jenkins step 9](../../images/integrations/jenkins/jenkins_9.png) - -Scroll down to the **Build** section and choose **Execute shell** from the **Add build step** menu. - -![Jenkins step 10](../../images/integrations/jenkins/jenkins_10.png) - -In the command field, you can now use the Infisical CLI to fetch secrets. -The example command below will print the secrets using the service token passed as a credential. When done, click **Save**. - -``` -infisical secrets --env=dev --path=/ -``` - -![Jenkins step 11](../../images/integrations/jenkins/jenkins_11.png) - -Finally, click **Build Now** from the navigation sidebar to run your new job. - - - Running into issues? Join Infisical's [community Slack](https://infisical.com/slack) for quick support. - + ![Infisical Universal Auth Credential](../../images/integrations/jenkins/plugin/universal-auth-credential.png) -## Use Infisical in a Jenkins Pipeline + ## Plugin Usage + ### Configuration -To fetch secrets using Infisical in a Pipeline job, you'll need to expose the Jenkins credential you created above as an environment variable. -To do so, click **New Item** from the dashboard navigation sidebar: + Configuration takes place on a job-level basis. -![Jenkins step 6](../../images/integrations/jenkins/jenkins_6.png) + Inside your job, you simply tick the `Infisical Plugin` checkbox under "Build Environment". After enabling the plugin, you'll see a new section appear where you'll have to configure the plugin. -Enter the name of the job, choose the **Pipeline** option, and click OK. + ![Plugin enabled](../../images/integrations/jenkins/plugin/plugin-checked.png) -![Jenkins step 12](../../images/integrations/jenkins/jenkins_12.png) + You'll be prompted with 4 options to fill: + * Infisical URL + * This defaults to https://app.infisical.com. This field is only relevant if you're running a managed or self-hosted instance. If you are using Infisical Cloud, leave this as-is, otherwise enter the URL of your Infisical instance. + * Infisical Credential + * This is where you select your Infisical credential to use for authentication. In the step above [Creating a Universal Auth credential](#creating-a-universal-auth-credential), you can read on how to configure the credential. Simply select the credential you have created for this field. + * Infisical Project Slug + * This is the slug of the project you wish to fetch secrets from. You can find this in your project settings on Infisical by clicking "Copy project slug". + * Environment Slug + * This is the slug of the environment to fetch secrets from. In most cases it's either `dev`, `staging`, or `prod`. You can however create custom environments in Infisical. If you are using custom environments, you need to enter the slug of the custom environment you wish to fetch secrets from. + + That's it! Now you're ready to select which secrets you want to fetch into Jenkins. + By clicking the `Add an Infisical secret` in the Jenkins UI like seen in the screenshot below. -Scroll down to the **Pipeline** section, paste the following into the **Script** field, and click **Save**. + ![Add Infisical secret](../../images/integrations/jenkins/plugin/add-infisical-secret.png) -``` -pipeline { - agent any + You need to select which secrets that should be pulled into Jenkins. + You start by specifying a [folder path from Infisical](https://infisical.com/docs/documentation/platform/folder#comparing-folders). The root path is simply `/`. You also need to select wether or not you want to [include imports](https://infisical.com/docs/documentation/platform/secret-reference#secret-imports). Now you can add secrets the secret keys that you want to pull from Infisical into Jenkins. If you want to add multiple secrets, press the "Add key/value pair". - environment { - INFISICAL_TOKEN = credentials('infisical-service-token') + If you wish to pull secrets from multiple paths, you can press the "Add an Infisical secret" button at the bottom, and configure a new set of secrets to pull. + + + ## Pipeline usage + + + ### Generating pipeline block + + Using the Infisical Plugin in a Jenkins pipeline is very straight forward. To generate a block to use the Infisical Plugin in a Pipeline, simply to go `{JENKINS_URL}/jenkins/job/{JOB_ID}/pipeline-syntax/`. + + You can find a direct link on the Pipeline configuration page in the very bottom of the page, see image below. + + ![Pipeline Syntax Highlight](../../images/integrations/jenkins/plugin/pipeline-syntax-highlight.png) + + On the Snippet Generator page, simply configure the Infisical Plugin like it's documented in the [Configuration documentation](#configuration) step. + + Once you have filled out the configuration, press `Generate Pipeline Script`, and it will generate a block you can use in your pipeline. + + ![Pipeline Configuration](../../images/integrations/jenkins/plugin/pipeline-configuration.png) + + ### Using Infisical in a Pipeline + + Using the generated block in a pipeline is very straight forward. There's a few approaches on how to implement the block in a Pipeline script. + Here's an example of using the generated block in a pipeline script. Make sure to replace the placeholder values with your own values. + + The script is formatted for clarity. All these fields will be pre-filled for you if you use the `Snippet Generator` like described in the [step above](#generating-pipeline-block). + ```groovy + node { + withInfisical( + configuration: [ + infisicalCredentialId: 'YOUR_CREDENTIAL_ID', + infisicalEnvironmentSlug: 'PROJECT_ENV_SLUG', + infisicalProjectSlug: 'PROJECT_SLUG', + infisicalUrl: 'https://app.infisical.com' // Change this to your Infisical instance URL if you aren't using Infisical Cloud. + ], + infisicalSecrets: [ + infisicalSecret( + includeImports: true, + path: '/', + secretValues: [ + [infisicalKey: 'DATABASE_URL'], + [infisicalKey: "API_URL"], + [infisicalKey: 'THIS_KEY_MIGHT_NOT_EXIST', isRequired: false], + ] + ) + ] + ) { + // Code runs here + sh "printenv" + } } + ``` - stages { - stage('Run Infisical') { - steps { - sh("infisical secrets --env=dev --path=/") - // doesn't work - // sh("docker run --rm test-container infisical secrets") +
- // works - // sh("docker run -e INFISICAL_TOKEN=${INFISICAL_TOKEN} --rm test-container infisical secrets --env=dev --path=/") + + ## Add Infisical Service Token to Jenkins - // doesn't work - // sh("docker-compose up -d") + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). + They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). - // works - // sh("INFISICAL_TOKEN=${INFISICAL_TOKEN} docker-compose up -d") + **Please use our Jenkins Plugin instead!** + + + After setting up your project in Infisical and installing the Infisical CLI to the environment where your Jenkins builds will run, you will need to add the Infisical Service Token to Jenkins. + + To generate a Infisical service token, follow the guide [here](/documentation/platform/token). + Once you have generated the token, navigate to **Manage Jenkins > Manage Credentials** in your Jenkins instance. + + ![Jenkins step 1](../../images/integrations/jenkins/jenkins_1.png) + + Click on the credential store you want to store the Infisical Service Token in. In this case, we're using the default Jenkins global store. + + + Each of your projects will have a different `INFISICAL_TOKEN`. + As a result, it may make sense to spread these out into separate credential domains depending on your use case. + + + ![Jenkins step 2](../../images/integrations/jenkins/jenkins_2.png) + + Now, click Add Credentials. + + ![Jenkins step 3](../../images/integrations/jenkins/jenkins_3.png) + + Choose **Secret text** for the **Kind** option from the dropdown list and enter the Infisical Service Token in the **Secret** field. + Although the **ID** can be any value, we'll set it to `infisical-service-token` for the sake of this guide. + The description is optional and can be any text you prefer. + + + ![Jenkins step 4](../../images/integrations/jenkins/jenkins_4.png) + + When you're done, you should see a credential similar to the one below: + + ![Jenkins step 5](../../images/integrations/jenkins/jenkins_5.png) + + + ## Use Infisical in a Freestyle Project + + To fetch secrets with Infisical in a Freestyle Project job, you'll need to expose the credential you created above as an environment variable to the Infisical CLI. + To do so, first click **New Item** from the dashboard navigation sidebar: + + ![Jenkins step 6](../../images/integrations/jenkins/jenkins_6.png) + + Enter the name of the job, choose the **Freestyle Project** option, and click **OK**. + + ![Jenkins step 7](../../images/integrations/jenkins/jenkins_7.png) + + Scroll down to the **Build Environment** section and enable the **Use secret text(s) or file(s)** option. Then click **Add** under the **Bindings** section and choose **Secret text** from the dropdown menu. + + ![Jenkins step 8](../../images/integrations/jenkins/jenkins_8.png) + + Enter `INFISICAL_TOKEN` in the **Variable** field then click the **Specific credentials** option from the Credentials section and select the credential you created earlier. + In this case, we saved it as `Infisical service token` so we'll choose that from the dropdown menu. + + ![Jenkins step 9](../../images/integrations/jenkins/jenkins_9.png) + + Scroll down to the **Build** section and choose **Execute shell** from the **Add build step** menu. + + ![Jenkins step 10](../../images/integrations/jenkins/jenkins_10.png) + + In the command field, you can now use the Infisical CLI to fetch secrets. + The example command below will print the secrets using the service token passed as a credential. When done, click **Save**. + + ``` + infisical secrets --env=dev --path=/ + ``` + + ![Jenkins step 11](../../images/integrations/jenkins/jenkins_11.png) + + Finally, click **Build Now** from the navigation sidebar to run your new job. + + + Running into issues? Join Infisical's [community Slack](https://infisical.com/slack) for quick support. + + + + + ## Use Infisical in a Jenkins Pipeline + + To fetch secrets using Infisical in a Pipeline job, you'll need to expose the Jenkins credential you created above as an environment variable. + To do so, click **New Item** from the dashboard navigation sidebar: + + ![Jenkins step 6](../../images/integrations/jenkins/jenkins_6.png) + + Enter the name of the job, choose the **Pipeline** option, and click OK. + + ![Jenkins step 12](../../images/integrations/jenkins/jenkins_12.png) + + Scroll down to the **Pipeline** section, paste the following into the **Script** field, and click **Save**. + + ``` + pipeline { + agent any + + environment { + INFISICAL_TOKEN = credentials('infisical-service-token') + } + + stages { + stage('Run Infisical') { + steps { + sh("infisical secrets --env=dev --path=/") + + // doesn't work + // sh("docker run --rm test-container infisical secrets") + + // works + // sh("docker run -e INFISICAL_TOKEN=${INFISICAL_TOKEN} --rm test-container infisical secrets --env=dev --path=/") + + // doesn't work + // sh("docker-compose up -d") + + // works + // sh("INFISICAL_TOKEN=${INFISICAL_TOKEN} docker-compose up -d") + } } } } -} -``` + ``` + + + +
The example provided above serves as an initial guide. It shows how Jenkins adds the `INFISICAL_TOKEN` environment variable, which is configured in the pipeline, into the shell for executing commands. -There may be instances where this doesn't work as expected in the context of running Docker commands. +There may be instances where this doesn't work as expected in the context of running Docker commands. However, the list of working examples should provide some insight into how this can be handled properly. diff --git a/docs/integrations/cicd/rundeck.mdx b/docs/integrations/cicd/rundeck.mdx new file mode 100644 index 000000000..a0743fd01 --- /dev/null +++ b/docs/integrations/cicd/rundeck.mdx @@ -0,0 +1,39 @@ +--- +title: "Rundeck" +description: "How to sync secrets from Infisical to Rundeck" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + + Obtain a User API Token in the Profile settings of Rundeck + + ![integrations rundeck token](../../images/integrations/rundeck/integrations-rundeck-token.png) + + Navigate to your project's integrations tab in Infisical. + + ![integrations](../../images/integrations.png) + + Press on the Rundeck tile and input your Rundeck instance Base URL and User API token to grant Infisical access to manage Rundeck keys + + ![integrations rundeck authorization](../../images/integrations/rundeck/integrations-rundeck-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + + + + Select which Infisical environment secrets you want to sync to a Rundeck Key Storage Path and press create integration to start syncing secrets to Rundeck. + + ![create integration rundeck](../../images/integrations/rundeck/integrations-rundeck-create.png) + ![integrations rundeck](../../images/integrations/rundeck/integrations-rundeck.png) + + + diff --git a/docs/integrations/cloud/aws-amplify.mdx b/docs/integrations/cloud/aws-amplify.mdx index 825c6c356..761971025 100644 --- a/docs/integrations/cloud/aws-amplify.mdx +++ b/docs/integrations/cloud/aws-amplify.mdx @@ -4,73 +4,134 @@ description: "Learn how to sync secrets from Infisical to AWS Amplify." --- Prerequisites: + - Infisical Cloud account - Add the secrets you wish to sync to Amplify to [Infisical Cloud](https://app.infisical.com) -There are many approaches to sync secrets stored within Infisical to AWS Amplify. This guide describes two such approaches below. +There are many approaches to sync secrets stored within Infisical to AWS Amplify. This guide describes two such approaches below. ## Access Infisical secrets at Amplify build time -This approach enables you to fetch secrets from Infisical during Amplify build time. +This approach enables you to fetch secrets from Infisical during Amplify build time. - - - Go to your project settings in the Infisical dashboard to generate a [service token](/documentation/platform/token). This service token will allow you to authenticate and fetch secrets from Infisical. Once you have created a service token with the required permissions, you’ll need to provide the token to the CLI installed in your Docker container. - - - ![aws amplify env console](../../images/integrations/aws/integrations-amplify-env-console.png) - 1. In the Amplify console, choose App Settings, and then select Environment variables. - 2. In the Environment variables section, select Manage variables. - 3. Under Variable, enter the key **INFISICAL_TOKEN**. For the value, enter the generated service token from the previous step. - 4. Click save. - - - In the prebuild phase, add the command in AWS Amplify to install the Infisical CLI. + - ```yaml - build: - phases: - preBuild: - commands: - - sudo curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.rpm.sh' | sudo -E bash - - sudo yum -y install infisical - ``` - - - You can now pull secrets from Infisical using the CLI and save them as a `.env` file. To do this, modify the build commands. + + + + Create a machine identtiy and connect it to your Infisical project. You can read more about how to use machine identities [here](/documentation/platform/identities/machine-identities). The machine identity will allow you to authenticate and fetch secrets from Infisical. + - ```yaml - build: - phases: + + ![aws amplify env console](../../images/integrations/aws/integrations-amplify-env-console-identity.png) + 1. In the Amplify console, choose App Settings, and then select Environment variables. + 2. In the Environment variables section, select Manage variables. + 3. Under the first Variable enter `INFISICAL_MACHINE_IDENTITY_CLIENT_ID`, and for the value, enter the client ID of the machine identity you created in the previous step. + 4. Under the second Variable enter `INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET`, and for the value, enter the client secret of the machine identity you created in the previous step. + 5. Click save. + + + + In the prebuild phase, add the command in AWS Amplify to install the Infisical CLI. + + ```yaml build: - commands: - - INFISICAL_TOKEN=${INFISICAL_TOKEN} - - infisical export --format=dotenv > .env - - - ``` - - + phases: + preBuild: + commands: + - sudo curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.rpm.sh' | sudo -E bash + - sudo yum -y install infisical + ``` + -## Sync Secrets Using AWS SSM Parameter Store + + You can now pull secrets from Infisical using the CLI and save them as a `.env` file. To do this, modify the build commands. -Another approach to use secrets from Infisical in AWS Amplify is to utilize AWS Parameter Store. -At high level, you begin by using Infisical's AWS SSM Parameter Store integration to sync secrets from Infisical to AWS SSM Parameter Store. You then instruct AWS Amplify to consume those secrets from AWS SSM Parameter Store as [environment secrets](https://docs.aws.amazon.com/amplify/latest/userguide/environment-variables.html#environment-secrets). + ```yaml + build: + phases: + build: + commands: + - INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id=${INFISICAL_MACHINE_IDENTITY_CLIENT_ID} --client-secret=${INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET} --silent --plain) + - infisical export --format=dotenv > .env + - + ``` + + - - - Follow the [Infisical AWS SSM Parameter Store Integration Guide](./aws-parameter-store) to set up the integration. Pause once you reach the step where it asks you to select the path you would like to sync. - - - ![amplify app id](../../images/integrations/aws/integrations-amplify-app-id.png) - 1. Open your AWS Amplify App console. - 2. Go to **Actions >> View App Settings** - 3. The App ID will be the last part of the App ARN field after the slash. - - - You need to set the path in the format `/amplify/[amplify_app_id]/[your-amplify-environment-name]` as the path option in AWS SSM Parameter Infisical Integration. - - + + + + + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). + + They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + + + + Go to your project settings in the Infisical dashboard to generate a [service token](/documentation/platform/token). This service token will allow you to authenticate and fetch secrets from Infisical. Once you have created a service token with the required permissions, you’ll need to provide the token to the CLI installed in your Docker container. + + + ![aws amplify env console](../../images/integrations/aws/integrations-amplify-env-console.png) + 1. In the Amplify console, choose App Settings, and then select Environment variables. + 2. In the Environment variables section, select Manage variables. + 3. Under Variable, enter the key **INFISICAL_TOKEN**. For the value, enter the generated service token from the previous step. + 4. Click save. + + + In the prebuild phase, add the command in AWS Amplify to install the Infisical CLI. + + ```yaml + build: + phases: + preBuild: + commands: + - sudo curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.rpm.sh' | sudo -E bash + - sudo yum -y install infisical + ``` + + + You can now pull secrets from Infisical using the CLI and save them as a `.env` file. To do this, modify the build commands. + + ```yaml + build: + phases: + build: + commands: + - INFISICAL_TOKEN=${INFISICAL_TOKEN} + - infisical export --format=dotenv > .env + - + ``` + + + + ## Sync Secrets Using AWS SSM Parameter Store + + Another approach to use secrets from Infisical in AWS Amplify is to utilize AWS Parameter Store. + At high level, you begin by using Infisical's AWS SSM Parameter Store integration to sync secrets from Infisical to AWS SSM Parameter Store. You then instruct AWS Amplify to consume those secrets from AWS SSM Parameter Store as [environment secrets](https://docs.aws.amazon.com/amplify/latest/userguide/environment-variables.html#environment-secrets). + + + + Follow the [Infisical AWS SSM Parameter Store Integration Guide](./aws-parameter-store) to set up the integration. Pause once you reach the step where it asks you to select the path you would like to sync. + + + ![amplify app id](../../images/integrations/aws/integrations-amplify-app-id.png) + 1. Open your AWS Amplify App console. + 2. Go to **Actions >> View App Settings** + 3. The App ID will be the last part of the App ARN field after the slash. + + + You need to set the path in the format `/amplify/[amplify_app_id]/[your-amplify-environment-name]` as the path option in AWS SSM Parameter Infisical Integration. + + + + + - Accessing an environment secret during a build is similar to accessing environment variables, except that environment secrets are stored in `process.env.secrets` as a JSON string. + Accessing an environment secret during a build is similar to accessing + environment variables, except that environment secrets are stored in + `process.env.secrets` as a JSON string. diff --git a/docs/integrations/cloud/aws-parameter-store.mdx b/docs/integrations/cloud/aws-parameter-store.mdx index fdad8b638..d53c557fb 100644 --- a/docs/integrations/cloud/aws-parameter-store.mdx +++ b/docs/integrations/cloud/aws-parameter-store.mdx @@ -28,6 +28,7 @@ Prerequisites: "Action": [ "ssm:PutParameter", "ssm:DeleteParameter", + "ssm:GetParameters", "ssm:GetParametersByPath", "ssm:DeleteParameters", "ssm:AddTagsToResource", // if you need to add tags to secrets diff --git a/docs/integrations/cloud/aws-secret-manager.mdx b/docs/integrations/cloud/aws-secret-manager.mdx index 2ab45c620..9b3a8a2f8 100644 --- a/docs/integrations/cloud/aws-secret-manager.mdx +++ b/docs/integrations/cloud/aws-secret-manager.mdx @@ -29,9 +29,13 @@ Prerequisites: "secretsmanager:GetSecretValue", "secretsmanager:CreateSecret", "secretsmanager:UpdateSecret", + "secretsmanager:DescribeSecret", // if you need to add tags to secrets "secretsmanager:TagResource", // if you need to add tags to secrets + "secretsmanager:UntagResource", // if you need to add tags to secrets "kms:ListKeys", // if you need to specify the KMS key - "kms:ListAliases" // if you need to specify the KMS key + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt" // if you need to specify the KMS key ], "Resource": "*" } @@ -68,6 +72,9 @@ Prerequisites: The region that you want to integrate with in AWS Secrets Manager. + + How you want the integration to map the secrets. The selected value could be either one to one or one to many. + The secret name/path in AWS into which you want to sync the secrets from Infisical. diff --git a/docs/integrations/cloud/gcp-secret-manager.mdx b/docs/integrations/cloud/gcp-secret-manager.mdx index 0f21a6a9d..99edcd115 100644 --- a/docs/integrations/cloud/gcp-secret-manager.mdx +++ b/docs/integrations/cloud/gcp-secret-manager.mdx @@ -51,6 +51,8 @@ description: "How to sync secrets from Infisical to GCP Secret Manager" Using Infisical to sync secrets to GCP Secret Manager requires that you enable the Service Usage API and Cloud Resource Manager API in the Google Cloud project you want to sync secrets to. More on that [here](https://cloud.google.com/service-usage/docs/set-up-development-environment). + + Additionally, ensure that your GCP account has sufficient permission to manage secret and service resources (you can assign Secret Manager Admin and Service Usage Admin roles for testing purposes)
@@ -115,6 +117,7 @@ description: "How to sync secrets from Infisical to GCP Secret Manager"
+ Using the GCP Secret Manager integration (via the OAuth2 method) on a self-hosted instance of Infisical requires configuring an OAuth2 application in GCP @@ -123,27 +126,27 @@ description: "How to sync secrets from Infisical to GCP Secret Manager" Navigate to your project API & Services > Credentials to create a new OAuth2 application. - - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-api-services.png) - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app.png) - + + ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-api-services.png) + ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app.png) + Create the application. As part of the form, add to **Authorized redirect URIs**: `https://your-domain.com/integrations/gcp-secret-manager/oauth2/callback`. - - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app-form.png) + + ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app-form.png) Obtain the **Client ID** and **Client Secret** for your GCP OAuth2 application. - - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-credentials.png) - + + ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-credentials.png) + Back in your Infisical instance, add two new environment variables for the credentials of your GCP OAuth2 application: - `CLIENT_ID_GCP_SECRET_MANAGER`: The **Client ID** of your GCP OAuth2 application. - `CLIENT_SECRET_GCP_SECRET_MANAGER`: The **Client Secret** of your GCP OAuth2 application. - + Once added, restart your Infisical instance and use the GCP Secret Manager integration. + - diff --git a/docs/integrations/frameworks/terraform.mdx b/docs/integrations/frameworks/terraform.mdx index 7d30ec0d9..dfacfbc57 100644 --- a/docs/integrations/frameworks/terraform.mdx +++ b/docs/integrations/frameworks/terraform.mdx @@ -34,7 +34,9 @@ Set up the Infisical provider by specifying the `host` and `service_token`. Repl ```hcl main.tf provider "infisical" { host = "https://app.infisical.com" # Only required if using self hosted instance of Infisical, default is https://app.infisical.com - service_token = "<>" # Get token https://infisical.com/docs/documentation/platform/token + client_id = "<>" + client_secret = "<>" + service_token = "<>" # DEPRECATED, USE MACHINE IDENTITY AUTH INSTEAD } ``` @@ -54,6 +56,7 @@ Use the `infisical_secrets` data source to fetch your secrets. In this block, yo data "infisical_secrets" "my-secrets" { env_slug = "dev" folder_path = "/some-folder/another-folder" + workspace_id = "your-project-id" } ``` diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 784f934ee..b29db8420 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -26,14 +26,14 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [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 | +| [Cloudflare Workers](/integrations/cloud/cloudflare-workers) | Cloud | Available | | [Checkly](/integrations/cloud/checkly) | Cloud | Available | -| [Qovery](/integrations/cloud/qovery) | 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 | +| [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 | +| [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 | @@ -41,6 +41,7 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [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 | | [React](/integrations/frameworks/react) | Framework | Available | | [Vue](/integrations/frameworks/vue) | Framework | Available | | [Express](/integrations/frameworks/express) | Framework | Available | diff --git a/docs/integrations/platforms/docker-compose.mdx b/docs/integrations/platforms/docker-compose.mdx index 1c061e04e..47715eb94 100644 --- a/docs/integrations/platforms/docker-compose.mdx +++ b/docs/integrations/platforms/docker-compose.mdx @@ -11,46 +11,109 @@ Prerequisites: Follow this [guide](./docker) to configure the Infisical CLI for each service that you wish to inject environment variables into; you'll have to update the Dockerfile of each service. -## Generate service token + + + ### Generate and configure machine identity + Generate a machine identity for each service you want to inject secrets into. You can do this by following the steps in the [Machine Identity](/documentation/platform/identities/machine-identities) guide. -Generate a unique [Infisical Token](/documentation/platform/token) for each service. + ### Set the machine identity client ID and client secret as environment variables + For each service you want to inject secrets into, set two environment variable called `INFISICAL_MACHINE_IDENTITY_CLIENT_ID`, and `INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET` equal to the client ID and client secret of the machine identity(s) you created in the previous step. -## Feed service token to your Docker Compose file + In the example below, we set two sets of client ID and client secret for the services. -For each service you want to inject secrets into, set an environment variable called `INFISICAL_TOKEN` equal to a unique identifier variable. + For the web service we set `INFISICAL_MACHINE_IDENTITY_CLIENT_ID_FOR_WEB` and `INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET_FOR_WEB` as the client ID and client secret respectively. -In the example below, we set `INFISICAL_TOKEN_FOR_WEB` and `INFISICAL_TOKEN_FOR_API` as the `INFISICAL_TOKEN` for the services. + For the API service we set `INFISICAL_MACHINE_IDENTITY_CLIENT_ID_FOR_API` and `INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET_FOR_API` as the client ID and client secret respectively. -```yaml -# Example Docker Compose file -services: - web: - build: . - image: example-service-1 - environment: - - INFISICAL_TOKEN=${INFISICAL_TOKEN_FOR_WEB} + ```yaml + # Example Docker Compose file + services: + web: + build: . + image: example-service-1 + environment: + - INFISICAL_MACHINE_IDENTITY_CLIENT_ID=${INFISICAL_MACHINE_IDENTITY_CLIENT_ID_FOR_WEB} + - INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET=${INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET_FOR_WEB} - api: - build: . - image: example-service-2 - environment: - - INFISICAL_TOKEN=${INFISICAL_TOKEN_FOR_API} -``` + api: + build: . + image: example-service-2 + environment: + - INFISICAL_MACHINE_IDENTITY_CLIENT_ID=${INFISICAL_MACHINE_IDENTITY_CLIENT_ID_FOR_API} + - INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET=${INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET_FOR_API} -## Export shell variables + ``` -Next, set the shell variables you defined in your compose file. This can be done manually or via your CI/CD environment. Once done, it will be used to populate the corresponding `INFISICAL_TOKEN` -in your Docker Compose file. + ### Export shell variables + Next, set the shell variables you defined in your compose file. This can be done manually or via your CI/CD environment. Once done, it will be used to populate the corresponding `INFISICAL_MACHINE_IDENTITY_CLIENT_ID` and `INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET` in your Docker Compose file. -```bash -#Example + ```bash + #Example -# Token refers to the token we generated in step 2 for this service -export INFISICAL_TOKEN_FOR_WEB= + # Token refers to the token we generated in step 2 for this service + export INFISICAL_MACHINE_IDENTITY_CLIENT_ID_FOR_WEB= + export INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET_FOR_WEB= -# Token refers to the token we generated in step 2 for this service -export INFISICAL_TOKEN_FOR_API= + # Token refers to the token we generated in step 2 for this service + export INFISICAL_MACHINE_IDENTITY_CLIENT_ID_FOR_API= + export INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET_FOR_API= -# Then run your compose file in the same terminal. -docker-compose ... -``` + # Then run your compose file in the same terminal. + docker-compose ... + ``` + + + + + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). + +They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + + + ## Generate service token + Generate a unique [Service Token](/documentation/platform/token) for each service. + + ## Feed service token to your Docker Compose file + + For each service you want to inject secrets into, set an environment variable called `INFISICAL_TOKEN` equal to a unique identifier variable. + + In the example below, we set `INFISICAL_TOKEN_FOR_WEB` and `INFISICAL_TOKEN_FOR_API` as the `INFISICAL_TOKEN` for the services. + + ```yaml + # Example Docker Compose file + services: + web: + build: . + image: example-service-1 + environment: + - INFISICAL_TOKEN=${INFISICAL_TOKEN_FOR_WEB} + + api: + build: . + image: example-service-2 + environment: + - INFISICAL_TOKEN=${INFISICAL_TOKEN_FOR_API} + ``` + + ## Export shell variables + + Next, set the shell variables you defined in your compose file. This can be done manually or via your CI/CD environment. Once done, it will be used to populate the corresponding `INFISICAL_TOKEN` + in your Docker Compose file. + + ```bash + #Example + + # Token refers to the token we generated in step 2 for this service + export INFISICAL_TOKEN_FOR_WEB= + + # Token refers to the token we generated in step 2 for this service + export INFISICAL_TOKEN_FOR_API= + + # Then run your compose file in the same terminal. + docker-compose ... + ``` + + + diff --git a/docs/integrations/platforms/docker-pass-envs.mdx b/docs/integrations/platforms/docker-pass-envs.mdx index 04cc36d6b..cf595de3d 100644 --- a/docs/integrations/platforms/docker-pass-envs.mdx +++ b/docs/integrations/platforms/docker-pass-envs.mdx @@ -10,8 +10,11 @@ For this method to function as expected, you must have a bash shell (for process ## 1. Authentication -If you are already logged in via the CLI you can skip this step. Otherwise, head to your project settings in Infisical Cloud to generate an [Infisical Token](/documentation/platform/token). The service token will allow you to authenticate and fetch secrets from Infisical. -Once you have created a service token with the required permissions, you'll need to feed the token to the CLI. +If you are already logged in via the CLI you can skip this step. Otherwise, head to your organization settings in Infisical Cloud to create a [Machine Identity](../../documentation/platform/identities/machine-identities). The machine identity will allow you to authenticate and fetch secrets from Infisical. +Once you have created a machine identity with the required permissions, you'll need to feed the token to the CLI. + + Please note that we highly recommend using `infisical login` for local development. + #### Pass as flag You may use the --token flag to set the token @@ -27,8 +30,14 @@ The CLI is configured to look for an environment variable named `INFISICAL_TOKEN export INFISICAL_TOKEN=<> ``` +You can use the `infisical login --method=universal-auth` command to directly obtain a universal auth access token and set it as an environment variable. + +```bash + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) +``` + - In production scenarios, please to avoid using the `infisical login` command and instead use a [service token](/documentation/platform/token). + In production scenarios, please to avoid using the `infisical login` command and instead use a [machine identity](../../documentation/platform/identities/machine-identities). ## 2. Run your docker command with Infisical diff --git a/docs/integrations/platforms/docker.mdx b/docs/integrations/platforms/docker.mdx index 92bd57943..e858ddad0 100644 --- a/docs/integrations/platforms/docker.mdx +++ b/docs/integrations/platforms/docker.mdx @@ -41,6 +41,54 @@ This is achieved by installing the Infisical CLI into your docker image and modi Starting your service with the Infisical CLI pulls your secrets from Infisical and injects them into your service. + + + ```dockerfile + CMD ["infisical", "run", "--projectId", "", "--", "[your service start command]"] + +# example with single single command + +CMD ["infisical", "run", "--projectId", "", "--", "npm", "run", "start"] + +# example with multiple commands + +CMD ["infisical", "run", "--projectId", "", "--command", "npm run start && ..."] + +```` + + + + Generate a machine identity for your project by following the steps in the [Machine Identity](/documentation/platform/identities/machine-identities) guide. The machine identity will allow you to authenticate and fetch secrets from Infisical. + + + Obtain an access token for the machine identity by running the following command: + ```bash + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --plain --silent) + ``` + + + Please note that the access token has a limited lifespan. The `infisical token renew` command can be used to renew the token if needed. + + + + The last step is to give the Infisical CLI installed in your Docker container access to the access token. This will allow the CLI to fetch and inject the secrets into your application. + + To feed the access token to the container, use the INFISICAL_TOKEN environment variable as shown below. + + ```bash + docker run --env INFISICAL_TOKEN=$INFISICAL_TOKEN [DOCKER-IMAGE]... + ``` + + + + + + +Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). + +They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + ```dockerfile CMD ["infisical", "run", "--", "[your service start command]"] @@ -49,19 +97,24 @@ CMD ["infisical", "run", "--", "npm", "run", "start"] # example with multiple commands CMD ["infisical", "run", "--command", "npm run start && ..."] -``` +```` -## Generate a service token + + + Head to your project settings in the Infisical dashboard to generate an [service token](/documentation/platform/token). + This service token will allow you to authenticate and fetch secrets from Infisical. + Once you have created a service token with the required permissions, you’ll need to feed the token to the CLI installed in your docker container. + + + The last step is to give the Infisical CLI installed in your Docker container access to the service token. This will allow the CLI to fetch and inject the secrets into your application. -Head to your project settings in the Infisical dashboard to generate an [service token](/documentation/platform/token). -This service token will allow you to authenticate and fetch secrets from Infisical. -Once you have created a service token with the required permissions, you’ll need to feed the token to the CLI installed in your docker container. + To feed the service token to the container, use the INFISICAL_TOKEN environment variable as shown below. -## Feed service token to docker container -The last step is to give the Infisical CLI installed in your Docker container access to the service token. This will allow the CLI to fetch and inject the secrets into your application. + ```bash + docker run --env INFISICAL_TOKEN=[token] [DOCKER-IMAGE]... + ``` + -To feed the service token to the container, use the INFISICAL_TOKEN environment variable as shown below. - -```bash - docker run --env INFISICAL_TOKEN=[token] [DOCKER-IMAGE]... -``` + + + diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 6dbe4acde..41a41726c 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -1,12 +1,11 @@ --- -title: 'Kubernetes' +title: "Kubernetes" description: "How to use Infisical to inject secrets into Kubernetes clusters." --- ![title](../../images/k8-diagram.png) - -The Infisical Secrets Operator is a Kubernetes controller that retrieves secrets from Infisical and stores them in a designated cluster. +The Infisical Secrets Operator is a Kubernetes controller that retrieves secrets from Infisical and stores them in a designated cluster. It uses an `InfisicalSecret` resource to specify authentication and storage methods. The operator continuously updates secrets and can also reload dependent deployments automatically. @@ -26,8 +25,8 @@ The operator can be install via [Helm](https://helm.sh) or [kubectl](https://git **Install the Helm chart** For production deployments, it is highly recommended to set the chart version and the application version during installs and upgrades. - This will prevent the operator from being accidentally updated to the latest version and introduce unintended breaking changes. - + This will prevent the operator from being accidentally updated to the latest version and introduce unintended breaking changes. + View application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags) and chart versions [here](https://cloudsmith.io/~infisical/repos/helm-charts/packages/detail/helm/secrets-operator/#versions) ```bash @@ -42,66 +41,72 @@ The operator can be install via [Helm](https://helm.sh) or [kubectl](https://git For production deployments, it is highly recommended to set the version of the Kubernetes operator manually instead of pointing to the latest version. Doing so will help you avoid accidental updates to the newest release which may introduce unintended breaking changes. View all application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags). +The command below will install the most recent version of the Kubernetes operator. +However, to set the version manually, download the manifest and set the image tag version of `infisical/kubernetes-operator` according to your desired version. - The command below will install the most recent version of the Kubernetes operator. - However, to set the version manually, download the manifest and set the image tag version of `infisical/kubernetes-operator` according to your desired version. - - Once you apply the manifest, the operator will be installed in `infisical-operator-system` namespace. +Once you apply the manifest, the operator will be installed in `infisical-operator-system` namespace. ``` kubectl apply -f https://raw.githubusercontent.com/Infisical/infisical/main/k8-operator/kubectl-install/install-secrets-operator.yaml - ``` + ``` + ## Sync Infisical Secrets to your cluster -Once you have installed the operator to your cluster, you'll need to create a `InfisicalSecret` custom resource definition (CRD). + +Once you have installed the operator to your cluster, you'll need to create a `InfisicalSecret` custom resource definition (CRD). ```yaml example-infisical-secret-crd.yaml apiVersion: secrets.infisical.com/v1alpha1 kind: InfisicalSecret metadata: - name: infisicalsecret-sample - labels: - label-to-be-passed-to-managed-secret: sample-value - annotations: - example.com/annotation-to-be-passed-to-managed-secret: "sample-value" + name: infisicalsecret-sample + labels: + label-to-be-passed-to-managed-secret: sample-value + annotations: + example.com/annotation-to-be-passed-to-managed-secret: "sample-value" spec: - hostAPI: https://app.infisical.com/api - resyncInterval: 10 - authentication: - # Make sure to only have 1 authentication method defined, serviceToken/universalAuth. - # If you have multiple authentication methods defined, it may cause issues. - universalAuth: - secretsScope: - projectSlug: - envSlug: # "dev", "staging", "prod", etc.. - secretsPath: "" # Root is "/" - credentialsRef: - secretName: universal-auth-credentials - secretNamespace: default - - serviceToken: - serviceTokenSecretReference: - secretName: service-token - secretNamespace: default - secretsScope: - envSlug: - secretsPath: # Root is "/" - - managedSecretReference: - secretName: managed-secret + hostAPI: https://app.infisical.com/api + resyncInterval: 10 + authentication: + # Make sure to only have 1 authentication method defined, serviceToken/universalAuth. + # If you have multiple authentication methods defined, it may cause issues. + universalAuth: + secretsScope: + projectSlug: + envSlug: # "dev", "staging", "prod", etc.. + secretsPath: "" # Root is "/" + recursive: true # Fetch all secrets from the specified path and all sub-directories. Default is false. + + credentialsRef: + secretName: universal-auth-credentials secretNamespace: default - creationPolicy: "Orphan" ## Owner | Orphan (default) - # secretType: kubernetes.io/dockerconfigjson + + # Service tokens are deprecated and will be removed in the near future. Please use Machine Identities for authenticating with Infisical. + serviceToken: + serviceTokenSecretReference: + secretName: service-token + secretNamespace: default + secretsScope: + envSlug: + secretsPath: # Root is "/" + recursive: true # Fetch all secrets from the specified path and all sub-directories. Default is false. + + managedSecretReference: + secretName: managed-secret + secretNamespace: default + creationPolicy: "Orphan" ## Owner | Orphan (default) + # secretType: kubernetes.io/dockerconfigjson ``` + ### InfisicalSecret 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` - When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. +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. @@ -112,16 +117,19 @@ spec: ``` Make sure to replace `` and `` with the appropriate values for your backend service and namespace. + -This property defines the time in seconds between each secret re-sync from Infisical. Shorter time between re-syncs will require higher rate limits only available on paid plans. -Default re-sync interval is every 1 minute. + This property defines the time in seconds between each secret re-sync from + Infisical. Shorter time between re-syncs will require higher rate limits only + available on paid plans. Default re-sync interval is every 1 minute. - This block defines the method that will be used to authenticate with Infisical so that secrets can be fetched + This block defines the method that will be used to authenticate with Infisical + so that secrets can be fetched @@ -145,76 +153,95 @@ Default re-sync interval is every 1 minute. Once the secret is created, add the `secretName` and `secretNamespace` of the secret that was just created under `authentication.universalAuth.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. + - - 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: + universalAuth: + secretsScope: + projectSlug: # <-- project slug + envSlug: # "dev", "staging", "prod", etc.. + secretsPath: "" # Root is "/" + credentialsRef: + secretName: universal-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 + ... +``` - ## Example - ```yaml - apiVersion: secrets.infisical.com/v1alpha1 - kind: InfisicalSecret - metadata: - name: infisicalsecret-sample-crd - spec: - authentication: - universalAuth: - secretsScope: - projectSlug: # <-- project slug - envSlug: # "dev", "staging", "prod", etc.. - secretsPath: "" # Root is "/" - credentialsRef: - secretName: universal-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. - Follow the instructions below to create and store the service token in a Kubernetes secrets and reference it in your CRD. + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). - #### 1. Generate service token +They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). - You can generate a [service token](../../documentation/platform/token) for an Infisical project by heading over to the Infisical dashboard then to Project Settings. + - #### 2. Create Kubernetes secret containing service token +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. +Follow the instructions below to create and store the service token in a Kubernetes secrets and reference it in your CRD. - Once you have generated the service token, you will need to create a Kubernetes secret containing the service token you generated. - To quickly create a Kubernetes secret containing the generated service token, you can run the command below. Make sure you replace `` with your service token. +#### 1. Generate service token - ``` bash - kubectl create secret generic service-token --from-literal=infisicalToken="" - ``` +You can generate a [service token](../../documentation/platform/token) for an Infisical project by heading over to the Infisical dashboard then to Project Settings. - #### 3. Add reference for the Kubernetes secret containing service token +#### 2. Create Kubernetes secret containing service token - Once the secret is created, add the name and namespace of the secret that was just created under `authentication.serviceToken.serviceTokenSecretReference` field in the InfisicalSecret resource. +Once you have generated the service token, you will need to create a Kubernetes secret containing the service token you generated. +To quickly create a Kubernetes secret containing the generated service token, you can run the command below. Make sure you replace `` with your service token. - - Make sure to also populate the `secretsScope` field with the, environment slug _`envSlug`_, and secrets path _`secretsPath`_ that you want to fetch secrets from. Please see the example below. - +```bash +kubectl create secret generic service-token --from-literal=infisicalToken="" +``` + +#### 3. Add reference for the Kubernetes secret containing service token + +Once the secret is created, add the name and namespace of the secret that was just created under `authentication.serviceToken.serviceTokenSecretReference` field in the InfisicalSecret resource. + +{" "} + + + Make sure to also populate the `secretsScope` field with the, 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: + serviceToken: + serviceTokenSecretReference: + secretName: service-token # <-- name of the Kubernetes secret that stores our service token + secretNamespace: option # <-- namespace of the Kubernetes secret that stores our service token + secretsScope: + envSlug: # "dev", "staging", "prod", etc.. + secretsPath: # Root is "/" + ... +``` - ## Example - ```yaml - apiVersion: secrets.infisical.com/v1alpha1 - kind: InfisicalSecret - metadata: - name: infisicalsecret-sample-crd - spec: - authentication: - serviceToken: - serviceTokenSecretReference: - secretName: service-token # <-- name of the Kubernetes secret that stores our service token - secretNamespace: option # <-- namespace of the Kubernetes secret that stores our service token - secretsScope: - envSlug: # "dev", "staging", "prod", etc.. - secretsPath: # Root is "/" - ... - ``` @@ -238,19 +265,21 @@ Override the default Opaque type for managed secrets with this field. Useful for Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes secret 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` - When creation policy is set to `Owner`, the `InfisicalSecret` CRD must be in the same namespace as where the managed kubernetes secret. + When creation policy is set to `Owner`, the `InfisicalSecret` CRD must be in + the same namespace as where the managed kubernetes secret. -### Propagating labels & annotations +### Propagating labels & annotations -The operator will transfer all labels & annotations present on the `InfisicalSecret` CRD to the managed Kubernetes secret to be created. +The operator will transfer all labels & annotations present on the `InfisicalSecret` CRD to the managed Kubernetes secret to be created. Thus, if a specific label is required on the resulting secret, it can be applied as demonstrated in the following example: @@ -275,8 +304,7 @@ This would result in the following managed secret to be created: ```yaml apiVersion: v1 -data: - ... +data: ... kind: Secret metadata: annotations: @@ -288,20 +316,21 @@ metadata: namespace: default type: Opaque ``` + +### Apply the Infisical CRD to your cluster -### Apply the Infisical CRD to your cluster -Once you have configured the Infisical CRD with the required fields, you can apply it to your cluster. +Once you have configured the Infisical CRD with the required fields, you can apply it to your cluster. After applying, you should notice that the managed secret has been created in the desired namespace your specified. ``` kubectl apply -f example-infisical-secret-crd.yaml ``` -### Verify managed secret creation +### Verify managed secret creation -To verify that the operator has successfully created the managed secret, you can check the secrets in the namespace that was specified. +To verify that the operator has successfully created the managed secret, you can check the secrets in the namespace that was specified. ```bash # Verify managed secret is created @@ -313,48 +342,49 @@ kubectl get secrets -n 1 minutes. -### Using managed secret in your deployment -Incorporating the managed secret created by the operator into your deployment can be achieved through several methods. +### Using managed secret in your deployment + +Incorporating the managed secret created by the operator into your deployment can be achieved through several methods. Here, we will highlight three of the most common ways to utilize it. Learn more about Kubernetes secrets [here](https://kubernetes.io/docs/concepts/configuration/secret/) - This will take all the secrets from your managed secret and expose them to your container + 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 - ``` - - 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 - 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 +````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: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: + 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 +```` + + This will allow you to select individual secrets by key name from your managed secret and expose them to your container @@ -368,99 +398,104 @@ 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 - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: - app: nginx - spec: - replicas: 1 - selector: - matchLabels: - 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 - ``` +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 +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 + +``` + - This will allow you to create a volume on your container which comprises of files holding the secrets in your managed kubernetes secret - ```yaml - volumes: - - name: secrets-volume-name # The name of the volume under which secrets will be stored - secret: - secretName: managed-secret # managed secret name - ``` +This will allow you to create a volume on your container which comprises of files holding the secrets in your managed kubernetes secret +```yaml +volumes: + - name: secrets-volume-name # The name of the volume under which secrets will be stored + secret: + 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 - ```yaml - volumeMounts: - - name: secrets-volume-name - mountPath: /etc/secrets - readOnly: true - ``` +You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets - Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: +```yaml +volumeMounts: + - name: secrets-volume-name + mountPath: /etc/secrets + readOnly: true +``` + +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: + spec: + containers: - name: nginx image: nginx:1.14.2 volumeMounts: - - name: secrets-volume-name - mountPath: /etc/secrets - readOnly: true + - name: secrets-volume-name + mountPath: /etc/secrets + readOnly: true ports: - - containerPort: 80 - volumes: + - containerPort: 80 + volumes: - name: secrets-volume-name secret: secretName: managed-secret # <- managed secrets - ``` +``` + -## Auto redeployment -Deployments using managed secrets don't reload automatically on updates, so they may use outdated secrets unless manually redeployed. +## Auto redeployment + +Deployments using managed secrets don't reload automatically on updates, so they may use outdated secrets unless manually redeployed. To address this, we added functionality to automatically redeploy your deployment when its managed secret updates. -### Enabling auto redeploy +### Enabling auto redeploy + To enable auto redeployment you simply have to add the following annotation to the deployment that consumes a managed secret + ```yaml secrets.infisical.com/auto-reload: "true" ``` - ```yaml apiVersion: apps/v1 @@ -491,19 +526,25 @@ spec: - containerPort: 80 ``` + + #### How it works + When a secret change occurs, 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. + +## Global configuration -## Global configuration -To configure global settings that will apply to all instances of `InfisicalSecret`, you can define these configurations in a Kubernetes ConfigMap. +To configure global settings that will apply to all instances of `InfisicalSecret`, you can define these configurations in a Kubernetes ConfigMap. For example, you can configure all `InfisicalSecret` instances to fetch secrets from a single backend API without specifying the `hostAPI` parameter for each instance. ### Available global properties -| Property | Description | Default value -| -------- | ------------------------------------- |------------------------ -| hostAPI | If `hostAPI` in `InfisicalSecret` instance is left empty, this value will be used | https://app.infisical.com/api +| Property | Description | Default value | +| -------- | --------------------------------------------------------------------------------- | ----------------------------- | +| hostAPI | If `hostAPI` in `InfisicalSecret` instance is left empty, this value will be used | https://app.infisical.com/api | ### Applying global configurations -All global configurations must reside in a Kubernetes ConfigMap named `infisical-config` in the namespace `infisical-operator-system`. + +All global configurations must reside in a Kubernetes ConfigMap named `infisical-config` in the namespace `infisical-operator-system`. To apply global configuration to the operator, copy the following yaml into `infisical-config.yaml` file. ```yaml infisical-config.yaml @@ -521,13 +562,12 @@ data: hostAPI: https://example.com/api # <-- global hostAPI ``` -Then apply this change via kubectl by running the following +Then apply this change via kubectl by running the following -```bash -kubectl apply -f infisical-config.yaml +```bash +kubectl apply -f infisical-config.yaml ``` - ## Troubleshoot operator If the operator is unable to fetch secrets from the API, it will not affect the managed Kubernetes secret. @@ -576,7 +616,6 @@ The managed secret created by the operator will not be deleted when the operator - ## Useful Articles - [Managing secrets in OpenShift with Infisical](https://xphyr.net/post/infisical_ocp/) diff --git a/docs/internals/overview.mdx b/docs/internals/overview.mdx index e64327a03..a80ebf294 100644 --- a/docs/internals/overview.mdx +++ b/docs/internals/overview.mdx @@ -32,6 +32,6 @@ This section covers the internals of Infisical including its technical underpinn icon="ticket" color="#000000" > - Learn best practices for utilizing Infisical service tokens. + Learn best practices for utilizing Infisical service tokens. Please note that service tokens are now deprecated and will be removed entirely in the future. diff --git a/docs/internals/service-tokens.mdx b/docs/internals/service-tokens.mdx index 3222b1aa8..39569326a 100644 --- a/docs/internals/service-tokens.mdx +++ b/docs/internals/service-tokens.mdx @@ -2,12 +2,19 @@ title: "Service tokens" description: "Understanding service tokens and their best practices." --- + + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). + +They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + ​ Many clients use service tokens to authenticate and read/write secrets from/to Infisical; they can be created in your project settings. ## Anatomy -A service token in Infisical consists of the token itself, a `string`, and a corresponding document in the storage backend containing its +A service token in Infisical consists of the token itself, a `string`, and a corresponding document in the storage backend containing its properties and metadata. ### Database model @@ -22,12 +29,13 @@ The storage backend model for a token contains the following information: ### Token -A service token itself consist of two parts used for authentication and decryption, separated by the delimiter `.`. +A service token itself consist of two parts used for authentication and decryption, separated by the delimiter `.`. Consider the token `st.abc.def.ghi`. Here, `st.abc.def` can be used to authenticate with the API, by including it in the `Authorization` header under `Bearer st.abc.def`, and retrieve (encrypted) secrets as well as a project key back. Meanwhile, `ghi`, a hex-string, can be used to decrypt the project key used to decrypt the secrets. Note that when using service tokens via select client methods like SDK or CLI, cryptographic operations are abstracted for you that is the token is parsed and encryption/decryption operations are handled. If using service tokens with the REST API and end-to-end encryption enabled, then you will have to handle the encryption/decryption operations yourself. ​ + ## Recommendations ### Issuance @@ -46,4 +54,4 @@ Since service tokens grant access to your secrets, we recommend storing them sec We recommend periodically rotating the service token, even in the absence of compromise. Since service tokens are capable of decrypting project keys used to decrypt secrets, all of which use AES-256-GCM encryption, they should be rotated before approximately 2^32 encryptions have been performed; this follows the guidance set forth by [NIST publication 800-38D](https://csrc.nist.gov/pubs/sp/800/38/d/final). -Note that Infisical keeps track of the number of times that service tokens are used and will alert you when you have reached 90% of the recommended capacity. \ No newline at end of file +Note that Infisical keeps track of the number of times that service tokens are used and will alert you when you have reached 90% of the recommended capacity. diff --git a/docs/mint.json b/docs/mint.json index b38b5fa52..7b92d065e 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -32,10 +32,7 @@ "thumbsRating": true }, "api": { - "baseUrl": [ - "https://app.infisical.com", - "http://localhost:8080" - ] + "baseUrl": ["https://app.infisical.com", "http://localhost:8080"] }, "topbarLinks": [ { @@ -76,11 +73,7 @@ "documentation/getting-started/introduction", { "group": "Quickstart", - "pages": [ - "documentation/guides/local-development", - "documentation/guides/staging", - "documentation/guides/production" - ] + "pages": ["documentation/guides/local-development"] }, { "group": "Guides", @@ -126,7 +119,9 @@ "documentation/platform/access-controls/temporary-access", "documentation/platform/access-controls/access-requests", "documentation/platform/pr-workflows", - "documentation/platform/audit-logs" + "documentation/platform/audit-logs", + "documentation/platform/audit-log-streams", + "documentation/platform/groups" ] }, { @@ -146,10 +141,11 @@ "documentation/platform/dynamic-secrets/postgresql", "documentation/platform/dynamic-secrets/mysql", "documentation/platform/dynamic-secrets/oracle", - "documentation/platform/dynamic-secrets/cassandra" + "documentation/platform/dynamic-secrets/cassandra", + "documentation/platform/dynamic-secrets/aws-iam" ] }, - "documentation/platform/groups" + "documentation/platform/secret-sharing" ] }, { @@ -158,6 +154,10 @@ "documentation/platform/auth-methods/email-password", "documentation/platform/token", "documentation/platform/identities/universal-auth", + "documentation/platform/identities/kubernetes-auth", + "documentation/platform/identities/gcp-auth", + "documentation/platform/identities/azure-auth", + "documentation/platform/identities/aws-auth", "documentation/platform/mfa", { "group": "SSO", @@ -196,16 +196,17 @@ "group": "Self-host Infisical", "pages": [ "self-hosting/overview", - "self-hosting/configuration/requirements", { "group": "Installation methods", "pages": [ "self-hosting/deployment-options/standalone-infisical", + "self-hosting/deployment-options/docker-swarm", "self-hosting/deployment-options/docker-compose", "self-hosting/deployment-options/kubernetes-helm" ] }, "self-hosting/configuration/envars", + "self-hosting/configuration/requirements", { "group": "Guides", "pages": [ @@ -215,10 +216,7 @@ }, { "group": "Reference architectures", - "pages": [ - "self-hosting/reference-architectures/aws-ecs", - "self-hosting/reference-architectures/on-premise" - ] + "pages": ["self-hosting/reference-architectures/aws-ecs"] }, "self-hosting/ee", "self-hosting/faq" @@ -338,6 +336,7 @@ "pages": [ "integrations/cicd/circleci", "integrations/cicd/travisci", + "integrations/cicd/rundeck", "integrations/cicd/codefresh", "integrations/cloud/checkly" ] @@ -374,15 +373,11 @@ }, { "group": "Build Tool Integrations", - "pages": [ - "integrations/build-tools/gradle" - ] + "pages": ["integrations/build-tools/gradle"] }, { "group": "", - "pages": [ - "sdks/overview" - ] + "pages": ["sdks/overview"] }, { "group": "SDK's", @@ -400,9 +395,7 @@ "api-reference/overview/authentication", { "group": "Examples", - "pages": [ - "api-reference/overview/examples/integration" - ] + "pages": ["api-reference/overview/examples/integration"] } ] }, @@ -427,7 +420,8 @@ "api-reference/endpoints/universal-auth/create-client-secret", "api-reference/endpoints/universal-auth/list-client-secrets", "api-reference/endpoints/universal-auth/revoke-client-secret", - "api-reference/endpoints/universal-auth/renew-access-token" + "api-reference/endpoints/universal-auth/renew-access-token", + "api-reference/endpoints/universal-auth/revoke-access-token" ] }, { @@ -447,17 +441,40 @@ "api-reference/endpoints/workspaces/delete-workspace", "api-reference/endpoints/workspaces/get-workspace", "api-reference/endpoints/workspaces/update-workspace", - "api-reference/endpoints/workspaces/invite-member-to-workspace", - "api-reference/endpoints/workspaces/remove-member-from-workspace", - "api-reference/endpoints/workspaces/memberships", - "api-reference/endpoints/workspaces/update-membership", - "api-reference/endpoints/workspaces/list-identity-memberships", - "api-reference/endpoints/workspaces/update-identity-membership", - "api-reference/endpoints/workspaces/delete-identity-membership", "api-reference/endpoints/workspaces/secret-snapshots", "api-reference/endpoints/workspaces/rollback-snapshot" ] }, + { + "group": "Project Users", + "pages": [ + "api-reference/endpoints/project-users/invite-member-to-workspace", + "api-reference/endpoints/project-users/remove-member-from-workspace", + "api-reference/endpoints/project-users/memberships", + "api-reference/endpoints/project-users/get-by-username", + "api-reference/endpoints/project-users/update-membership" + ] + }, + { + "group": "Project Identities", + "pages": [ + "api-reference/endpoints/project-identities/add-identity-membership", + "api-reference/endpoints/project-identities/list-identity-memberships", + "api-reference/endpoints/project-identities/get-by-id", + "api-reference/endpoints/project-identities/update-identity-membership", + "api-reference/endpoints/project-identities/delete-identity-membership" + ] + }, + { + "group": "Project Roles", + "pages": [ + "api-reference/endpoints/project-roles/create", + "api-reference/endpoints/project-roles/update", + "api-reference/endpoints/project-roles/delete", + "api-reference/endpoints/project-roles/get-by-slug", + "api-reference/endpoints/project-roles/list" + ] + }, { "group": "Environments", "pages": [ @@ -491,6 +508,9 @@ "api-reference/endpoints/secrets/read", "api-reference/endpoints/secrets/update", "api-reference/endpoints/secrets/delete", + "api-reference/endpoints/secrets/create-many", + "api-reference/endpoints/secrets/update-many", + "api-reference/endpoints/secrets/delete-many", "api-reference/endpoints/secrets/attach-tags", "api-reference/endpoints/secrets/detach-tags" ] @@ -531,15 +551,11 @@ }, { "group": "Service Tokens", - "pages": [ - "api-reference/endpoints/service-tokens/get" - ] + "pages": ["api-reference/endpoints/service-tokens/get"] }, { "group": "Audit Logs", - "pages": [ - "api-reference/endpoints/audit-logs/export-audit-log" - ] + "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] } ] }, @@ -555,9 +571,7 @@ }, { "group": "", - "pages": [ - "changelog/overview" - ] + "pages": ["changelog/overview"] }, { "group": "Contributing", @@ -581,9 +595,7 @@ }, { "group": "Contributing to SDK", - "pages": [ - "contributing/sdk/developing" - ] + "pages": ["contributing/sdk/developing"] } ] } diff --git a/docs/sdks/languages/csharp.mdx b/docs/sdks/languages/csharp.mdx index b3a1d2086..90351a986 100644 --- a/docs/sdks/languages/csharp.mdx +++ b/docs/sdks/languages/csharp.mdx @@ -21,21 +21,28 @@ namespace Example static void Main(string[] args) { - var settings = new ClientSettings + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions { - ClientId = "CLIENT_ID", - ClientSecret = "CLIENT_SECRET", - // SiteUrl = "http://localhost:8080", <-- This line can be omitted if you're using Infisical Cloud. - }; - var infisical = new InfisicalClient(settings); + UniversalAuth = new UniversalAuthMethod + { + ClientId = "your-client-id", + ClientSecret = "your-client-secret" + } + } + }; - var options = new GetSecretOptions + + var infisicalClient = new InfisicalClient(settings); + + var getSecretOptions = new GetSecretOptions { SecretName = "TEST", ProjectId = "PROJECT_ID", Environment = "dev", }; - var secret = infisical.GetSecret(options); + var secret = infisical.GetSecret(getSecretOptions); Console.WriteLine($"The value of secret '{secret.SecretKey}', is: {secret.SecretValue}"); @@ -52,8 +59,6 @@ This example demonstrates how to use the Infisical C# SDK in a C# application. T # Installation -Run `npm` to add `@infisical/sdk` to your project. - ```console $ dotnet add package Infisical.Sdk ``` @@ -70,14 +75,20 @@ namespace Example { static void Main(string[] args) { - - var settings = new ClientSettings + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions { - ClientId = "CLIENT_ID", - ClientSecret = "CLIENT_SECRET", - }; + UniversalAuth = new UniversalAuthMethod + { + ClientId = "your-client-id", + ClientSecret = "your-client-secret" + } + } + }; - var infisical = new InfisicalClient(settings); // <-- Your SDK instance! + + var infisicalClient = new InfisicalClient(settings); // <-- Your SDK client is now ready to use } } } @@ -87,14 +98,14 @@ namespace Example - + Your machine identity client ID. - + Your machine identity client secret. - + An access token obtained from the machine identity login endpoint. @@ -103,13 +114,175 @@ namespace Example 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 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. @@ -155,6 +328,14 @@ Retrieve all secrets within the Infisical project and environment that client is 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) + diff --git a/docs/sdks/languages/java.mdx b/docs/sdks/languages/java.mdx index 5b8797b5d..879bfa624 100644 --- a/docs/sdks/languages/java.mdx +++ b/docs/sdks/languages/java.mdx @@ -19,12 +19,19 @@ import com.infisical.sdk.schema.*; public class Example { public static void main(String[] args) { - // Create a new Infisical Client + + // Create the authentication settings for the client ClientSettings settings = new ClientSettings(); - settings.setClientID("MACHINE_IDENTITY_CLIENT_ID"); - settings.setClientSecret("MACHINE_IDENTITY_CLIENT_SECRET"); - settings.setCacheTTL(Long.valueOf(300)); // 300 seconds, 5 minutes + 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); // Create the options for fetching the secret @@ -68,11 +75,18 @@ 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(); - settings.setClientID("MACHINE_IDENTITY_CLIENT_ID"); - settings.setClientSecret("MACHINE_IDENTITY_CLIENT_SECRET"); + 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! } } @@ -82,15 +96,21 @@ public class App { - + 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. @@ -101,10 +121,155 @@ public class App { Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) + + + 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); +``` + + ### 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. @@ -119,6 +284,8 @@ 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); ``` @@ -148,6 +315,14 @@ Retrieve all secrets within the Infisical project and environment that client is 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) + diff --git a/docs/sdks/languages/node.mdx b/docs/sdks/languages/node.mdx index 4816392ed..1546451b8 100644 --- a/docs/sdks/languages/node.mdx +++ b/docs/sdks/languages/node.mdx @@ -4,7 +4,7 @@ sidebarTitle: "Node.js" icon: "node" --- -If you're working with Node.js, the official [infisical-node](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 SDK](https://github.com/Infisical/sdk/tree/main/languages/node) 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) @@ -14,21 +14,25 @@ If you're working with Node.js, the official [infisical-node](https://github.com ```js import express from "express"; -import { InfisicalClient, LogLevel } from "@infisical/sdk"; +import { InfisicalClient } from "@infisical/sdk"; const app = express(); const PORT = 3000; const client = new InfisicalClient({ - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", - logLevel: LogLevel.Error + 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 value - + // Access the secret + const name = await client.getSecret({ environment: "dev", projectId: "PROJECT_ID", @@ -72,8 +76,12 @@ Import the SDK and create a client instance with your [Machine Identity](/docume import { InfisicalClient, LogLevel } from "@infisical/sdk"; const client = new InfisicalClient({ - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET" + } + }, logLevel: LogLevel.Error }); ``` @@ -81,31 +89,40 @@ Import the SDK and create a client instance with your [Machine Identity](/docume ```js - const { InfisicalClient, LogLevel } = require("@infisical/sdk"); + const { InfisicalClient } = require("@infisical/sdk"); const client = new InfisicalClient({ - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", - logLevel: LogLevel.Error + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET" + } + }, }); ``` -#### Parameters +### Parameters - + 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. - + 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. @@ -119,10 +136,138 @@ Import the SDK and create a client instance with your [Machine Identity](/docume The level of logs you wish to log The logs are derived from Rust, as we have written our base SDK in Rust. + + + 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** +```js +const client = new InfisicalClient({ + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_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** +```js +const client = new InfisicalClient({ + auth: { + gcpIdToken: { + identityId: "YOUR_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** +```js +const client = new InfisicalClient({ + auth: { + gcpIam: { + identityId: "YOUR_IDENTITY_ID", + serviceAccountKeyFilePath: "./path/to/your/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** +```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" + } + } +}); +``` + + +#### 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** +```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. @@ -161,6 +306,14 @@ Retrieve all secrets within the Infisical project and environment that client is 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) diff --git a/docs/sdks/languages/python.mdx b/docs/sdks/languages/python.mdx index 0ce221757..d9ab49688 100644 --- a/docs/sdks/languages/python.mdx +++ b/docs/sdks/languages/python.mdx @@ -6,20 +6,24 @@ icon: "python" 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. -- [PyPi Package](https://pypi.org/project/infisical-python/) -- [Github Repository](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) +- [PyPi Package](https://pypi.org/project/infisical-python/) +- [Github Repository](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) ## Basic Usage ```py from flask import Flask -from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions +from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions, AuthenticationOptions, UniversalAuthMethod app = Flask(__name__) client = InfisicalClient(ClientSettings( - client_id="MACHINE_IDENTITY_CLIENT_ID", - client_secret="MACHINE_IDENTITY_CLIENT_SECRET", + auth=AuthenticationOptions( + universal_auth=UniversalAuthMethod( + client_id="CLIENT_ID", + client_secret="CLIENT_SECRET", + ) + ) )) @app.route("/") @@ -38,7 +42,7 @@ def hello_world(): 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. + We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. ## Installation @@ -56,11 +60,15 @@ Note: You need Python 3.7+. Import the SDK and create a client instance with your [Machine Identity](/api-reference/overview/authentication). ```py -from infisical_client import ClientSettings, InfisicalClient +from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, UniversalAuthMethod client = InfisicalClient(ClientSettings( - client_id="MACHINE_IDENTITY_CLIENT_ID", - client_secret="MACHINE_IDENTITY_CLIENT_SECRET", + auth=AuthenticationOptions( + universal_auth=UniversalAuthMethod( + client_id="CLIENT_ID", + client_secret="CLIENT_SECRET", + ) + ) )) ``` @@ -68,14 +76,20 @@ client = InfisicalClient(ClientSettings( - + Your Infisical Client ID. + + **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. - + Your Infisical Client Secret. + + **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. + + **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. @@ -85,18 +99,155 @@ client = InfisicalClient(ClientSettings( - Your self-hosted absolute site URL including the protocol (e.g. - `https://app.infisical.com`) + Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) + + + 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** +```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", + ) + ) +)) +``` + + +#### 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 + ) + ) +)) +``` + ### 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 "cache_ttl" option when creating the client. @@ -133,6 +284,14 @@ Retrieve all secrets within the Infisical project and environment that client is 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) @@ -156,26 +315,26 @@ By default, `getSecret()` fetches and returns a shared secret. If not found, it #### Parameters - - - The key of the secret to retrieve - - - 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) - - + + + The key of the secret to retrieve + + + 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) + + ### client.createSecret(options) @@ -194,26 +353,26 @@ 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". - - + + + 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) @@ -232,26 +391,26 @@ 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". - - + + + 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) @@ -269,23 +428,23 @@ 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". - - + + + 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 @@ -299,9 +458,11 @@ 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, @@ -314,22 +475,22 @@ encryptedData = client.encryptSymmetric(encryptOptions) #### Parameters - - - The plaintext you want to encrypt. - - - The symmetric key to use for encryption. - - + + + 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. + +`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 + ```py decryptOptions = DecryptSymmetricOptions( ciphertext=encryptedData.ciphertext, @@ -344,22 +505,24 @@ decryptedString = client.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. - - + + + 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/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 4c1456d3b..33e6c697c 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -3,30 +3,34 @@ title: "Configurations" description: "Read how to configure environment variables for self-hosted Infisical." --- - -Infisical accepts all configurations via environment variables. For a minimal self-hosted instance, at least `ENCRYPTION_KEY`, `AUTH_SECRET`, `DB_CONNECTION_URI` and `REDIS_URL` must be defined. +Infisical accepts all configurations via environment variables. For a minimal self-hosted instance, at least `ENCRYPTION_KEY`, `AUTH_SECRET`, `DB_CONNECTION_URI` and `REDIS_URL` must be defined. However, you can configure additional settings to activate more features as needed. -## General platform +## General platform + Used to configure platform-specific security and operational settings - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` + Must be a random 16 byte hex string. Can be generated with `openssl rand -hex + 16` - Must be a random 32 byte base64 string. Can be generated with `openssl rand -base64 32` + Must be a random 32 byte base64 string. Can be generated with `openssl rand + -base64 32` - Must be an absolute URL including the protocol (e.g. https://app.infisical.com). + Must be an absolute URL including the protocol (e.g. + https://app.infisical.com). -## Data Layer +## Data Layer + The platform utilizes Postgres to persist all of its data and Redis for caching and backgroud tasks - Postgres database connection string. + Postgres database connection string. @@ -39,9 +43,8 @@ The platform utilizes Postgres to persist all of its data and Redis for caching Redis connection string. - - ## Email service + Without email configuration, Infisical's core functions like sign-up/login and secret operations work, but this disables multi-factor authentication, email invites for projects, alerts for suspicious logins, and all other email-dependent features. @@ -49,25 +52,36 @@ Without email configuration, Infisical's core functions like sign-up/login and s Hostname to connect to for establishing SMTP connections - - Credential to connect to host (e.g. team@infisical.com) - +{" "} - - Credential to connect to host - + + Credential to connect to host (e.g. team@infisical.com) + - - Port to connect to for establishing SMTP connections - +{" "} - - If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported - + + Credential to connect to host + - - Email address to be used for sending emails - +{" "} + + + Port to connect to for establishing SMTP connections + + +{" "} + + + If true, use TLS when connecting to host. If false, TLS will be used if + STARTTLS is supported + + +{" "} + + + Email address to be used for sending emails + Name label to be used in From field (e.g. Team) @@ -76,25 +90,25 @@ Without email configuration, Infisical's core functions like sign-up/login and s - 1. Create an account and configure [SendGrid](https://sendgrid.com) to send emails. - 2. Create a SendGrid API Key under Settings > [API Keys](https://app.sendgrid.com/settings/api_keys) - 3. Set a name for your API Key, we recommend using "Infisical," and select the "Restricted Key" option. You will need to enable the "Mail Send" permission as shown below: +1. Create an account and configure [SendGrid](https://sendgrid.com) to send emails. +2. Create a SendGrid API Key under Settings > [API Keys](https://app.sendgrid.com/settings/api_keys) +3. Set a name for your API Key, we recommend using "Infisical," and select the "Restricted Key" option. You will need to enable the "Mail Send" permission as shown below: - ![creating sendgrid api key](../../images/self-hosting/configuration/email/email-sendgrid-create-key.png) +![creating sendgrid api key](../../images/self-hosting/configuration/email/email-sendgrid-create-key.png) - ![setting sendgrid api key restriction](../../images/self-hosting/configuration/email/email-sendgrid-restrictions.png) +![setting sendgrid api key restriction](../../images/self-hosting/configuration/email/email-sendgrid-restrictions.png) - 4. With the API Key, you can now set your SMTP environment variables: +4. With the API Key, you can now set your SMTP environment variables: - ``` - SMTP_HOST=smtp.sendgrid.net - SMTP_USERNAME=apikey - SMTP_PASSWORD=SG.rqFsfjxYPiqE1lqZTgD_lz7x8IVLx # your SendGrid API Key from step above - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails - SMTP_FROM_NAME=Infisical - ``` +``` +SMTP_HOST=smtp.sendgrid.net +SMTP_USERNAME=apikey +SMTP_PASSWORD=SG.rqFsfjxYPiqE1lqZTgD_lz7x8IVLx # your SendGrid API Key from step above +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` Remember that you will need to restart Infisical for this to work properly. @@ -105,19 +119,20 @@ Without email configuration, Infisical's core functions like sign-up/login and s 1. Create an account and configure [Mailgun](https://www.mailgun.com) to send emails. 2. Obtain your Mailgun credentials in Sending > Overview > SMTP - ![obtain mailhog api key estriction](../../images/self-hosting/configuration/email/email-mailhog-credentials.png) +![obtain mailhog api key estriction](../../images/self-hosting/configuration/email/email-mailhog-credentials.png) - 3. With your Mailgun credentials, you can now set up your SMTP environment variables: +3. With your Mailgun credentials, you can now set up your SMTP environment variables: + +``` +SMTP_HOST=smtp.mailgun.org # obtained from credentials page +SMTP_USERNAME=postmaster@example.mailgun.org # obtained from credentials page +SMTP_PASSWORD=password # obtained from credentials page +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` - ``` - SMTP_HOST=smtp.mailgun.org # obtained from credentials page - SMTP_USERNAME=postmaster@example.mailgun.org # obtained from credentials page - SMTP_PASSWORD=password # obtained from credentials page - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails - SMTP_FROM_NAME=Infisical - ``` @@ -149,6 +164,7 @@ Without email configuration, Infisical's core functions like sign-up/login and s SMTP_FROM_NAME=Infisical ``` + @@ -160,30 +176,32 @@ Without email configuration, Infisical's core functions like sign-up/login and s 1. Create an account and configure [SocketLabs](https://www.socketlabs.com/) to send emails. 2. From the dashboard, navigate to SMTP Credentials > SMTP & APIs > SMTP Credentials to obtain your SocketLabs SMTP credentials. - ![opening SocketLabs dashboard](../../images/self-hosting/configuration/email/email-socketlabs-dashboard.png) +![opening SocketLabs dashboard](../../images/self-hosting/configuration/email/email-socketlabs-dashboard.png) - ![obtaining SocketLabs credentials](../../images/self-hosting/configuration/email/email-socketlabs-credentials.png) +![obtaining SocketLabs credentials](../../images/self-hosting/configuration/email/email-socketlabs-credentials.png) - 3. With your SocketLabs SMTP credentials, you can now set up your SMTP environment variables: +3. With your SocketLabs SMTP credentials, you can now set up your SMTP environment variables: - ``` - SMTP_HOST=smtp.socketlabs.com - SMTP_USERNAME=username # obtained from your credentials - SMTP_PASSWORD=password # obtained from your credentials - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails - SMTP_FROM_NAME=Infisical - ``` +``` +SMTP_HOST=smtp.socketlabs.com +SMTP_USERNAME=username # obtained from your credentials +SMTP_PASSWORD=password # obtained from your credentials +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` - - The `SMTP_FROM_ADDRESS` environment variable should be an email for an - authenticated domain under Configuration > Domain Management in SocketLabs. - For example, if you're using SocketLabs in sandbox mode, then you may use an - email like `team@sandbox.socketlabs.dev`. - +{" "} - ![SocketLabs domain management](../../images/self-hosting/configuration/email/email-socketlabs-domains.png) + + The `SMTP_FROM_ADDRESS` environment variable should be an email for an + authenticated domain under Configuration > Domain Management in SocketLabs. + For example, if you're using SocketLabs in sandbox mode, then you may use an + email like `team@sandbox.socketlabs.dev`. + + +![SocketLabs domain management](../../images/self-hosting/configuration/email/email-socketlabs-domains.png) Remember that you will need to restart Infisical for this to work properly. @@ -194,55 +212,57 @@ Without email configuration, Infisical's core functions like sign-up/login and s 1. Create an account on [Resend](https://resend.com). 2. Add a [Domain](https://resend.com/domains). - ![adding resend domain](../../images/self-hosting/configuration/email/email-resend-create-domain.png) +![adding resend domain](../../images/self-hosting/configuration/email/email-resend-create-domain.png) - 3. Create an [API Key](https://resend.com/api-keys). +3. Create an [API Key](https://resend.com/api-keys). - ![creating resend api key](../../images/self-hosting/configuration/email/email-resend-create-key.png) +![creating resend api key](../../images/self-hosting/configuration/email/email-resend-create-key.png) - 4. Go to the [SMTP page](https://resend.com/settings/smtp) and copy the values. +4. Go to the [SMTP page](https://resend.com/settings/smtp) and copy the values. - ![go to resend smtp settings](../../images/self-hosting/configuration/email/email-resend-smtp-settings.png) +![go to resend smtp settings](../../images/self-hosting/configuration/email/email-resend-smtp-settings.png) - 5. With the API Key, you can now set your SMTP environment variables variables: +5. With the API Key, you can now set your SMTP environment variables variables: + +``` +SMTP_HOST=smtp.resend.com +SMTP_USERNAME=resend +SMTP_PASSWORD=YOUR_API_KEY +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` - ``` - SMTP_HOST=smtp.resend.com - SMTP_USERNAME=resend - SMTP_PASSWORD=YOUR_API_KEY - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails - SMTP_FROM_NAME=Infisical - ``` Remember that you will need to restart Infisical for this to work properly. + Create an account and enable "less secure app access" in Gmail Account Settings > Security. This will allow applications like Infisical to authenticate with Gmail via your username and password. - ![Gmail secure app access](../../images/self-hosting/configuration/email/email-gmail-app-access.png) +![Gmail secure app access](../../images/self-hosting/configuration/email/email-gmail-app-access.png) - With your Gmail username and password, you can set your SMTP environment variables: +With your Gmail username and password, you can set your SMTP environment variables: - ``` - SMTP_HOST=smtp.gmail.com - SMTP_USERNAME=hey@gmail.com # your email - SMTP_PASSWORD=password # your password - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@gmail.com - SMTP_FROM_NAME=Infisical - ``` +``` +SMTP_HOST=smtp.gmail.com +SMTP_USERNAME=hey@gmail.com # your email +SMTP_PASSWORD=password # your password +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@gmail.com +SMTP_FROM_NAME=Infisical +``` As per the [notice](https://support.google.com/accounts/answer/6010255?hl=en) by Google, you should note that using Gmail credentials for SMTP configuration will only work for Google Workspace or Google Cloud Identity customers as of May 30, 2022. - Put differently, the SMTP configuration is only possible with business (not personal) Gmail credentials. +Put differently, the SMTP configuration is only possible with business (not personal) Gmail credentials. @@ -250,54 +270,59 @@ Without email configuration, Infisical's core functions like sign-up/login and s 1. Create an account and configure [Office365](https://www.office.com/) to send emails. - 2. With your login credentials, you can now set up your SMTP environment variables: +2. With your login credentials, you can now set up your SMTP environment variables: + +``` +SMTP_HOST=smtp.office365.com +SMTP_USERNAME=username@yourdomain.com # your username +SMTP_PASSWORD=password # your password +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=username@yourdomain.com +SMTP_FROM_NAME=Infisical +``` - ``` - SMTP_HOST=smtp.office365.com - SMTP_USERNAME=username@yourdomain.com # your username - SMTP_PASSWORD=password # your password - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=username@yourdomain.com - SMTP_FROM_NAME=Infisical - ``` 1. Create an account and configure [Zoho Mail](https://www.zoho.com/mail/) to send emails. - 2. With your email credentials, you can now set up your SMTP environment variables: +2. With your email credentials, you can now set up your SMTP environment variables: - ``` - SMTP_HOST=smtp.zoho.com - SMTP_USERNAME=username # your email - SMTP_PASSWORD=password # your password - SMTP_PORT=587 - SMTP_SECURE=true - SMTP_FROM_ADDRESS=hey@example.com # your personal Zoho email or domain-based email linked to Zoho Mail - SMTP_FROM_NAME=Infisical - ``` +``` +SMTP_HOST=smtp.zoho.com +SMTP_USERNAME=username # your email +SMTP_PASSWORD=password # your password +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your personal Zoho email or domain-based email linked to Zoho Mail +SMTP_FROM_NAME=Infisical +``` - - You can use either your personal Zoho email address like `you@zohomail.com` or - a domain-based email address like `you@yourdomain.com`. If using a - domain-based email address, then please make sure that you've configured and - verified it with Zoho Mail. - +{" "} + + + You can use either your personal Zoho email address like `you@zohomail.com` or + a domain-based email address like `you@yourdomain.com`. If using a + domain-based email address, then please make sure that you've configured and + verified it with Zoho Mail. + Remember that you will need to restart Infisical for this to work properly. +## Authentication - - - -## SSO based login By default, users can only login via email/password based login method. To login into Infisical with OAuth providers such as Google, configure the associated variables. + + + When set, all visits to the Infisical login page will automatically redirect users of your Infisical instance to the SAML identity provider associated with the specified organization slug. + + Follow detailed guide to configure [Google SSO](/documentation/platform/sso/google) @@ -335,33 +360,34 @@ To login into Infisical with OAuth providers such as Google, configure the assoc - Requires enterprise license. Please contact team@infisical.com to get more information. + Requires enterprise license. Please contact team@infisical.com to get more + information. - Requires enterprise license. Please contact team@infisical.com to get more information. + Requires enterprise license. Please contact team@infisical.com to get more + information. - Requires enterprise license. Please contact team@infisical.com to get more information. + Requires enterprise license. Please contact team@infisical.com to get more + information. - - Configure SAML organization slug to automatically redirect all users of your Infisical instance to the identity provider. - - - - - - ## Native secret integrations + To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box. OAuth2 client ID for Heroku integration - + OAuth2 client secret for Heroku integration @@ -371,9 +397,11 @@ To help you sync secrets from Infisical to services such as Github and Gitlab, I OAuth2 client ID for Vercel integration - - OAuth2 client secret for Vercel integration - +{" "} + + + OAuth2 client secret for Vercel integration + OAuth2 slug for Vercel integration diff --git a/docs/self-hosting/configuration/requirements.mdx b/docs/self-hosting/configuration/requirements.mdx index 2e31ac853..c0e9cab01 100644 --- a/docs/self-hosting/configuration/requirements.mdx +++ b/docs/self-hosting/configuration/requirements.mdx @@ -1,5 +1,5 @@ --- -title: "Requirements" +title: "Hardware requirements" description: "Find out the minimal requirements for operating Infisical." --- diff --git a/docs/self-hosting/deployment-options/docker-compose.mdx b/docs/self-hosting/deployment-options/docker-compose.mdx index 291a91370..d61879255 100644 --- a/docs/self-hosting/deployment-options/docker-compose.mdx +++ b/docs/self-hosting/deployment-options/docker-compose.mdx @@ -2,8 +2,7 @@ title: "Docker Compose" description: "Read how to run Infisical with Docker Compose template." --- -Install Infisical using Docker compose. This self hosting method contains all of the required components needed -to run a functional instance of Infisical. +This self hosting guide will walk you though the steps to self host Infisical using Docker compose. ## Prerequisites - [Docker](https://docs.docker.com/engine/install/) @@ -12,7 +11,7 @@ to run a functional instance of Infisical. This Docker Compose configuration is not designed for high-availability production scenarios. It includes just the essential components needed to set up an Infisical proof of concept (POC). -Additional configuration is required to enhance data redundancy and ensure higher availability for production environments. +To run Infisical in a highly available manner, give the [Docker Swarm guide](/self-hosting/deployment-options/docker-swarm). ## Verify prerequisites @@ -80,4 +79,4 @@ docker-compose -f docker-compose.prod.yml up Your Infisical instance should now be running on port `80`. To access your instance, visit `http://localhost:80`. -![self host sign up](/images/self-hosting/applicable-to-all/selfhost-signup.png) \ No newline at end of file +![self host sign up](/images/self-hosting/applicable-to-all/selfhost-signup.png) diff --git a/docs/self-hosting/deployment-options/docker-swarm.mdx b/docs/self-hosting/deployment-options/docker-swarm.mdx new file mode 100644 index 000000000..c63aff23b --- /dev/null +++ b/docs/self-hosting/deployment-options/docker-swarm.mdx @@ -0,0 +1,216 @@ +--- +title: "Docker Swarm" +description: "How to self Infisical with Docker Swarm (HA)." +--- + +# Self-Hosting Infisical with Docker Swarm + +This guide will provide step-by-step instructions on how to self-host Infisical using Docker Swarm. This is particularly helpful for those wanting to self host Infisical on premise while still maintaining high availability (HA) for the core Infisical components. +The guide will demonstrate a setup with three nodes, ensuring that the cluster can tolerate the failure of one node while remaining fully operational. + +## Docker Swarm + +[Docker Swarm](https://docs.docker.com/engine/swarm/) is a native clustering and orchestration solution for Docker containers. +It simplifies the deployment and management of containerized applications across multiple nodes, making it a great choice for self-hosting Infisical. + +Unlike Kubernetes, which requires a deep understanding of the Kubernetes ecosystem, if you're accustomed to Docker and Docker Compose, you're already familiar with most of Docker Swarm. +For this reason, we suggest teams use Docker Swarm to deploy Infisical in a highly available and fault tolerant manner. + +## Prerequisites +- Understanding of Docker Swarm +- Bare/Virtual Machines with Docker installed on each VM. +- Docker Swarm initialized on the VMs. + +## Core Components for High Availability + +The provided Docker stack includes the following core components to achieve high availability: + +1. **Spilo**: [Spilo](https://github.com/zalando/spilo) is used to run PostgreSQL with [Patroni](https://github.com/zalando/patroni) for HA and automatic failover. It utilizes etcd for leader election of the PostgreSQL instances. + +2. **Redis**: Redis is used for caching and is set up with Redis Sentinel for HA. +The stack includes three Redis replicas and three Redis Sentinel instances for monitoring and failover. + +3. **Infisical**: Infisical is stateless, allowing for easy scaling and replication across multiple nodes. + +4. **HAProxy**: HAProxy is used as a load balancer to distribute traffic to the PostgreSQL and Redis instances. +It is configured to perform health checks and route requests to the appropriate backend services. + +## Node Failure Tolerance + +To ensure Infisical is highly available and fault tolerant, it's important to choose the number of nodes in the cluster. +The following table shows the relationship between the number of nodes and the maximum number of nodes that can be down while the cluster continues to function: + +| Total Nodes | Max Nodes Down | Min Nodes Required | +|-------------|----------------|-------------------| +| 1 | 0 | 1 | +| 2 | 0 | 2 | +| 3 | 1 | 2 | +| 4 | 1 | 3 | +| 5 | 2 | 3 | +| 6 | 2 | 4 | +| 7 | 3 | 4 | + +The formula for calculating the minimum number of nodes required is: `floor(n/2) + 1`, where `n` is the total number of nodes. + +This guide will demonstrate a setup with three nodes, which allows for one node to be down while the cluster remains operational. This fault tolerance applies to the following components: + +- Redis Sentinel: With three Sentinel instances, one instance can be down, and the remaining two can still form a quorum to make decisions. +- Redis: With three Redis instances (one master and two replicas), one instance can be down, and the remaining two can continue to provide caching services. +- PostgreSQL: With three PostgreSQL instances managed by Patroni and etcd, one instance can be down, and the remaining two can maintain data consistency and availability. +- Manager Nodes: In a Docker Swarm cluster with three manager nodes, one manager node can be down, and the remaining two can continue to manage the cluster. +For the sake of simplicity, the example in this guide only contains one manager node. + +It's important to note that while the cluster can tolerate the failure of one node in a three-node setup, it's recommended to have a minimum of three nodes to ensure high availability. +With two nodes, the failure of a single node can result in a loss of quorum and potential downtime. + +## Docker Deployment Stack Overview + +The [Docker stack file](https://github.com/Infisical/infisical/tree/main/docker-swarm) used in this guide defines the services and their configurations for deploying Infisical in a highly available manner. The main components of this stack are as follows. + +1. **HAProxy**: The HAProxy service is configured to expose ports for accessing PostgreSQL (5433 for the master, 5434 for replicas), Redis master (6379), and the Infisical backend (8080). It uses a config file (`haproxy.cfg`) to define the load balancing and health check rules. + +2. **Infisical**: The Infisical backend service is deployed with the latest PostgreSQL-compatible image. It is connected to the `infisical` network and uses secrets for environment variables. + +3. **etcd**: Three etcd instances (etcd1, etcd2, etcd3) are deployed, one on each node, to provide distributed key-value storage for leader election and configuration management. + +4. **Spilo**: Three Spilo instances (spolo1, spolo2, spolo3) are deployed, one on each node, to run PostgreSQL with Patroni for high availability. They are connected to the `infisical` network and use persistent volumes for data storage. + +5. **Redis**: Three Redis instances (redis_replica0, redis_replica1, redis_replica2) are deployed, one on each node, with redis_replica0 acting as the master. They are connected to the `infisical` network. + +6. **Redis Sentinel**: Three Redis Sentinel instances (redis_sentinel1, redis_sentinel2, redis_sentinel3) are deployed, one on each node, to monitor and manage the Redis instances. They are connected to the `infisical` network. + +## Deployment instructions + + + + ``` + docker swarm init + ``` + + Replace `` with the IP address of the VM that will serve as the manager node. Remember to copy the join token returned by the this init command. + + + For the sake of simplicity, we only use one manager node in this example deployment. However, in production settings, we recommended you have at least 3 manager nodes. + + + + + ``` + docker swarm join --token :2377 + ``` + + Replace `` with the token provided by the manager node during initialization. + + + + + Labels on nodes will help us select where stateful components such as Postgres and Redis are deployed on. To label nodes, follow the steps below. + + ``` + docker node update --label-add name=node1 + docker node update --label-add name=node2 + docker node update --label-add name=node3 + ``` + + Replace ``, ``, and `` with the respective node IDs. + To view the list of nodes and their ids, run the following on the manager node `docker node ls`. + + + + + Copy the Docker stack YAML file, HAProxy configuration file and example `.env` file to the manager node. Ensure that all 3 files are placed in the same file directory. + - [Docker stack file](https://github.com/Infisical/infisical/blob/main/docker-swarm/stack.yaml) (rename to infisical-stack.yaml) + - [HA configuration file](https://github.com/Infisical/infisical/blob/main/docker-swarm/haproxy.cfg) (rename to haproxy.cfg) + - [Example .env file](https://github.com/Infisical/infisical/blob/main/docker-swarm/.env-example) (rename to .env) + + + + + ``` + docker stack deploy -c infisical-stack.yaml infisical + ``` + + + + ```plain + $ docker service ls + ID NAME MODE REPLICAS IMAGE PORTS + 4kzq3ub8qgn9 infisical_etcd1 replicated 1/1 ghcr.io/zalando/spilo-16:3.2-p2 + tqx9t82bn8d9 infisical_etcd2 replicated 1/1 ghcr.io/zalando/spilo-16:3.2-p2 + t8vbkrasy8fz infisical_etcd3 replicated 1/1 ghcr.io/zalando/spilo-16:3.2-p2 + 77iei42fcf6q infisical_haproxy global 4/4 haproxy:latest *:5002-5003->5433-5434/tcp, *:6379->6379/tcp, *:7001->7000/tcp, *:8080->8080/tcp + jaewzqy8md56 infisical_infisical replicated 5/5 infisical/infisical:v0.60.1-postgres + 58w4zablfbtb infisical_redis_replica0 replicated 1/1 bitnami/redis:6.2.10 + w4yag2whq0un infisical_redis_replica1 replicated 1/1 bitnami/redis:6.2.10 + w03mriy0jave infisical_redis_replica2 replicated 1/1 bitnami/redis:6.2.10 + ppo6rk47hc9t infisical_redis_sentinel1 replicated 1/1 bitnami/redis-sentinel:6.2.10 + ub29vd0lnq7f infisical_redis_sentinel2 replicated 1/1 bitnami/redis-sentinel:6.2.10 + szg3yky7yji2 infisical_redis_sentinel3 replicated 1/1 bitnami/redis-sentinel:6.2.10 + eqtocpf5tiy0 infisical_spolo1 replicated 1/1 ghcr.io/zalando/spilo-16:3.2-p2 + 3lznscvk7k5t infisical_spolo2 replicated 1/1 ghcr.io/zalando/spilo-16:3.2-p2 + v04ml7rz2j5q infisical_spolo3 replicated 1/1 ghcr.io/zalando/spilo-16:3.2-p2 + ``` + + + You'll notice that service `infisical_infisical` will not be in running state. + This is expected as the database does not yet have the desired schemas. + Once the database schema migrations have been successfully applied, this issue should be resolved. + + + + + Run the schema migration to initialize the database. Follow the [guide here](/self-hosting/configuration/schema-migrations) to learn how. + + To connect to the Postgres database, use the following default credentials defined in the Docker swarm: username: `postgres`, password: `postgres` and database: `postgres`. + + + + ![HA Proxy stats](/images/self-hosting/deployment-options/docker-swarm/ha-proxy-ha.png) + To view the health of services in your Infisical cluster, visit port `:7001` of any node in your Docker swarm. + This port will expose the HA Proxy stats. + + Run the following command to view the IPs of the nodes in your docker swarm. + + ```plain + $ docker node ls + ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION + 0jnegl4gpo235l66nglcwc07t localhost Ready Active 26.0.2 + no1a7zwj88057k73m196ulkq6 * localhost Ready Active Leader 26.0.2 + wcb2x27w3tq7ht4v1h7ke49qk localhost Ready Active 26.0.2 + zov5q7uop7wpxc2ndz712v9oa localhost Ready Active 26.0.2 + ``` + + + The stats page may take 1-2 minutes to become accessible. + + + + + ![self host sign up](/images/self-hosting/applicable-to-all/selfhost-signup.png) + Once all expected services are up and running, visit `:8080` of any node in the swarm. This will take you to the Infisical configuration page. + + + + +## FAQ + + To further scale and make the system more resilient, you can add more nodes to the Docker Swarm and update the stack configuration accordingly: + + 1. Add new VMs and join them to the Docker Swarm as worker nodes. + + 2. Update the Docker stack YAML file to include the new nodes in the `deploy` section of the relevant services, specifying the appropriate `node.labels.name` constraints. + + 3. Update the HAProxy configuration file (`haproxy.cfg`) to include the new nodes in the backend sections for PostgreSQL and Redis. + + 4. Redeploy the updated stack using the `docker stack deploy` command. + + Note that the database containers (PostgreSQL) are stateful and cannot be simply replicated. Instead, one database instance is deployed per node to ensure data consistency and avoid conflicts. + + + + +Native tooling for scheduled backups of Postgres and Redis is currently in development. +In the meantime, we recommend using a variety of open-source tools available for this purpose. +For Postgres, [Spilo](https://github.com/zalando/spilo) provides built-in support for scheduled data dumps. +You can explore other third party tools for managing db backups, one such tool is [docker-db-backup](https://github.com/tiredofit/docker-db-backup). + diff --git a/docs/self-hosting/deployment-options/kubernetes-helm.mdx b/docs/self-hosting/deployment-options/kubernetes-helm.mdx index d127dbfb1..8ba940fee 100644 --- a/docs/self-hosting/deployment-options/kubernetes-helm.mdx +++ b/docs/self-hosting/deployment-options/kubernetes-helm.mdx @@ -33,7 +33,7 @@ description: "Learn how to use Helm chart to install Infisical on your Kubernete pullPolicy: IfNotPresent ``` - Do you not use the latest docker image tag in production deployments as they can introduce unexpected changes + Do not use the latest docker image tag in production deployments as they can introduce unexpected changes @@ -176,7 +176,7 @@ description: "Learn how to use Helm chart to install Infisical on your Kubernete ![infisical-selfhost](/images/self-hosting/applicable-to-all/selfhost-signup.png) - To upgrade your instance of Infisical simply update the docker image tag in your Halm values and rerun the command below. + To upgrade your instance of Infisical simply update the docker image tag in your Helm values and rerun the command below. ```bash helm upgrade --install infisical infisical-helm-charts/infisical-standalone --values /path/to/values.yaml diff --git a/docs/self-hosting/reference-architectures/on-premise.mdx b/docs/self-hosting/reference-architectures/on-premise.mdx deleted file mode 100644 index 543e9e938..000000000 --- a/docs/self-hosting/reference-architectures/on-premise.mdx +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: "On-premise" -description: "Reference architecture for self-hosting Infisical on premise" ---- - -Deploying Infisical on-premise with high availability requires deep knowledge in areas like networking, container orchestration, and database management. -This guide presents a reference architecture that outlines how to achieve such a deployment effectively. -For organizations that do not have the necessary resources or expertise, we recommend opting for managed, dedicated Infisical instances or engaging professional services to mitigate the complexities. - -## System Overview -![On premise architecture](/images/self-hosting/reference-architectures/on-premise-architecture.png) - -The architecture above utilizes a combination of Kubernetes for orchestrating stateless components and virtual machines (VMs) or bare metal for stateful components. -The infrastructure spans multiple data centers for redundancy and load distribution, enhancing availability and disaster recovery capabilities. -You may duplicate the architecture in multiple data centers and join them via Consul to increase availability. This way, if one data center is out of order, active data centers will take over workloads. - -### Stateful vs stateless workloads - -To reduce the challenges of managing state within Kubernetes, including storage provisioning, persistent volume management, and intricate data backup and recovery processes, we strongly recommend deploying stateful components on Virtual Machines (VMs) or bare metal. -As depicted in the architecture, Infisical is intentionally deployed on Kubernetes to leverage its strengths in managing stateless applications. -Being stateless, Infisical fully benefits from Kubernetes' features like horizontal scaling, self-healing, and rolling updates and rollbacks. - -## Core Components - -### Kubernetes Cluster -Infisical is deployed on a Kubernetes cluster, which allows for container management, auto-scaling, and self-healing capabilities. -A load balancer sits in front of the Kubernetes cluster, directing traffic and ensuring even load distribution across the application nodes. -This is the entry point where all other services will interact with Infisical. - - -### Consul as the Networking Backbone -Consul is an critical component in the reference architecture, serving as a unified service networking layer that links and controls services across different environments and data centers. -It functions as the common communication channel between data centers for stateless applications on Kubernetes and stateful services such as databases on dedicated VMs or bare metal. - - -### Postgres with Patroni -The database layer is powered by Postgres, with [Patroni](https://patroni.readthedocs.io/en/latest/) providing automated management to create a high availability setup. Patroni leverages Consul for several critical operations: - -- **Redundancy:** By managing a cluster of one primary and multiple secondary Postgres nodes, the architecture ensures redundancy. -The primary node handles all the write operations, and secondary nodes handle read operations and are prepared to step up in case of primary failure. - -- **Failover and Service Discovery:** Consul is integrated with Patroni for service discovery and health checks. -When Patroni detects that the primary node is unhealthy, it uses Consul to elect a new primary node from the secondaries, thereby ensuring that the database service remains available. - -- **Data Center Awareness:** Patroni configured with Consul is aware of the multi-data center setup and can handle failover across data centers if necessary, which further enhances the system's availability. - -### Redis with Redis Sentinel -For caching and message brokering: - -- Redis is deployed with a primary-replica setup. -- Redis Sentinel monitors the Redis nodes, providing automatic failover and service discovery. -- Write operations go to the primary node, and replicas serve read operations, ensuring data integrity and availability. - -## Multi data center deployment -Infisical can be deployed across a number of data centers to both increase performance and resiliency to disaster scenarios. -For mission critical deployment of Infisical, we recommend deploying Infisical on at least 3 data centers to reduce downtime in the event of complete data center malfunction. - -### Data Center A -Data Center A houses the primary nodes of both Postgres and Redis, which handle all write operations. The secondary nodes and replicas serve as hot standbys for failover. Consul servers maintain the state of the cluster, elect a leader, and facilitate service discovery. - -### $n^{th}$ data center -The $n^{th}$ data center acts as a performance and disaster recovery site, featuring a mesh gateway that enables cross-data center service discovery and configuration. It houses additional secondary nodes for Postgres and Redis replicas, which are ready to be promoted in case the primary data center fails. Additionally, this data center can reduce the latency of applications that need to interact with Infisical, particularly if those applications or services are geographically closer to this data center. - -## Considerations - -The complexity of an on-premise deployment scales with the level of availability required. This reference architecture provides a robust framework for organizations aiming for high availability and disaster resilience. However, it's important to recognize that this is not a one-size-fits-all solution. - -Organizations with less stringent Recovery Time Objectives (RTO) might find that [simpler deployments methods](/self-hosting/deployment-options/docker-compose) using tools such as Docker Compose are adequate. Such setups can still provide a reasonable level of service continuity without the complexities involved in managing a multi-data center environment with Kubernetes, Consul, and other high-availability components. - -Ultimately, the choice of architecture should be guided by a thorough analysis of business needs, available resources, and expertise. \ No newline at end of file diff --git a/docs/style.css b/docs/style.css index b76d06450..3359151e4 100644 --- a/docs/style.css +++ b/docs/style.css @@ -1,7 +1,7 @@ #navbar .max-w-8xl { max-width: 100%; border-bottom: 1px solid #ebebeb; - background-color: #fcfcfc; + background-color: #F4F3EF; } .max-w-8xl { @@ -14,7 +14,7 @@ padding-right: 30px; border-right: 1px; border-color: #cdd64b; - background-color: #fcfcfc; + background-color: #F4F3EF; border-right: 1px solid #ebebeb; } @@ -27,6 +27,13 @@ padding: 5px; } +#sidebar li > a.text-primary { + border-radius: 0; + background-color: #FBFFCC; + border-left: 4px solid #EFFF33; + padding: 5px; +} + #sidebar li > a.mt-2 { border-radius: 0; padding: 5px; @@ -49,10 +56,10 @@ } */ #header { - border-left: 1px solid #26272b; + border-left: 4px solid #EFFF33; padding-left: 16px; padding-right: 16px; - background-color: #f5f5f5; + background-color: #FDFFE5; padding-bottom: 10px; padding-top: 10px; } @@ -60,9 +67,17 @@ #content-area .mt-8 .block{ border-radius: 0; border-width: 1px; + background-color: #FCFBFA; border-color: #ebebeb; } +/* #content-area:hover .mt-8 .block:hover{ + border-radius: 0; + border-width: 1px; + background-color: #FDFFE5; + border-color: #EFFF33; +} */ + #content-area .mt-8 .rounded-xl{ border-radius: 0; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e7c587f13..c33c9dc36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "frontend", "dependencies": { "@casl/ability": "^6.5.0", "@casl/react": "^3.1.0", @@ -12165,9 +12166,9 @@ "dev": true }, "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, "dependencies": { "jake": "^10.8.5" @@ -22439,9 +22440,9 @@ } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dev": true, "dependencies": { "chownr": "^2.0.0", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 451890ef9..cf90ef659 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -32,7 +32,8 @@ const integrationSlugNameMapping: Mapping = { northflank: "Northflank", windmill: "Windmill", "gcp-secret-manager": "GCP Secret Manager", - "hasura-cloud": "Hasura Cloud" + "hasura-cloud": "Hasura Cloud", + rundeck: "Rundeck" }; const envMapping: Mapping = { diff --git a/frontend/public/images/integrations/Rundeck.svg b/frontend/public/images/integrations/Rundeck.svg new file mode 100644 index 000000000..4ded97a8a --- /dev/null +++ b/frontend/public/images/integrations/Rundeck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/images/loading/loading.gif b/frontend/public/images/loading/loading.gif index f15b1b1ce..9cf0f6b6a 100644 Binary files a/frontend/public/images/loading/loading.gif and b/frontend/public/images/loading/loading.gif differ diff --git a/frontend/public/images/loading/loadingblack.gif b/frontend/public/images/loading/loadingblack.gif index 47aec0374..dd45a4824 100644 Binary files a/frontend/public/images/loading/loadingblack.gif and b/frontend/public/images/loading/loadingblack.gif differ diff --git a/frontend/scripts/initialize-standalone-build.sh b/frontend/scripts/initialize-standalone-build.sh index 859814eda..d9138bb77 100755 --- a/frontend/scripts/initialize-standalone-build.sh +++ b/frontend/scripts/initialize-standalone-build.sh @@ -4,8 +4,6 @@ scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_POSTHOG_API_KEY scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_INTERCOM_ID" "$NEXT_PUBLIC_INTERCOM_ID" -scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_SAML_ORG_SLUG" "$NEXT_PUBLIC_SAML_ORG_SLUG" - if [ "$TELEMETRY_ENABLED" != "false" ]; then echo "Telemetry is enabled" scripts/set-standalone-build-telemetry.sh true diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index 21950fc29..cecae5287 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -120,7 +120,7 @@ export default function NavHeader({ passHref legacyBehavior href={{ - pathname: "/project/[id]/secrets/v2/[env]", + pathname: "/project/[id]/secrets/[env]", query: { id: router.query.id, env: router.query.env } }} > diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx index 067ee9c4a..b3c7a4f53 100644 --- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -17,23 +17,20 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children containerClassName )} > -
-
- -
-
-
Access Restricted
- {children || ( -
- Your role has limited permissions, please
contact your administrator to gain - access -
- )} +
+
+
+ +
+
+
Access Restricted
+ {children || ( +
+ Your role has limited permissions, please
contact your administrator to gain + access +
+ )} +
diff --git a/frontend/src/components/v2/Badge/Badge.tsx b/frontend/src/components/v2/Badge/Badge.tsx new file mode 100644 index 000000000..321c03296 --- /dev/null +++ b/frontend/src/components/v2/Badge/Badge.tsx @@ -0,0 +1,32 @@ +import { cva, VariantProps } from "cva"; +import { twMerge } from "tailwind-merge"; + +interface IProps { + children: React.ReactNode; + className?: string; +} + +const badgeVariants = cva( + [ + "inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-xs text-yellow opacity-80 hover:opacity-100" + ], + { + variants: { + variant: { + primary: "bg-yellow/20 text-yellow", + danger: "bg-red/20 text-red", + success: "bg-green/20 text-green" + } + } + } +); + +export type BadgeProps = VariantProps & IProps; + +export const Badge = ({ children, className, variant }: BadgeProps) => { + return ( +
+ {children} +
+ ); +}; diff --git a/frontend/src/components/v2/Badge/index.tsx b/frontend/src/components/v2/Badge/index.tsx new file mode 100644 index 000000000..5c7042709 --- /dev/null +++ b/frontend/src/components/v2/Badge/index.tsx @@ -0,0 +1 @@ +export { Badge } from "./Badge"; diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx index 5536d699a..7707805e9 100644 --- a/frontend/src/components/v2/Button/Button.tsx +++ b/frontend/src/components/v2/Button/Button.tsx @@ -29,7 +29,7 @@ const buttonVariants = cva( colorSchema: { primary: ["bg-primary", "text-black", "border-primary bg-opacity-90 hover:bg-opacity-100"], secondary: ["bg-mineshaft", "text-gray-300", "border-mineshaft hover:bg-opacity-80"], - danger: ["bg-red", "text-white", "border-red hover:bg-opacity-90"], + danger: ["!bg-red", "!text-white", "!border-red hover:!bg-opacity-90"], gray: ["bg-bunker-500", "text-bunker-200"] }, variant: { diff --git a/frontend/src/components/v2/Divider/Divider.tsx b/frontend/src/components/v2/Divider/Divider.tsx new file mode 100644 index 000000000..39b0f84c5 --- /dev/null +++ b/frontend/src/components/v2/Divider/Divider.tsx @@ -0,0 +1,13 @@ +import { twMerge } from "tailwind-merge"; + +interface IProps { + className?: string; +} + +export const Divider = ({ className }: IProps): JSX.Element => { + return ( +
+ + ); +}; diff --git a/frontend/src/components/v2/Divider/index.tsx b/frontend/src/components/v2/Divider/index.tsx new file mode 100644 index 000000000..ac407aa37 --- /dev/null +++ b/frontend/src/components/v2/Divider/index.tsx @@ -0,0 +1 @@ +export { Divider } from "./Divider"; diff --git a/frontend/src/components/v2/FontAwesomeSymbol/FontAwesomeSymbol.tsx b/frontend/src/components/v2/FontAwesomeSymbol/FontAwesomeSymbol.tsx new file mode 100644 index 000000000..69639ae5f --- /dev/null +++ b/frontend/src/components/v2/FontAwesomeSymbol/FontAwesomeSymbol.tsx @@ -0,0 +1,19 @@ +import { forwardRef, HTMLAttributes } from "react"; + +type Props = { + symbolName: string; +} & HTMLAttributes; + +export const FontAwesomeSymbol = forwardRef( + ({ symbolName, ...props }, ref) => { + return ( +
+ + + +
+ ); + } +); + +FontAwesomeSymbol.displayName = "FontAwesomeSymbol"; diff --git a/frontend/src/components/v2/FontAwesomeSymbol/index.tsx b/frontend/src/components/v2/FontAwesomeSymbol/index.tsx new file mode 100644 index 000000000..baaa34e7e --- /dev/null +++ b/frontend/src/components/v2/FontAwesomeSymbol/index.tsx @@ -0,0 +1 @@ +export { FontAwesomeSymbol } from "./FontAwesomeSymbol"; diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index 2added878..a1bf35292 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -1,17 +1,42 @@ -import { TextareaHTMLAttributes, useEffect, useRef, useState } from "react"; +import { forwardRef, TextareaHTMLAttributes, useCallback, useMemo, useRef, useState } from "react"; import { faCircle, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as Popover from "@radix-ui/react-popover"; -import { twMerge } from "tailwind-merge"; import { useWorkspace } from "@app/context"; -import { useDebounce } from "@app/hooks"; -import { useGetFoldersByEnv, useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api"; +import { useDebounce, useToggle } from "@app/hooks"; +import { useGetProjectFolders, useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api"; import { SecretInput } from "../SecretInput"; -const REGEX_UNCLOSED_SECRET_REFERENCE = /\${(?![^{}]*\})/g; -const REGEX_OPEN_SECRET_REFERENCE = /\${/g; +const getIndexOfUnclosedRefToTheLeft = (value: string, pos: number) => { + // take substring up to pos in order to consider edits for closed references + for (let i = pos; i >= 1; i -= 1) { + if (value[i] === "}") return -1; + if (value[i - 1] === "$" && value[i] === "{") { + return i; + } + } + return -1; +}; + +const getIndexOfUnclosedRefToTheRight = (value: string, pos: number) => { + // use it with above to identify an open ${ + for (let i = pos; i < value.length; i += 1) { + if (value[i] === "}") return i - 1; + } + return -1; +}; + +const getClosingSymbol = (isSelectedSecret: boolean, isClosed: boolean) => { + if (!isClosed) { + return isSelectedSecret ? "}" : "."; + } + if (!isSelectedSecret) return "."; + return ""; +}; + +const mod = (n: number, m: number) => ((n % m) + m) % m; export enum ReferenceType { ENVIRONMENT = "environment", @@ -19,8 +44,9 @@ export enum ReferenceType { SECRET = "secret" } -type Props = TextareaHTMLAttributes & { - value?: string | null; +type Props = Omit, "onChange" | "value"> & { + value?: string; + onChange: (val: string) => void; isImport?: boolean; isVisible?: boolean; isReadOnly?: boolean; @@ -31,345 +57,298 @@ type Props = TextareaHTMLAttributes & { }; type ReferenceItem = { - name: string; + label: string; type: ReferenceType; - slug?: string; + slug: string; }; -export const InfisicalSecretInput = ({ - value: propValue, - isVisible, - containerClassName, - onBlur, - isDisabled, - isImport, - isReadOnly, - secretPath: propSecretPath, - environment: propEnvironment, - onChange, - ...props -}: Props) => { - const [inputValue, setInputValue] = useState(propValue ?? ""); - const [isSuggestionsOpen, setIsSuggestionsOpen] = useState(false); - const [currentCursorPosition, setCurrentCursorPosition] = useState(0); - const [currentReference, setCurrentReference] = useState(""); - const [secretPath, setSecretPath] = useState(propSecretPath || "/"); - const [environment, setEnvironment] = useState(propEnvironment); - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; - const { data: decryptFileKey } = useGetUserWsKey(workspaceId); - const { data: secrets } = useGetProjectSecrets({ - decryptFileKey: decryptFileKey!, - environment: environment || currentWorkspace?.environments?.[0].slug!, - secretPath, - workspaceId - }); - const { folderNames: folders } = useGetFoldersByEnv({ - path: secretPath, - environments: [environment || currentWorkspace?.environments?.[0].slug!], - projectId: workspaceId - }); +export const InfisicalSecretInput = forwardRef( + ( + { + value = "", + onChange, + containerClassName, + secretPath: propSecretPath, + environment: propEnvironment, + ...props + }, + ref + ) => { + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { data: decryptFileKey } = useGetUserWsKey(workspaceId); - const debouncedCurrentReference = useDebounce(currentReference, 100); + const debouncedValue = useDebounce(value, 500); - const [listReference, setListReference] = useState([]); - const [highlightedIndex, setHighlightedIndex] = useState(-1); - const inputRef = useRef(null); + const [highlightedIndex, setHighlightedIndex] = useState(-1); - useEffect(() => { - setInputValue(propValue ?? ""); - }, [propValue]); + const inputRef = useRef(null); + const popoverContentRef = useRef(null); + const [isFocused, setIsFocused] = useToggle(false); + const currentCursorPosition = inputRef.current?.selectionStart || 0; - useEffect(() => { - let currentEnvironment = propEnvironment; - let currentSecretPath = propSecretPath || "/"; + const suggestionSource = useMemo(() => { + const left = getIndexOfUnclosedRefToTheLeft(debouncedValue, currentCursorPosition - 1); + if (left === -1) return { left, value: "", predicate: "", isDeep: false }; - if (!currentReference) { - setSecretPath(currentSecretPath); - setEnvironment(currentEnvironment); - return; - } + const suggestionSourceValue = debouncedValue.slice(left + 1, currentCursorPosition); + let suggestionSourceEnv: string | undefined = propEnvironment; + let suggestionSourceSecretPath: string | undefined = propSecretPath || "/"; - const isNested = currentReference.includes("."); + // means its like ..<...more folder>.secret + const isDeep = suggestionSourceValue.includes("."); + let predicate = suggestionSourceValue; + if (isDeep) { + const [envSlug, ...folderPaths] = suggestionSourceValue.split("."); + const isValidEnvSlug = currentWorkspace?.environments.find((e) => e.slug === envSlug); + suggestionSourceEnv = isValidEnvSlug ? envSlug : undefined; + suggestionSourceSecretPath = `/${folderPaths.slice(0, -1)?.join("/")}`; + predicate = folderPaths[folderPaths.length - 1]; + } - if (isNested) { - const [envSlug, ...folderPaths] = currentReference.split("."); - const isValidEnvSlug = currentWorkspace?.environments.find((e) => e.slug === envSlug); - currentEnvironment = isValidEnvSlug ? envSlug : undefined; + return { + left: left + 1, + // the full value inside a ${} + value: suggestionSourceValue, + // the final part after staging.dev.. + predicate, + isOpen: left !== -1, + isDeep, + environment: suggestionSourceEnv, + secretPath: suggestionSourceSecretPath + }; + }, [debouncedValue]); - // should be based on the last valid section (with .) - folderPaths.pop(); - currentSecretPath = `/${folderPaths?.join("/")}`; - } - - setSecretPath(currentSecretPath); - setEnvironment(currentEnvironment); - }, [debouncedCurrentReference]); - - useEffect(() => { - const currentListReference: ReferenceItem[] = []; - const isNested = currentReference?.includes("."); - - if (!currentReference) { - setListReference(currentListReference); - return; - } - - if (!environment) { - currentWorkspace?.environments.forEach((env) => { - currentListReference.unshift({ - name: env.slug, - type: ReferenceType.ENVIRONMENT - }); - }); - } else if (isNested) { - folders?.forEach((folder) => { - currentListReference.unshift({ name: folder, type: ReferenceType.FOLDER }); - }); - } else if (environment) { - currentWorkspace?.environments.forEach((env) => { - currentListReference.unshift({ - name: env.slug, - type: ReferenceType.ENVIRONMENT - }); - }); - } - - secrets?.forEach((secret) => { - currentListReference.unshift({ name: secret.key, type: ReferenceType.SECRET }); + const isPopupOpen = Boolean(suggestionSource.isOpen) && isFocused; + const { data: secrets } = useGetProjectSecrets({ + decryptFileKey: decryptFileKey!, + environment: suggestionSource.environment || "", + secretPath: suggestionSource.secretPath || "", + workspaceId, + options: { + enabled: isPopupOpen + } }); - - // Get fragment inside currentReference - const searchFragment = isNested ? currentReference.split(".").pop() || "" : currentReference; - const filteredListRef = currentListReference - .filter((suggestionEntry) => - suggestionEntry.name.toUpperCase().startsWith(searchFragment.toUpperCase()) - ) - .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); - - setListReference(filteredListRef); - }, [secrets, environment, debouncedCurrentReference]); - - const getIndexOfUnclosedRefToTheLeft = (pos: number) => { - // take substring up to pos in order to consider edits for closed references - const unclosedReferenceIndexMatches = [ - ...inputValue.substring(0, pos).matchAll(REGEX_UNCLOSED_SECRET_REFERENCE) - ].map((match) => match.index); - - // find unclosed reference index less than the current cursor position - let indexIter = -1; - unclosedReferenceIndexMatches.forEach((index) => { - if (index !== undefined && index > indexIter && index < pos) { - indexIter = index; + const { data: folders } = useGetProjectFolders({ + environment: suggestionSource.environment || "", + path: suggestionSource.secretPath || "", + projectId: workspaceId, + options: { + enabled: isPopupOpen } }); - return indexIter; - }; + const suggestions = useMemo(() => { + if (!isPopupOpen) return []; + // reset highlight whenever recomputation happens + setHighlightedIndex(-1); + const suggestionsArr: ReferenceItem[] = []; + const predicate = suggestionSource.predicate.toLowerCase(); - const getIndexOfUnclosedRefToTheRight = (pos: number) => { - const unclosedReferenceIndexMatches = [...inputValue.matchAll(REGEX_OPEN_SECRET_REFERENCE)].map( - (match) => match.index - ); - - // find the next unclosed reference index to the right of the current cursor position - // this is so that we know the limitation for slicing references - let indexIter = Infinity; - unclosedReferenceIndexMatches.forEach((index) => { - if (index !== undefined && index > pos && index < indexIter) { - indexIter = index; + if (!suggestionSource.isDeep) { + // At first level only environments and secrets + (currentWorkspace?.environments || []).forEach(({ name, slug }) => { + if (name.toLowerCase().startsWith(predicate)) + suggestionsArr.push({ + label: name, + slug, + type: ReferenceType.ENVIRONMENT + }); + }); + } else { + // one deeper levels its based on an environment folders and secrets + (folders || []).forEach(({ name }) => { + if (name.toLowerCase().startsWith(predicate)) + suggestionsArr.push({ + label: name, + slug: name, + type: ReferenceType.FOLDER + }); + }); } - }); + (secrets || []).forEach(({ key }) => { + if (key.toLowerCase().startsWith(predicate)) + suggestionsArr.push({ + label: key, + slug: key, + type: ReferenceType.SECRET + }); + }); + return suggestionsArr; + }, [secrets, folders, currentWorkspace?.environments, isPopupOpen, suggestionSource.value]); - return indexIter; - }; + const handleSuggestionSelect = (selectIndex?: number) => { + const selectedSuggestion = + suggestions[typeof selectIndex !== "undefined" ? selectIndex : highlightedIndex]; + if (!selectedSuggestion) { + return; + } - const handleKeyUp = (e: React.KeyboardEvent) => { - // open suggestions if current position is to the right of an unclosed secret reference - const indexIter = getIndexOfUnclosedRefToTheLeft(currentCursorPosition); - if (indexIter === -1) { - return; - } - - setIsSuggestionsOpen(true); - - if (e.key !== "Enter") { - // current reference is then going to be based on the text from the closest ${ to the right - // until the current cursor position - const openReferenceValue = inputValue.slice(indexIter + 2, currentCursorPosition); - setCurrentReference(openReferenceValue); - } - }; - - const handleSuggestionSelect = (selectedIndex?: number) => { - const selectedSuggestion = listReference[selectedIndex ?? highlightedIndex]; - - if (!selectedSuggestion) { - return; - } - - const leftIndexIter = getIndexOfUnclosedRefToTheLeft(currentCursorPosition); - const rightIndexLimit = getIndexOfUnclosedRefToTheRight(currentCursorPosition); - - if (leftIndexIter === -1) { - return; - } - - let newValue = ""; - const currentOpenRef = inputValue.slice(leftIndexIter + 2, currentCursorPosition); - if (currentOpenRef.includes(".")) { - // append suggestion after last DOT (.) - const lastDotIndex = currentReference.lastIndexOf("."); - const existingPath = currentReference.slice(0, lastDotIndex); - const refEndAfterAppending = Math.min( - leftIndexIter + - 3 + - existingPath.length + - selectedSuggestion.name.length + - Number(selectedSuggestion.type !== ReferenceType.SECRET), - rightIndexLimit - 1 + const rightBracketIndex = getIndexOfUnclosedRefToTheRight(value, suggestionSource.left); + const isEnclosed = rightBracketIndex !== -1; + // ${} + const lhsValue = value.slice(0, suggestionSource.left); + const rhsValue = value.slice( + rightBracketIndex !== -1 ? rightBracketIndex + 1 : currentCursorPosition + ); + // mid will be computed value inside the interpolation + const mid = suggestionSource.isDeep + ? `${suggestionSource.value.slice(0, -suggestionSource.predicate.length || undefined)}${selectedSuggestion.slug + }` + : selectedSuggestion.slug; + // whether we should append . or closing bracket on selecting suggestion + const closingSymbol = getClosingSymbol( + selectedSuggestion.type === ReferenceType.SECRET, + isEnclosed ); - newValue = `${inputValue.slice(0, leftIndexIter + 2)}${existingPath}.${ - selectedSuggestion.name - }${selectedSuggestion.type !== ReferenceType.SECRET ? "." : "}"}${inputValue.slice( - refEndAfterAppending - )}`; - const openReferenceValue = newValue.slice(leftIndexIter + 2, refEndAfterAppending); - setCurrentReference(openReferenceValue); + const newValue = `${lhsValue}${mid}${closingSymbol}${rhsValue}`; + onChange?.(newValue); + // this delay is for cursor adjustment + // cannot do this without a delay because what happens in onChange gets propogated after the cursor change + // Thus the cursor goes last to avoid that we put a slight delay on cursor change to make it happen later + const delay = setTimeout(() => { + clearTimeout(delay); + if (inputRef.current) + inputRef.current.selectionEnd = + lhsValue.length + + mid.length + + closingSymbol.length + + (isEnclosed && selectedSuggestion.type === ReferenceType.SECRET ? 1 : 0); // if secret is selected the cursor should move after the closing bracket -> } + }, 10); + setHighlightedIndex(-1); // reset highlight + }; - // add 1 in order to prevent referenceOpen from being triggered by handleKeyUp - setCurrentCursorPosition(refEndAfterAppending + 1); - } else { - // append selectedSuggestion at position after unclosed ${ - const refEndAfterAppending = Math.min( - selectedSuggestion.name.length + - leftIndexIter + - 2 + - Number(selectedSuggestion.type !== ReferenceType.SECRET), - rightIndexLimit - 1 - ); + const handleKeyDown = (e: React.KeyboardEvent) => { + // key operation should trigger only when popup is open + if (isPopupOpen) { + if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { + setHighlightedIndex((prevIndex) => { + const pos = mod(prevIndex + 1, suggestions.length); + popoverContentRef.current?.children?.[pos]?.scrollIntoView({ + block: "nearest", + behavior: "smooth" + }); + return pos; + }); + } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { + setHighlightedIndex((prevIndex) => { + const pos = mod(prevIndex - 1, suggestions.length); + popoverContentRef.current?.children?.[pos]?.scrollIntoView({ + block: "nearest", + behavior: "smooth" + }); + return pos; + }); + } else if (e.key === "Enter" && highlightedIndex >= 0) { + e.preventDefault(); + handleSuggestionSelect(); + } + if (["ArrowDown", "ArrowUp", "Tab"].includes(e.key)) { + e.preventDefault(); + } + } + }; - newValue = `${inputValue.slice(0, leftIndexIter + 2)}${selectedSuggestion.name}${ - selectedSuggestion.type !== ReferenceType.SECRET ? "." : "}" - }${inputValue.slice(refEndAfterAppending)}`; + const handlePopUpOpen = () => { + setHighlightedIndex(-1); + }; - const openReferenceValue = newValue.slice(leftIndexIter + 2, refEndAfterAppending); - setCurrentReference(openReferenceValue); - setCurrentCursorPosition(refEndAfterAppending); - } + // to handle multiple ref for single component + const handleRef = useCallback((el: HTMLTextAreaElement) => { + // @ts-expect-error this is for multiple ref single component + inputRef.current = el; + if (ref) { + if (typeof ref === "function") { + ref(el); + } else { + // eslint-disable-next-line + ref.current = el; + } + } + }, []); - onChange?.({ target: { value: newValue } } as any); - setInputValue(newValue); - setHighlightedIndex(-1); - setIsSuggestionsOpen(false); - }; + return ( + + + setIsFocused.on()} + onBlur={(evt) => { + // should not on blur when its mouse down selecting a item from suggestion + if (!(evt.relatedTarget?.getAttribute("aria-label") === "suggestion-item")) + setIsFocused.off(); + }} + onChange={(e) => onChange?.(e.target.value)} + containerClassName={containerClassName} + /> + + e.preventDefault()} + className="relative top-2 z-[100] max-h-64 overflow-auto rounded-md border border-mineshaft-600 bg-mineshaft-900 font-inter text-bunker-100 shadow-md" + style={{ + width: "var(--radix-popover-trigger-width)" + }} + > +
+ {suggestions.map((item, i) => { + let entryIcon; + if (item.type === ReferenceType.SECRET) { + entryIcon = faKey; + } else if (item.type === ReferenceType.ENVIRONMENT) { + entryIcon = faCircle; + } else { + entryIcon = faFolder; + } - const handleKeyDown = (e: React.KeyboardEvent) => { - const mod = (n: number, m: number) => ((n % m) + m) % m; - if (e.key === "ArrowDown") { - setHighlightedIndex((prevIndex) => mod(prevIndex + 1, listReference.length)); - } else if (e.key === "ArrowUp") { - setHighlightedIndex((prevIndex) => mod(prevIndex - 1, listReference.length)); - } else if (e.key === "Enter" && highlightedIndex >= 0) { - handleSuggestionSelect(); - } - if (["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) { - e.preventDefault(); - } - }; - - const setIsOpen = (isOpen: boolean) => { - setHighlightedIndex(-1); - - if (isSuggestionsOpen) { - setIsSuggestionsOpen(isOpen); - } - }; - - const handleSecretChange = (e: any) => { - // propagate event to react-hook-form onChange - if (onChange) { - onChange(e); - } - - setCurrentCursorPosition(inputRef.current?.selectionStart || 0); - setInputValue(e.target.value); - }; - - return ( - 0 && currentReference.length > 0} - onOpenChange={setIsOpen} - > - - - - e.preventDefault()} - className={twMerge( - "relative top-2 z-[100] overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 font-inter text-bunker-100 shadow-md" - )} - style={{ - width: "var(--radix-popover-trigger-width)", - maxHeight: "var(--radix-select-content-available-height)" - }} - > -
- {listReference.map((item, i) => { - let entryIcon; - if (item.type === ReferenceType.SECRET) { - entryIcon = faKey; - } else if (item.type === ReferenceType.ENVIRONMENT) { - entryIcon = faCircle; - } else { - entryIcon = faFolder; - } - - return ( -
{ - e.preventDefault(); - setHighlightedIndex(i); - handleSuggestionSelect(i); - }} - style={{ pointerEvents: "auto" }} - className="flex items-center justify-between border-mineshaft-600 text-left" - key={`secret-reference-secret-${i + 1}`} - > + return (
{ + if (e.key === "Enter") handleSuggestionSelect(i); + }} + aria-label="suggestion-item" + onClick={(e) => { + inputRef.current?.focus(); + e.preventDefault(); + e.stopPropagation(); + handleSuggestionSelect(i); + }} + onMouseEnter={() => setHighlightedIndex(i)} + style={{ pointerEvents: "auto" }} + className="flex items-center justify-between border-mineshaft-600 text-left" + key={`secret-reference-secret-${i + 1}`} > -
-
- +
+
+
+ +
+
{item.label}
-
{item.name}
-
- ); - })} -
- - - ); -}; + ); + })} +
+ + + ); + } +); InfisicalSecretInput.displayName = "InfisicalSecretInput"; diff --git a/frontend/src/components/v2/NoticeBanner/NoticeBanner.tsx b/frontend/src/components/v2/NoticeBanner/NoticeBanner.tsx new file mode 100644 index 000000000..cf32427a3 --- /dev/null +++ b/frontend/src/components/v2/NoticeBanner/NoticeBanner.tsx @@ -0,0 +1,26 @@ +import { ReactNode } from "react"; +import { faWarning, IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +type Props = { + icon?: IconDefinition; + title: string; + children: ReactNode; + className?: string; +}; + +export const NoticeBanner = ({ icon = faWarning, title, children, className }: Props) => ( +
+ +
+
{title}
+
{children}
+
+
+); diff --git a/frontend/src/components/v2/NoticeBanner/index.tsx b/frontend/src/components/v2/NoticeBanner/index.tsx new file mode 100644 index 000000000..0006c75a5 --- /dev/null +++ b/frontend/src/components/v2/NoticeBanner/index.tsx @@ -0,0 +1 @@ +export { NoticeBanner } from "./NoticeBanner"; diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index eed5867e3..5e7bbef78 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -41,7 +41,7 @@ const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport? // akhilmhdh: Dont remove this br. I am still clueless how this works but weirdly enough // when break is added a line break works properly - return formattedContent.concat(
); + return formattedContent.concat(
); }; type Props = TextareaHTMLAttributes & { @@ -90,7 +90,10 @@ export const SecretInput = forwardRef( aria-label="secret value" ref={ref} className={`absolute inset-0 block h-full resize-none overflow-hidden bg-transparent text-transparent no-scrollbar focus:border-0 ${commonClassName}`} - onFocus={() => setIsSecretFocused.on()} + onFocus={(evt) => { + onFocus?.(evt); + setIsSecretFocused.on(); + }} disabled={isDisabled} spellCheck={false} onBlur={(evt) => { diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index 1487cb302..9dfb5ff62 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -31,6 +31,7 @@ export const SecretPathInput = ({ const [inputValue, setInputValue] = useState(propValue ?? ""); const [secretPath, setSecretPath] = useState("/"); const [suggestions, setSuggestions] = useState([]); + const [isInputFocused, setIsInputFocus] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(-1); const debouncedInputValue = useDebounce(inputValue, 200); @@ -46,14 +47,6 @@ export const SecretPathInput = ({ setInputValue(propValue ?? "/"); }, [propValue]); - useEffect(() => { - if (environment) { - setInputValue("/"); - setSecretPath("/"); - onChange?.("/"); - } - }, [environment]); - useEffect(() => { // update secret path if input is valid if ( @@ -63,7 +56,9 @@ export const SecretPathInput = ({ ) { setSecretPath(debouncedInputValue); } + }, [debouncedInputValue]); + useEffect(() => { // filter suggestions based on matching const searchFragment = debouncedInputValue.split("/").pop() || ""; const filteredSuggestions = folders @@ -73,7 +68,7 @@ export const SecretPathInput = ({ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); setSuggestions(filteredSuggestions); - }, [debouncedInputValue]); + }, [debouncedInputValue, folders]); const handleSuggestionSelect = (selectedIndex: number) => { if (!suggestions[selectedIndex]) { @@ -83,7 +78,7 @@ export const SecretPathInput = ({ const validPaths = inputValue.split("/"); validPaths.pop(); - const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}`; + const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}/`; onChange?.(newValue); setInputValue(newValue); setSecretPath(newValue); @@ -116,7 +111,7 @@ export const SecretPathInput = ({ return ( 0 && inputValue.length > 1} + open={suggestions.length > 0 && isInputFocused} onOpenChange={() => { setHighlightedIndex(-1); }} @@ -127,6 +122,8 @@ export const SecretPathInput = ({ type="text" autoComplete="off" onKeyDown={handleKeyDown} + onFocus={() => setIsInputFocus(true)} + onBlur={() => setIsInputFocus(false)} value={inputValue} onChange={handleInputChange} className={containerClassName} diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 2a76be2ab..29dba23c7 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -41,23 +41,28 @@ export const Select = forwardRef( ref={ref} className={twMerge( `inline-flex items-center justify-between rounded-md - bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200 focus:bg-mineshaft-700/80`, - className + bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none focus:bg-mineshaft-700/80 data-[placeholder]:text-mineshaft-200`, + className, + isDisabled && "cursor-not-allowed opacity-50" )} > {props.icon ? : placeholder} - {!isDisabled && ( - - - - )} + + + + ( outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80`, isSelected && "bg-primary", isDisabled && - "cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600", + "cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600", className )} ref={forwardedRef} diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 26af93f37..3a5cef86b 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -10,12 +10,14 @@ export * from "./Drawer"; export * from "./Dropdown"; export * from "./EmailServiceSetupModal"; export * from "./EmptyState"; +export * from "./FontAwesomeSymbol"; export * from "./FormControl"; export * from "./HoverCardv2"; export * from "./IconButton"; export * from "./Input"; export * from "./Menu"; export * from "./Modal"; +export * from "./NoticeBanner"; export * from "./Pagination"; export * from "./Popoverv2"; export * from "./SecretInput"; diff --git a/frontend/src/const.ts b/frontend/src/const.ts index 67340780d..4d13b4602 100644 --- a/frontend/src/const.ts +++ b/frontend/src/const.ts @@ -6,6 +6,7 @@ export const publicPaths = [ "/signup", "/signup/sso", "/login", + "/login/ldap", "/blog", "/docs", "/changelog", @@ -22,7 +23,8 @@ export const publicPaths = [ "/login/provider/success", // TODO: change "/login/provider/error", // TODO: change "/login/sso", - "/admin/signup" + "/admin/signup", + "/shared/secret/[id]" ]; export const languageMap = { diff --git a/frontend/src/helpers/secret.ts b/frontend/src/helpers/secret.ts new file mode 100644 index 000000000..607fa4268 --- /dev/null +++ b/frontend/src/helpers/secret.ts @@ -0,0 +1,175 @@ +import path from "path"; + +import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto"; +import { fetchProjectEncryptedSecrets } from "@app/hooks/api/secrets/queries"; + +const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g; +export const interpolateSecrets = ({ + projectId, + secretEncKey +}: { + projectId: string; + secretEncKey: string; +}) => { + const fetchSecretsCrossEnv = () => { + const fetchCache: Record> = {}; + + return async (secRefEnv: string, secRefPath: string[], secRefKey: string) => { + const secRefPathUrl = path.join("/", ...secRefPath); + const uniqKey = `${secRefEnv}-${secRefPathUrl}`; + + if (fetchCache?.[uniqKey]) { + return fetchCache[uniqKey][secRefKey]; + } + + // get secrets by projectId, env, path + const encryptedSecrets = await fetchProjectEncryptedSecrets({ + workspaceId: projectId, + environment: secRefEnv, + secretPath: secRefPathUrl + }); + + const decryptedSec = encryptedSecrets.reduce>((prev, secret) => { + const secretKey = decryptSymmetric({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key: secretEncKey + }); + const secretValue = decryptSymmetric({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key: secretEncKey + }); + + // eslint-disable-next-line + prev[secretKey] = secretValue; + return prev; + }, {}); + + fetchCache[uniqKey] = decryptedSec; + + return fetchCache[uniqKey][secRefKey]; + }; + }; + + const recursivelyExpandSecret = async ( + expandedSec: Record, + interpolatedSec: Record, + fetchCrossEnv: (env: string, secPath: string[], secKey: string) => Promise, + recursionChainBreaker: Record, + key: string + ) => { + if (expandedSec?.[key] !== undefined) { + return expandedSec[key]; + } + if (recursionChainBreaker?.[key]) { + return ""; + } + // eslint-disable-next-line + recursionChainBreaker[key] = true; + + let interpolatedValue = interpolatedSec[key]; + if (!interpolatedValue) { + // eslint-disable-next-line no-console + console.error(`Couldn't find referenced value - ${key}`); + return ""; + } + + const refs = interpolatedValue.match(INTERPOLATION_SYNTAX_REG); + if (refs) { + await Promise.all( + refs.map(async (interpolationSyntax) => { + const interpolationKey = interpolationSyntax.slice(2, interpolationSyntax.length - 1); + const entities = interpolationKey.trim().split("."); + + if (entities.length === 1) { + const val = await recursivelyExpandSecret( + expandedSec, + interpolatedSec, + fetchCrossEnv, + recursionChainBreaker, + interpolationKey + ); + if (val) { + interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); + } + return; + } + + if (entities.length > 1) { + const secRefEnv = entities[0]; + const secRefPath = entities.slice(1, entities.length - 1); + const secRefKey = entities[entities.length - 1]; + + const val = await fetchCrossEnv(secRefEnv, secRefPath, secRefKey); + if (val) { + interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); + } + } + }) + ); + } + + // eslint-disable-next-line + expandedSec[key] = interpolatedValue; + return interpolatedValue; + }; + + // used to convert multi line ones to quotes ones with \n + const formatMultiValueEnv = (val?: string) => { + if (!val) return ""; + if (!val.match("\n")) return val; + return `"${val.replace(/\n/g, "\\n")}"`; + }; + + const expandSecrets = async ( + secrets: Record + ) => { + const expandedSec: Record = {}; + const interpolatedSec: Record = {}; + + const crossSecEnvFetch = fetchSecretsCrossEnv(); + + Object.keys(secrets).forEach((key) => { + if (secrets[key].value.match(INTERPOLATION_SYNTAX_REG)) { + interpolatedSec[key] = secrets[key].value; + } else { + expandedSec[key] = secrets[key].value; + } + }); + + await Promise.all( + Object.keys(secrets).map(async (key) => { + if (expandedSec?.[key]) { + // should not do multi line encoding if user has set it to skip + // eslint-disable-next-line + secrets[key].value = secrets[key].skipMultilineEncoding + ? expandedSec[key] + : formatMultiValueEnv(expandedSec[key]); + return; + } + + // this is to avoid recursion loop. So the graph should be direct graph rather than cyclic + // so for any recursion building if there is an entity two times same key meaning it will be looped + const recursionChainBreaker: Record = {}; + const expandedVal = await recursivelyExpandSecret( + expandedSec, + interpolatedSec, + crossSecEnvFetch, + recursionChainBreaker, + key + ); + + // eslint-disable-next-line + secrets[key].value = secrets[key].skipMultilineEncoding + ? expandedVal + : formatMultiValueEnv(expandedVal); + }) + ); + + return secrets; + }; + return expandSecrets; +}; diff --git a/frontend/src/helpers/string.ts b/frontend/src/helpers/string.ts new file mode 100644 index 000000000..8f581a280 --- /dev/null +++ b/frontend/src/helpers/string.ts @@ -0,0 +1,5 @@ +export const removeTrailingSlash = (str: string) => { + if (str === "/") return str; + + return str.endsWith("/") ? str.slice(0, -1) : str; +}; diff --git a/frontend/src/hooks/api/accessApproval/index.tsx b/frontend/src/hooks/api/accessApproval/index.tsx new file mode 100644 index 000000000..ec9789d05 --- /dev/null +++ b/frontend/src/hooks/api/accessApproval/index.tsx @@ -0,0 +1,13 @@ +export { + useCreateAccessApprovalPolicy, + useCreateAccessRequest, + useDeleteAccessApprovalPolicy, + useReviewAccessRequest, + useUpdateAccessApprovalPolicy +} from "./mutation"; +export { + useGetAccessApprovalPolicies, + useGetAccessApprovalRequests, + useGetAccessPolicyApprovalCount, + useGetAccessRequestsCount +} from "./queries"; diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx new file mode 100644 index 000000000..5f595c8a2 --- /dev/null +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -0,0 +1,123 @@ +import { packRules } from "@casl/ability/extra"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { accessApprovalKeys } from "./queries"; +import { + TAccessApproval, + TCreateAccessPolicyDTO, + TCreateAccessRequestDTO, + TDeleteSecretPolicyDTO, + TUpdateAccessPolicyDTO +} from "./types"; + +export const useCreateAccessApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TCreateAccessPolicyDTO>({ + mutationFn: async ({ environment, projectSlug, approvals, approvers, name, secretPath }) => { + const { data } = await apiRequest.post("/api/v1/access-approvals/policies", { + environment, + projectSlug, + approvals, + approvers, + secretPath, + name + }); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug)); + } + }); +}; + +export const useUpdateAccessApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateAccessPolicyDTO>({ + mutationFn: async ({ id, approvers, approvals, name, secretPath }) => { + const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { + approvals, + approvers, + secretPath, + name + }); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug)); + } + }); +}; + +export const useDeleteAccessApprovalPolicy = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSecretPolicyDTO>({ + mutationFn: async ({ id }) => { + const { data } = await apiRequest.delete(`/api/v1/access-approvals/policies/${id}`); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug)); + } + }); +}; + +export const useCreateAccessRequest = () => { + const queryClient = useQueryClient(); + return useMutation<{}, {}, TCreateAccessRequestDTO>({ + mutationFn: async ({ projectSlug, ...request }) => { + const { data } = await apiRequest.post( + "/api/v1/access-approvals/requests", + { + ...request, + permissions: request.permissions ? packRules(request.permissions) : undefined + }, + { + params: { + projectSlug + } + } + ); + + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalRequestCount(projectSlug)); + } + }); +}; + +export const useReviewAccessRequest = () => { + const queryClient = useQueryClient(); + return useMutation< + {}, + {}, + { + requestId: string; + status: "approved" | "rejected"; + projectSlug: string; + envSlug?: string; + requestedBy?: string; + } + >({ + mutationFn: async ({ requestId, status }) => { + const { data } = await apiRequest.post( + `/api/v1/access-approvals/requests/${requestId}/review`, + { + status + } + ); + return data; + }, + onSuccess: (_, { projectSlug, envSlug, requestedBy }) => { + queryClient.invalidateQueries( + accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, requestedBy) + ); + queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalRequestCount(projectSlug)); + } + }); +}; diff --git a/frontend/src/hooks/api/accessApproval/queries.tsx b/frontend/src/hooks/api/accessApproval/queries.tsx new file mode 100644 index 000000000..599962e43 --- /dev/null +++ b/frontend/src/hooks/api/accessApproval/queries.tsx @@ -0,0 +1,159 @@ +import { PackRule, unpackRules } from "@casl/ability/extra"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TProjectPermission } from "../roles/types"; +import { + TAccessApprovalPolicy, + TAccessApprovalRequest, + TAccessRequestCount, + TGetAccessApprovalRequestsDTO, + TGetAccessPolicyApprovalCountDTO +} from "./types"; + +export const accessApprovalKeys = { + getAccessApprovalPolicies: (projectSlug: string) => + [{ projectSlug }, "access-approval-policies"] as const, + getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) => + [{ workspaceId, environment }, "access-approval-policy"] as const, + + getAccessApprovalRequests: (projectSlug: string, envSlug?: string, requestedBy?: string) => + [{ projectSlug, envSlug, requestedBy }, "access-approvals-requests"] as const, + getAccessApprovalRequestCount: (projectSlug: string) => + [{ projectSlug }, "access-approval-request-count"] as const +}; + +export const fetchPolicyApprovalCount = async ({ + projectSlug, + envSlug +}: TGetAccessPolicyApprovalCountDTO) => { + const { data } = await apiRequest.get<{ count: number }>( + "/api/v1/access-approvals/policies/count", + { + params: { projectSlug, envSlug } + } + ); + return data.count; +}; + +export const useGetAccessPolicyApprovalCount = ({ + projectSlug, + envSlug, + options = {} +}: TGetAccessPolicyApprovalCountDTO & { + options?: UseQueryOptions< + number, + unknown, + number, + ReturnType + >; +}) => + useQuery({ + queryFn: () => fetchPolicyApprovalCount({ projectSlug, envSlug }), + ...options, + enabled: Boolean(projectSlug) && (options?.enabled ?? true) + }); + +const fetchApprovalPolicies = async ({ projectSlug }: TGetAccessApprovalRequestsDTO) => { + const { data } = await apiRequest.get<{ approvals: TAccessApprovalPolicy[] }>( + "/api/v1/access-approvals/policies", + { params: { projectSlug } } + ); + return data.approvals; +}; + +const fetchApprovalRequests = async ({ + projectSlug, + envSlug, + authorProjectMembershipId +}: TGetAccessApprovalRequestsDTO) => { + const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>( + "/api/v1/access-approvals/requests", + { params: { projectSlug, envSlug, authorProjectMembershipId } } + ); + + return data.requests.map((request) => ({ + ...request, + + privilege: request.privilege + ? { + ...request.privilege, + permissions: unpackRules( + request.privilege.permissions as unknown as PackRule[] + ) + } + : null, + permissions: unpackRules(request.permissions as unknown as PackRule[]) + })); +}; + +const fetchAccessRequestsCount = async (projectSlug: string) => { + const { data } = await apiRequest.get( + "/api/v1/access-approvals/requests/count", + { params: { projectSlug } } + ); + return data; +}; + +export const useGetAccessRequestsCount = ({ + projectSlug, + options = {} +}: TGetAccessApprovalRequestsDTO & { + options?: UseQueryOptions< + TAccessRequestCount, + unknown, + { pendingCount: number; finalizedCount: number }, + ReturnType + >; +}) => + useQuery({ + queryKey: accessApprovalKeys.getAccessApprovalRequestCount(projectSlug), + queryFn: () => fetchAccessRequestsCount(projectSlug), + ...options, + enabled: Boolean(projectSlug) && (options?.enabled ?? true) + }); + +export const useGetAccessApprovalPolicies = ({ + projectSlug, + envSlug, + authorProjectMembershipId, + options = {} +}: TGetAccessApprovalRequestsDTO & { + options?: UseQueryOptions< + TAccessApprovalPolicy[], + unknown, + TAccessApprovalPolicy[], + ReturnType + >; +}) => + useQuery({ + queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug), + queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorProjectMembershipId }), + ...options, + enabled: Boolean(projectSlug) && (options?.enabled ?? true) + }); + +export const useGetAccessApprovalRequests = ({ + projectSlug, + envSlug, + authorProjectMembershipId, + options = {} +}: TGetAccessApprovalRequestsDTO & { + options?: UseQueryOptions< + TAccessApprovalRequest[], + unknown, + TAccessApprovalRequest[], + ReturnType + >; +}) => + useQuery({ + queryKey: accessApprovalKeys.getAccessApprovalRequests( + projectSlug, + envSlug, + authorProjectMembershipId + ), + queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorProjectMembershipId }), + ...options, + enabled: Boolean(projectSlug) && (options?.enabled ?? true) + }); diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts new file mode 100644 index 000000000..2176b8bc1 --- /dev/null +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -0,0 +1,139 @@ +import { TProjectPermission } from "../roles/types"; +import { WorkspaceEnv } from "../workspace/types"; + +export type TAccessApprovalPolicy = { + id: string; + name: string; + approvals: number; + secretPath: string; + envId: string; + workspace: string; + environment: WorkspaceEnv; + projectId: string; + approvers: string[]; +}; + +export type TAccessApprovalRequest = { + id: string; + policyId: string; + privilegeId: string | null; + requestedBy: string; + createdAt: Date; + updatedAt: Date; + isTemporary: boolean; + temporaryRange: string | null | undefined; + + permissions: TProjectPermission[] | null; + + // Computed + environmentName: string; + isApproved: boolean; + + privilege: { + membershipId: string; + isTemporary: boolean; + temporaryMode?: string | null; + temporaryRange?: string | null; + temporaryAccessStartTime?: Date | null; + temporaryAccessEndTime?: Date | null; + permissions: TProjectPermission[]; + isApproved: boolean; + } | null; + + policy: { + id: string; + name: string; + approvals: number; + approvers: string[]; + secretPath?: string | null; + envId: string; + }; + + reviewers: { + member: string; + status: string; + }[]; +}; + +export type TAccessApproval = { + id: string; + policyId: string; + privilegeId: string; + requestedBy: string; +}; + +export type TAccessRequestCount = { + pendingCount: number; + finalizedCount: number; +}; + +export type TProjectUserPrivilege = { + projectMembershipId: string; + slug: string; + id: string; + createdAt: Date; + updatedAt: Date; + permissions?: TProjectPermission[]; +} & ( + | { + isTemporary: true; + temporaryMode: string; + temporaryRange: string; + temporaryAccessStartTime: string; + temporaryAccessEndTime?: string; + } + | { + isTemporary: false; + temporaryMode?: null; + temporaryRange?: null; + temporaryAccessStartTime?: null; + temporaryAccessEndTime?: null; + } +); + +export type TCreateAccessRequestDTO = { + projectSlug: string; +} & Omit; + +export type TGetAccessApprovalRequestsDTO = { + projectSlug: string; + envSlug?: string; + authorProjectMembershipId?: string; +}; + +export type TGetAccessPolicyApprovalCountDTO = { + projectSlug: string; + envSlug: string; +}; + +export type TGetSecretApprovalPolicyOfBoardDTO = { + workspaceId: string; + environment: string; + secretPath: string; +}; + +export type TCreateAccessPolicyDTO = { + projectSlug: string; + name?: string; + environment: string; + approvers?: string[]; + approvals?: number; + secretPath?: string; +}; + +export type TUpdateAccessPolicyDTO = { + id: string; + name?: string; + approvers?: string[]; + secretPath?: string; + environment?: string; + approvals?: number; + // for invalidating list + projectSlug: string; +}; + +export type TDeleteSecretPolicyDTO = { + id: string; + // for invalidating list + projectSlug: string; +}; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index c7022a1d7..6a42e6ed0 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -3,6 +3,9 @@ export type TServerConfig = { allowSignUp: boolean; allowedSignUpDomain?: string | null; isMigrationModeOn?: boolean; + trustSamlEmails: boolean; + trustLdapEmails: boolean; + isSecretScanningDisabled: boolean; }; export type TCreateAdminUserDTO = { diff --git a/frontend/src/hooks/api/auditLogStreams/index.tsx b/frontend/src/hooks/api/auditLogStreams/index.tsx new file mode 100644 index 000000000..72b1fba1a --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/index.tsx @@ -0,0 +1,6 @@ +export { + useCreateAuditLogStream, + useDeleteAuditLogStream, + useUpdateAuditLogStream +} from "./mutations"; +export { useGetAuditLogStreamDetails, useGetAuditLogStreams } from "./queries"; diff --git a/frontend/src/hooks/api/auditLogStreams/mutations.tsx b/frontend/src/hooks/api/auditLogStreams/mutations.tsx new file mode 100644 index 000000000..2d99f57c4 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/mutations.tsx @@ -0,0 +1,61 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { auditLogStreamKeys } from "./queries"; +import { + TAuditLogStream, + TCreateAuditLogStreamDTO, + TDeleteAuditLogStreamDTO, + TUpdateAuditLogStreamDTO +} from "./types"; + +export const useCreateAuditLogStream = () => { + const queryClient = useQueryClient(); + + return useMutation<{ auditLogStream: TAuditLogStream }, {}, TCreateAuditLogStreamDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post<{ auditLogStream: TAuditLogStream }>( + "/api/v1/audit-log-streams", + dto + ); + return data; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(auditLogStreamKeys.list(orgId)); + } + }); +}; + +export const useUpdateAuditLogStream = () => { + const queryClient = useQueryClient(); + + return useMutation<{ auditLogStream: TAuditLogStream }, {}, TUpdateAuditLogStreamDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch<{ auditLogStream: TAuditLogStream }>( + `/api/v1/audit-log-streams/${dto.id}`, + dto + ); + return data; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(auditLogStreamKeys.list(orgId)); + } + }); +}; + +export const useDeleteAuditLogStream = () => { + const queryClient = useQueryClient(); + + return useMutation<{ auditLogStream: TAuditLogStream }, {}, TDeleteAuditLogStreamDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete<{ auditLogStream: TAuditLogStream }>( + `/api/v1/audit-log-streams/${dto.id}` + ); + return data; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(auditLogStreamKeys.list(orgId)); + } + }); +}; diff --git a/frontend/src/hooks/api/auditLogStreams/queries.tsx b/frontend/src/hooks/api/auditLogStreams/queries.tsx new file mode 100644 index 000000000..f86ca0dce --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/queries.tsx @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TAuditLogStream } from "./types"; + +export const auditLogStreamKeys = { + list: (orgId: string) => ["audit-log-stream", { orgId }], + getById: (id: string) => ["audit-log-stream-details", { id }] +}; + +const fetchAuditLogStreams = async () => { + const { data } = await apiRequest.get<{ auditLogStreams: TAuditLogStream[] }>( + "/api/v1/audit-log-streams" + ); + + return data.auditLogStreams; +}; + +export const useGetAuditLogStreams = (orgId: string) => + useQuery({ + queryKey: auditLogStreamKeys.list(orgId), + queryFn: () => fetchAuditLogStreams(), + enabled: Boolean(orgId) + }); + +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) + }); diff --git a/frontend/src/hooks/api/auditLogStreams/types.ts b/frontend/src/hooks/api/auditLogStreams/types.ts new file mode 100644 index 000000000..8e21a3209 --- /dev/null +++ b/frontend/src/hooks/api/auditLogStreams/types.ts @@ -0,0 +1,28 @@ +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/auth/index.tsx b/frontend/src/hooks/api/auth/index.tsx index 8b918c7ab..505f7b05f 100644 --- a/frontend/src/hooks/api/auth/index.tsx +++ b/frontend/src/hooks/api/auth/index.tsx @@ -5,7 +5,6 @@ export { useSendMfaToken, useSendPasswordResetEmail, useSendVerificationEmail, - useVerifyEmailVerificationCode, useVerifyMfaToken, - useVerifyPasswordResetCode -} from "./queries"; + useVerifyPasswordResetCode, + useVerifySignupEmailVerificationCode} from "./queries"; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 4d05fb963..cba815fae 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "@app/config/request"; import { setAuthToken } from "@app/reactQuery"; import { organizationKeys } from "../organization/queries"; +import { workspaceKeys } from "../workspace/queries"; import { ChangePasswordDTO, CompleteAccountDTO, @@ -78,7 +79,10 @@ export const useSelectOrganization = () => { return data; }, onSuccess: () => { - queryClient.invalidateQueries(organizationKeys.getUserOrganizations); + queryClient.invalidateQueries([ + organizationKeys.getUserOrganizations, + workspaceKeys.getAllUserWorkspace + ]); } }); }; @@ -164,7 +168,7 @@ export const useSendVerificationEmail = () => { }); }; -export const useVerifyEmailVerificationCode = () => { +export const useVerifySignupEmailVerificationCode = () => { return useMutation({ mutationFn: async ({ email, code }: { email: string; code: string }) => { const { data } = await apiRequest.post("/api/v3/signup/email/verify", { diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 27c4c5ddf..a9aab8318 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -17,7 +17,8 @@ export type TDynamicSecret = { export enum DynamicSecretProviders { SqlDatabase = "sql-database", - Cassandra = "cassandra" + Cassandra = "cassandra", + AwsIam = "aws-iam" } export enum SqlProviders { @@ -56,6 +57,18 @@ export type TDynamicSecretProvider = renewStatement?: string; ca?: string | undefined; }; + } + | { + type: DynamicSecretProviders.AwsIam; + inputs: { + accessKey: string; + secretAccessKey: string; + region: string; + awsPath?: string; + policyDocument?: string; + userGroups?: string; + policyArns?: string; + }; }; export type TCreateDynamicSecretDTO = { diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 53fceb7d5..51495d4f2 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -1,5 +1,9 @@ import { IdentityAuthMethod } from "./enums"; export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { - [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth" + [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth", + [IdentityAuthMethod.KUBERNETES_AUTH]: "Kubernetes Auth", + [IdentityAuthMethod.GCP_AUTH]: "GCP Auth", + [IdentityAuthMethod.AWS_AUTH]: "AWS Auth", + [IdentityAuthMethod.AZURE_AUTH]: "Azure Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index 524c3a20c..66af91093 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -1,3 +1,7 @@ export enum IdentityAuthMethod { - UNIVERSAL_AUTH = "universal-auth" + UNIVERSAL_AUTH = "universal-auth", + KUBERNETES_AUTH = "kubernetes-auth", + GCP_AUTH = "gcp-auth", + AWS_AUTH = "aws-auth", + AZURE_AUTH = "azure-auth" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index 684a00b6f..41b03669b 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -1,12 +1,27 @@ export { identityAuthToNameMap } from "./constants"; export { IdentityAuthMethod } from "./enums"; export { + useAddIdentityAwsAuth, + useAddIdentityAzureAuth, + useAddIdentityGcpAuth, + useAddIdentityKubernetesAuth, useAddIdentityUniversalAuth, useCreateIdentity, useCreateIdentityUniversalAuthClientSecret, useDeleteIdentity, useRevokeIdentityUniversalAuthClientSecret, useUpdateIdentity, + useUpdateIdentityAwsAuth, + useUpdateIdentityAzureAuth, + useUpdateIdentityGcpAuth, + useUpdateIdentityKubernetesAuth, useUpdateIdentityUniversalAuth } from "./mutations"; -export { useGetIdentityUniversalAuth, useGetIdentityUniversalAuthClientSecrets } from "./queries"; +export { + useGetIdentityAwsAuth, + useGetIdentityAzureAuth, + useGetIdentityGcpAuth, + useGetIdentityKubernetesAuth, + useGetIdentityUniversalAuth, + useGetIdentityUniversalAuthClientSecrets +} from "./queries"; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index ec418659d..cb1fe4c17 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -5,6 +5,10 @@ import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; import { identitiesKeys } from "./queries"; import { + AddIdentityAwsAuthDTO, + AddIdentityAzureAuthDTO, + AddIdentityGcpAuthDTO, + AddIdentityKubernetesAuthDTO, AddIdentityUniversalAuthDTO, ClientSecretData, CreateIdentityDTO, @@ -13,8 +17,16 @@ import { DeleteIdentityDTO, DeleteIdentityUniversalAuthClientSecretDTO, Identity, + IdentityAwsAuth, + IdentityAzureAuth, + IdentityGcpAuth, + IdentityKubernetesAuth, IdentityUniversalAuth, + UpdateIdentityAwsAuthDTO, + UpdateIdentityAzureAuthDTO, UpdateIdentityDTO, + UpdateIdentityGcpAuthDTO, + UpdateIdentityKubernetesAuthDTO, UpdateIdentityUniversalAuthDTO } from "./types"; @@ -169,3 +181,307 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => { } }); }; + +export const useAddIdentityGcpAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityGcpAuth } + } = await apiRequest.post<{ identityGcpAuth: IdentityGcpAuth }>( + `/api/v1/auth/gcp-auth/identities/${identityId}`, + { + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityGcpAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useUpdateIdentityGcpAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityGcpAuth } + } = await apiRequest.patch<{ identityGcpAuth: IdentityGcpAuth }>( + `/api/v1/auth/gcp-auth/identities/${identityId}`, + { + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityGcpAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useAddIdentityAwsAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAwsAuth } + } = await apiRequest.post<{ identityAwsAuth: IdentityAwsAuth }>( + `/api/v1/auth/aws-auth/identities/${identityId}`, + { + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAwsAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useUpdateIdentityAwsAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAwsAuth } + } = await apiRequest.patch<{ identityAwsAuth: IdentityAwsAuth }>( + `/api/v1/auth/aws-auth/identities/${identityId}`, + { + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAwsAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useAddIdentityAzureAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAzureAuth } + } = await apiRequest.post<{ identityAzureAuth: IdentityAzureAuth }>( + `/api/v1/auth/azure-auth/identities/${identityId}`, + { + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAzureAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useAddIdentityKubernetesAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + kubernetesHost, + tokenReviewerJwt, + allowedNames, + allowedNamespaces, + allowedAudience, + caCert, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityKubernetesAuth } + } = await apiRequest.post<{ identityKubernetesAuth: IdentityKubernetesAuth }>( + `/api/v1/auth/kubernetes-auth/identities/${identityId}`, + { + kubernetesHost, + tokenReviewerJwt, + allowedNames, + allowedNamespaces, + allowedAudience, + caCert, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityKubernetesAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useUpdateIdentityAzureAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAzureAuth } + } = await apiRequest.patch<{ identityAzureAuth: IdentityAzureAuth }>( + `/api/v1/auth/azure-auth/identities/${identityId}`, + { + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAzureAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + +export const useUpdateIdentityKubernetesAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + kubernetesHost, + tokenReviewerJwt, + allowedNamespaces, + allowedNames, + allowedAudience, + caCert, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityKubernetesAuth } + } = await apiRequest.patch<{ identityKubernetesAuth: IdentityKubernetesAuth }>( + `/api/v1/auth/kubernetes-auth/identities/${identityId}`, + { + kubernetesHost, + tokenReviewerJwt, + allowedNames, + allowedNamespaces, + allowedAudience, + caCert, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityKubernetesAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 92d2a432f..eb04227eb 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -2,27 +2,36 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { ClientSecretData, IdentityUniversalAuth } from "./types"; +import { + ClientSecretData, + IdentityAwsAuth, + IdentityAzureAuth, + IdentityGcpAuth, + IdentityKubernetesAuth, + IdentityUniversalAuth} from "./types"; export const identitiesKeys = { getIdentityUniversalAuth: (identityId: string) => [{ identityId }, "identity-universal-auth"] as const, getIdentityUniversalAuthClientSecrets: (identityId: string) => - [{ identityId }, "identity-universal-auth-client-secrets"] as const + [{ identityId }, "identity-universal-auth-client-secrets"] as const, + getIdentityKubernetesAuth: (identityId: string) => + [{ identityId }, "identity-kubernetes-auth"] as const, + getIdentityGcpAuth: (identityId: string) => [{ identityId }, "identity-gcp-auth"] as const, + getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const, + getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const }; export const useGetIdentityUniversalAuth = (identityId: string) => { return useQuery({ + enabled: Boolean(identityId), queryKey: identitiesKeys.getIdentityUniversalAuth(identityId), queryFn: async () => { - if (identityId === "") throw new Error("Identity ID is required"); - const { data: { identityUniversalAuth } } = await apiRequest.get<{ identityUniversalAuth: IdentityUniversalAuth }>( `/api/v1/auth/universal-auth/identities/${identityId}` ); - return identityUniversalAuth; } }); @@ -30,17 +39,75 @@ export const useGetIdentityUniversalAuth = (identityId: string) => { export const useGetIdentityUniversalAuthClientSecrets = (identityId: string) => { return useQuery({ + enabled: Boolean(identityId), queryKey: identitiesKeys.getIdentityUniversalAuthClientSecrets(identityId), queryFn: async () => { - if (identityId === "") return []; - const { data: { clientSecretData } } = await apiRequest.get<{ clientSecretData: ClientSecretData[] }>( `/api/v1/auth/universal-auth/identities/${identityId}/client-secrets` ); - return clientSecretData; } }); }; + +export const useGetIdentityGcpAuth = (identityId: string) => { + return useQuery({ + enabled: Boolean(identityId), + queryKey: identitiesKeys.getIdentityGcpAuth(identityId), + queryFn: async () => { + const { + data: { identityGcpAuth } + } = await apiRequest.get<{ identityGcpAuth: IdentityGcpAuth }>( + `/api/v1/auth/gcp-auth/identities/${identityId}` + ); + return identityGcpAuth; + } + }); +}; + +export const useGetIdentityAwsAuth = (identityId: string) => { + return useQuery({ + enabled: Boolean(identityId), + queryKey: identitiesKeys.getIdentityAwsAuth(identityId), + queryFn: async () => { + const { + data: { identityAwsAuth } + } = await apiRequest.get<{ identityAwsAuth: IdentityAwsAuth }>( + `/api/v1/auth/aws-auth/identities/${identityId}` + ); + return identityAwsAuth; + } + }); +}; + +export const useGetIdentityAzureAuth = (identityId: string) => { + return useQuery({ + enabled: Boolean(identityId), + queryKey: identitiesKeys.getIdentityAzureAuth(identityId), + queryFn: async () => { + const { + data: { identityAzureAuth } + } = await apiRequest.get<{ identityAzureAuth: IdentityAzureAuth }>( + `/api/v1/auth/azure-auth/identities/${identityId}` + ); + return identityAzureAuth; + } + }); +}; + +export const useGetIdentityKubernetesAuth = (identityId: string) => { + return useQuery({ + enabled: Boolean(identityId), + queryKey: identitiesKeys.getIdentityKubernetesAuth(identityId), + queryFn: async () => { + const { + data: { identityKubernetesAuth } + } = await apiRequest.get<{ identityKubernetesAuth: IdentityKubernetesAuth }>( + `/api/v1/auth/kubernetes-auth/identities/${identityId}` + ); + return identityKubernetesAuth; + } + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 4ac19c351..80d066c72 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -38,19 +38,19 @@ export type IdentityMembership = { customRoleSlug: string; } & ( | { - isTemporary: false; - temporaryRange: null; - temporaryMode: null; - temporaryAccessEndTime: null; - temporaryAccessStartTime: null; - } + isTemporary: false; + temporaryRange: null; + temporaryMode: null; + temporaryAccessEndTime: null; + temporaryAccessStartTime: null; + } | { - isTemporary: true; - temporaryRange: string; - temporaryMode: string; - temporaryAccessEndTime: string; - temporaryAccessStartTime: string; - } + isTemporary: true; + temporaryRange: string; + temporaryMode: string; + temporaryAccessEndTime: string; + temporaryAccessStartTime: string; + } ) >; createdAt: string; @@ -113,6 +113,175 @@ export type UpdateIdentityUniversalAuthDTO = { }[]; }; +export type IdentityGcpAuth = { + identityId: string; + type: "iam" | "gce"; + allowedServiceAccounts: string; + allowedProjects: string; + allowedZones: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityGcpAuthDTO = { + organizationId: string; + identityId: string; + type: "iam" | "gce"; + allowedServiceAccounts: string; + allowedProjects: string; + allowedZones: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityGcpAuthDTO = { + organizationId: string; + identityId: string; + type?: "iam" | "gce"; + allowedServiceAccounts?: string; + allowedProjects?: string; + allowedZones?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + +export type IdentityAwsAuth = { + identityId: string; + type: "iam"; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityAwsAuthDTO = { + organizationId: string; + identityId: string; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityAwsAuthDTO = { + organizationId: string; + identityId: string; + stsEndpoint?: string; + allowedPrincipalArns?: string; + allowedAccountIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + +export type IdentityAzureAuth = { + identityId: string; + tenantId: string; + resource: string; + allowedServicePrincipalIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityAzureAuthDTO = { + organizationId: string; + identityId: string; + tenantId: string; + resource: string; + allowedServicePrincipalIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityAzureAuthDTO = { + organizationId: string; + identityId: string; + tenantId?: string; + resource?: string; + allowedServicePrincipalIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + +export type IdentityKubernetesAuth = { + identityId: string; + kubernetesHost: string; + tokenReviewerJwt: string; + allowedNamespaces: string; + allowedNames: string; + allowedAudience: string; + caCert: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityKubernetesAuthDTO = { + organizationId: string; + identityId: string; + kubernetesHost: string; + tokenReviewerJwt: string; + allowedNamespaces: string; + allowedNames: string; + allowedAudience: string; + caCert: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityKubernetesAuthDTO = { + organizationId: string; + identityId: string; + kubernetesHost?: string; + tokenReviewerJwt?: string; + allowedNamespaces?: string; + allowedNames?: string; + allowedAudience?: string; + caCert?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + export type CreateIdentityUniversalAuthClientSecretDTO = { identityId: string; description?: string; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx index 72534c158..e4bd141fb 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx @@ -1,9 +1,7 @@ -import { PackRule, unpackRules } from "@casl/ability/extra"; import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TProjectPermission } from "../roles/types"; import { TGetIdentityProejctPrivilegeDetails as TGetIdentityProjectPrivilegeDetails, TIdentityProjectPrivilege, @@ -36,17 +34,14 @@ export const useGetIdentityProjectPrivilegeDetails = ({ const { data: { privilege } } = await apiRequest.get<{ - privilege: Omit & { permissions: unknown }; + privilege: TIdentityProjectPrivilege; }>(`/api/v1/additional-privilege/identity/${privilegeSlug}`, { params: { identityId, projectSlug } }); - return { - ...privilege, - permissions: unpackRules(privilege.permissions as PackRule[]) - }; + return privilege; } }); }; @@ -62,16 +57,11 @@ export const useListIdentityProjectPrivileges = ({ const { data: { privileges } } = await apiRequest.get<{ - privileges: Array< - Omit & { permissions: unknown } - >; + privileges: Array; }>("/api/v1/additional-privilege/identity", { - params: { identityId, projectSlug, unpacked: false } + params: { identityId, projectSlug } }); - return privileges.map((el) => ({ - ...el, - permissions: unpackRules(el.permissions as PackRule[]) - })); + return privileges; } }); }; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx index fad549e38..df04f3e8a 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx @@ -12,21 +12,30 @@ export type TIdentityProjectPrivilege = { updatedAt: Date; permissions?: TProjectPermission[]; } & ( - | { + | { isTemporary: true; temporaryMode: string; temporaryRange: string; temporaryAccessStartTime: string; temporaryAccessEndTime?: string; } - | { + | { isTemporary: false; temporaryMode?: null; temporaryRange?: null; temporaryAccessStartTime?: null; temporaryAccessEndTime?: null; } - ); +); + +export type TProjectSpecificPrivilegePermission = { + conditions: { + environment: string; + secretPath?: { $glob: string }; + }; + actions: string[]; + subject: string; +}; export type TCreateIdentityProjectPrivilegeDTO = { identityId: string; @@ -36,14 +45,16 @@ export type TCreateIdentityProjectPrivilegeDTO = { temporaryMode?: IdentityProjectAdditionalPrivilegeTemporaryMode; temporaryRange?: string; temporaryAccessStartTime?: string; - permissions: TProjectPermission[]; + privilegePermission: TProjectSpecificPrivilegePermission; }; export type TUpdateIdentityProjectPrivlegeDTO = { projectSlug: string; identityId: string; privilegeSlug: string; - privilegeDetails: Partial>; + privilegeDetails: Partial< + Omit + >; }; export type TDeleteIdentityProjectPrivilegeDTO = { diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index b2df27f2d..61e8cb666 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,6 +1,8 @@ +export * from "./accessApproval"; export * from "./admin"; export * from "./apiKeys"; export * from "./auditLogs"; +export * from "./auditLogStreams"; export * from "./auth"; export * from "./bots"; export * from "./dynamicSecret"; diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index e66dd5700..d800e5313 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -48,10 +48,9 @@ const integrationAuthKeys = { integrationAuthId, region }: { - integrationAuthId: string, - region: string - }) => - [{ integrationAuthId, region }, "integrationAuthAwsKmsKeyIds"] as const, + integrationAuthId: string; + region: string; + }) => [{ integrationAuthId, region }, "integrationAuthAwsKmsKeyIds"] as const, getIntegrationAuthQoveryOrgs: (integrationAuthId: string) => [{ integrationAuthId }, "integrationAuthQoveryOrgs"] as const, getIntegrationAuthQoveryProjects: ({ @@ -226,27 +225,6 @@ const fetchIntegrationAuthQoveryOrgs = async (integrationAuthId: string) => { return orgs; }; -const fetchIntegrationAuthAwsKmsKeys = async ({ - integrationAuthId, - region -}: { - integrationAuthId: string; - region: string; -}) => { - const { - data: { kmsKeys } - } = await apiRequest.get<{ kmsKeys: KmsKey[] }>( - `/api/v1/integration-auth/${integrationAuthId}/aws-secrets-manager/kms-keys`, - { - params: { - region - } - } - ); - - return kmsKeys; -}; - const fetchIntegrationAuthQoveryProjects = async ({ integrationAuthId, orgId @@ -586,11 +564,22 @@ export const useGetIntegrationAuthAwsKmsKeys = ({ integrationAuthId, region }), - queryFn: () => - fetchIntegrationAuthAwsKmsKeys({ - integrationAuthId, - region - }), + queryFn: async () => { + if (!region) return []; + + const { + data: { kmsKeys } + } = await apiRequest.get<{ kmsKeys: KmsKey[] }>( + `/api/v1/integration-auth/${integrationAuthId}/aws-secrets-manager/kms-keys`, + { + params: { + region + } + } + ); + + return kmsKeys; + }, enabled: true }); }; diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index b0e1dd9f5..b73528384 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -7,6 +7,7 @@ export type IntegrationAuth = { updatedAt: string; algorithm: string; keyEncoding: string; + url?: string; teamId?: string; }; @@ -30,7 +31,7 @@ export type HerokuPipelineCoupling = { export type Team = { name: string; - teamId: string; + id: string; }; export type Environment = { diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index b855dbf73..3aa8f3ed1 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createNotification } from "@app/components/notifications"; import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace/queries"; @@ -40,6 +41,7 @@ export const useCreateIntegration = () => { owner, path, region, + url, scope, secretPath, metadata @@ -55,6 +57,7 @@ export const useCreateIntegration = () => { targetService?: string; targetServiceId?: string; owner?: string; + url?: string; path?: string; region?: string; scope?: string; @@ -63,11 +66,13 @@ export const useCreateIntegration = () => { secretSuffix?: string; initialSyncBehavior?: string; shouldAutoRedeploy?: boolean; + mappingBehavior?: string; secretAWSTag?: { key: string; value: string; }[]; kmsKeyId?: string; + shouldDisableDelete?: boolean; }; }) => { const { @@ -82,6 +87,7 @@ export const useCreateIntegration = () => { targetEnvironmentId, targetService, targetServiceId, + url, owner, path, scope, @@ -109,3 +115,15 @@ export const useDeleteIntegration = () => { } }); }; + +export const useSyncIntegration = () => { + return useMutation<{}, {}, { id: string; workspaceId: string; lastUsed: string }>({ + mutationFn: ({ id }) => apiRequest.post(`/api/v1/integration/${id}/sync`), + onSuccess: () => { + createNotification({ + text: "Successfully triggered manual sync", + type: "success" + }); + } + }); +}; diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index a67ce15a8..21e6bff26 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -29,10 +29,14 @@ export type TIntegration = { secretPath: string; createdAt: string; updatedAt: string; + lastUsed?: string; + isSynced?: boolean; + syncMessage?: string; __v: number; metadata?: { secretSuffix?: string; syncBehavior?: IntegrationSyncBehavior; + mappingBehavior?: IntegrationMappingBehavior; scope: string; org: string; project: string; @@ -45,3 +49,8 @@ export enum IntegrationSyncBehavior { PREFER_TARGET = "prefer-target", PREFER_SOURCE = "prefer-source" } + +export enum IntegrationMappingBehavior { + ONE_TO_ONE = "one-to-one", + MANY_TO_ONE = "many-to-one" +} diff --git a/frontend/src/hooks/api/roles/index.tsx b/frontend/src/hooks/api/roles/index.tsx index 50736b30d..53c05a7b6 100644 --- a/frontend/src/hooks/api/roles/index.tsx +++ b/frontend/src/hooks/api/roles/index.tsx @@ -8,6 +8,7 @@ export { } from "./mutation"; export { useGetOrgRoles, + useGetProjectRoleBySlug, useGetProjectRoles, useGetUserOrgPermissions, useGetUserProjectPermissions diff --git a/frontend/src/hooks/api/roles/mutation.tsx b/frontend/src/hooks/api/roles/mutation.tsx index 6df6a3933..ae3e170de 100644 --- a/frontend/src/hooks/api/roles/mutation.tsx +++ b/frontend/src/hooks/api/roles/mutation.tsx @@ -17,13 +17,10 @@ export const useCreateProjectRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ projectId, permissions, ...dto }: TCreateProjectRoleDTO) => - apiRequest.post(`/api/v1/workspace/${projectId}/roles`, { - ...dto, - permissions: permissions.length ? packRules(permissions) : [] - }), - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectId)); + mutationFn: ({ projectSlug, ...dto }: TCreateProjectRoleDTO) => + apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto), + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } }); }; @@ -32,13 +29,10 @@ export const useUpdateProjectRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, projectId, permissions, ...dto }: TUpdateProjectRoleDTO) => - apiRequest.patch(`/api/v1/workspace/${projectId}/roles/${id}`, { - ...dto, - permissions: permissions?.length ? packRules(permissions) : [] - }), - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectId)); + mutationFn: ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) => + apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto), + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } }); }; @@ -47,12 +41,10 @@ export const useDeleteProjectRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ projectId, id }: TDeleteProjectRoleDTO) => - apiRequest.delete(`/api/v1/workspace/${projectId}/roles/${id}`, { - data: { projectId } - }), - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectId)); + mutationFn: ({ projectSlug, id }: TDeleteProjectRoleDTO) => + apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`), + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } }); }; diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 3280bface..f04af697d 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -14,7 +14,6 @@ import { TGetUserProjectPermissionDTO, TOrgRole, TPermission, - TProjectPermission, TProjectRole } from "./types"; @@ -37,7 +36,9 @@ const glob: JsInterpreter> = (node, object, context) => { const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); export const roleQueryKeys = { - getProjectRoles: (projectId: string) => ["roles", { projectId }] as const, + getProjectRoles: (projectSlug: string) => ["roles", { projectSlug }] as const, + getProjectRoleBySlug: (projectSlug: string, roleSlug: string) => + ["roles", { projectSlug, roleSlug }] as const, getOrgRoles: (orgId: string) => ["org-roles", { orgId }] as const, getUserOrgPermissions: ({ orgId }: TGetUserOrgPermissionsDTO) => ["user-permissions", { orgId }] as const, @@ -46,20 +47,29 @@ export const roleQueryKeys = { }; const getProjectRoles = async (projectId: string) => { - const { data } = await apiRequest.get<{ - data: { roles: Array & { permissions: unknown }> }; - }>(`/api/v1/workspace/${projectId}/roles`); - return data.data.roles.map(({ permissions, ...el }) => ({ - ...el, - permissions: unpackRules(permissions as PackRule[]) - })); + const { data } = await apiRequest.get<{ roles: Array> }>( + `/api/v1/workspace/${projectId}/roles` + ); + return data.roles; }; -export const useGetProjectRoles = (projectId: string) => +export const useGetProjectRoles = (projectSlug: string) => useQuery({ - queryKey: roleQueryKeys.getProjectRoles(projectId), - queryFn: () => getProjectRoles(projectId), - enabled: Boolean(projectId) + queryKey: roleQueryKeys.getProjectRoles(projectSlug), + queryFn: () => getProjectRoles(projectSlug), + enabled: Boolean(projectSlug) + }); + +export const useGetProjectRoleBySlug = (projectSlug: string, roleSlug: string) => + useQuery({ + queryKey: roleQueryKeys.getProjectRoleBySlug(projectSlug, roleSlug), + queryFn: async () => { + const { data } = await apiRequest.get<{ role: TProjectRole }>( + `/api/v1/workspace/${projectSlug}/roles/slug/${roleSlug}` + ); + return data.role; + }, + enabled: Boolean(projectSlug && roleSlug) }); const getOrgRoles = async (orgId: string) => { diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index 5f205e585..e2d1b533a 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -41,7 +41,7 @@ export type TPermission = { export type TProjectPermission = { conditions?: Record; action: string; - subject: [string]; + subject: string | string[]; }; export type TGetUserOrgPermissionsDTO = { @@ -71,7 +71,7 @@ export type TDeleteOrgRoleDTO = { }; export type TCreateProjectRoleDTO = { - projectId: string; + projectSlug: string; name: string; description?: string; slug: string; @@ -79,11 +79,11 @@ export type TCreateProjectRoleDTO = { }; export type TUpdateProjectRoleDTO = { - projectId: string; + projectSlug: string; id: string; } & Partial>; export type TDeleteProjectRoleDTO = { - projectId: string; + projectSlug: string; id: string; }; diff --git a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx index 23017c15d..aaf84941a 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx @@ -220,6 +220,7 @@ export const useGetSecretApprovalRequestCount = ({ }) => useQuery({ queryKey: secretApprovalRequestKeys.count({ workspaceId }), + refetchInterval: 5000, queryFn: () => fetchSecretApprovalRequestCount({ workspaceId }), enabled: Boolean(workspaceId) && (options?.enabled ?? true) }); diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index 32fe31c6b..8c2ba6963 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -44,6 +44,7 @@ export type TSecretApprovalSecChange = { export type TSecretApprovalRequest = { id: string; + isReplicated?: boolean; slug: string; createdAt: string; committerId: string; diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index bcda2b0a4..236a13a26 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -16,6 +16,7 @@ import { TGetFoldersByEnvDTO, TGetProjectFoldersDTO, TSecretFolder, + TUpdateFolderBatchDTO, TUpdateFolderDTO } from "./types"; @@ -79,7 +80,7 @@ export const useGetFoldersByEnv = ({ }); }); return [...names]; - }, [(folders || []).map((folder) => folder.data)]); + }, [...(folders || []).map((folder) => folder.data)]); const isFolderPresentInEnv = useCallback( (name: string, env: string) => { @@ -91,10 +92,24 @@ export const useGetFoldersByEnv = ({ } return false; }, + [...(folders || []).map((folder) => folder.data)] + ); + + const getFolderByNameAndEnv = useCallback( + (name: string, env: string) => { + const selectedEnvIndex = environments.indexOf(env); + if (selectedEnvIndex !== -1) { + return folders?.[selectedEnvIndex]?.data?.find( + ({ name: folderName }) => folderName === name + ); + } + + return undefined; + }, [(folders || []).map((folder) => folder.data)] ); - return { folders, folderNames, isFolderPresentInEnv }; + return { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv }; }; export const useCreateFolder = () => { @@ -176,3 +191,43 @@ export const useDeleteFolder = () => { } }); }; + +export const useUpdateFolderBatch = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateFolderBatchDTO>({ + mutationFn: async ({ projectSlug, folders }) => { + const { data } = await apiRequest.patch("/api/v1/folders/batch", { + projectSlug, + folders + }); + + return data; + }, + onSuccess: (_, { projectId, folders }) => { + folders.forEach((folder) => { + queryClient.invalidateQueries( + folderQueryKeys.getSecretFolders({ + projectId, + environment: folder.environment, + path: folder.path + }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.list({ + workspaceId: projectId, + environment: folder.environment, + directory: folder.path + }) + ); + queryClient.invalidateQueries( + secretSnapshotKeys.count({ + workspaceId: projectId, + environment: folder.environment, + directory: folder.path + }) + ); + }); + } + }); +}; diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index eac202389..412f2686d 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -1,3 +1,7 @@ +export enum ReservedFolders { + SecretReplication = "__reserve_replication_" +} + export type TSecretFolder = { id: string; name: string; @@ -36,3 +40,14 @@ export type TDeleteFolderDTO = { folderId: string; path?: string; }; + +export type TUpdateFolderBatchDTO = { + projectId: string; + projectSlug: string; + folders: { + name: string; + environment: string; + id: string; + path?: string; + }[]; +}; diff --git a/frontend/src/hooks/api/secretImports/index.ts b/frontend/src/hooks/api/secretImports/index.ts index f30506b6b..fed0f13d4 100644 --- a/frontend/src/hooks/api/secretImports/index.ts +++ b/frontend/src/hooks/api/secretImports/index.ts @@ -1,4 +1,9 @@ -export { useCreateSecretImport, useDeleteSecretImport, useUpdateSecretImport } from "./mutation"; +export { + useCreateSecretImport, + useDeleteSecretImport, + useResyncSecretReplication, + useUpdateSecretImport +} from "./mutation"; export { useGetImportedFoldersByEnv, useGetImportedSecretsAllEnvs, diff --git a/frontend/src/hooks/api/secretImports/mutation.tsx b/frontend/src/hooks/api/secretImports/mutation.tsx index 928322a3c..04f1f01e6 100644 --- a/frontend/src/hooks/api/secretImports/mutation.tsx +++ b/frontend/src/hooks/api/secretImports/mutation.tsx @@ -3,18 +3,24 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { secretImportKeys } from "./queries"; -import { TCreateSecretImportDTO, TDeleteSecretImportDTO, TUpdateSecretImportDTO } from "./types"; +import { + TCreateSecretImportDTO, + TDeleteSecretImportDTO, + TResyncSecretReplicationDTO, + TUpdateSecretImportDTO +} from "./types"; export const useCreateSecretImport = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateSecretImportDTO>({ - mutationFn: async ({ import: secretImport, environment, projectId, path }) => { + mutationFn: async ({ import: secretImport, environment, isReplication, projectId, path }) => { const { data } = await apiRequest.post("/api/v1/secret-imports", { import: secretImport, environment, workspaceId: projectId, - path + path, + isReplication }); return data; }, @@ -53,6 +59,19 @@ export const useUpdateSecretImport = () => { }); }; +export const useResyncSecretReplication = () => { + return useMutation<{}, {}, TResyncSecretReplicationDTO>({ + mutationFn: async ({ environment, projectId, path, id }) => { + const { data } = await apiRequest.post(`/api/v1/secret-imports/${id}/replication-resync`, { + environment, + path, + workspaceId: projectId + }); + return data; + } + }); +}; + export const useDeleteSecretImport = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/secretImports/types.ts b/frontend/src/hooks/api/secretImports/types.ts index 950fc20c4..1a6c06dd3 100644 --- a/frontend/src/hooks/api/secretImports/types.ts +++ b/frontend/src/hooks/api/secretImports/types.ts @@ -10,6 +10,11 @@ export type TSecretImport = { position: string; createdAt: string; updatedAt: string; + isReserved?: boolean; + isReplication?: boolean; + isReplicationSuccess?: boolean; + replicationStatus?: string; + lastReplicated?: string; }; export type TGetImportedFoldersByEnvDTO = { @@ -60,6 +65,7 @@ export type TCreateSecretImportDTO = { environment: string; path: string; }; + isReplication?: boolean; }; export type TUpdateSecretImportDTO = { @@ -74,6 +80,13 @@ export type TUpdateSecretImportDTO = { }>; }; +export type TResyncSecretReplicationDTO = { + id: string; + projectId: string; + environment: string; + path?: string; +}; + export type TDeleteSecretImportDTO = { id: string; projectId: string; diff --git a/frontend/src/hooks/api/secretSharing/index.ts b/frontend/src/hooks/api/secretSharing/index.ts new file mode 100644 index 000000000..177955438 --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/index.ts @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/secretSharing/mutations.ts b/frontend/src/hooks/api/secretSharing/mutations.ts new file mode 100644 index 000000000..e21cc08f6 --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/mutations.ts @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCreateSharedSecretRequest, TDeleteSharedSecretRequest, TSharedSecret } from "./types"; + +export const useCreateSharedSecret = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (inputData: TCreateSharedSecretRequest) => { + const { data } = await apiRequest.post("/api/v1/secret-sharing", inputData); + return data; + }, + onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"]) + }); +}; + +export const useDeleteSharedSecret = () => { + const queryClient = useQueryClient(); + return useMutation< + TSharedSecret, + { message: string }, + { sharedSecretId: string } + >({ + mutationFn: async ({ sharedSecretId }: TDeleteSharedSecretRequest) => { + const { data } = await apiRequest.delete( + `/api/v1/secret-sharing/${sharedSecretId}` + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(["sharedSecrets"]); + } + }); +}; diff --git a/frontend/src/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts new file mode 100644 index 000000000..c7970fabc --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/queries.ts @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSharedSecret, TViewSharedSecretResponse } from "./types"; + +export const useGetSharedSecrets = () => { + return useQuery({ + queryKey: ["sharedSecrets"], + queryFn: async () => { + const { data } = await apiRequest.get("/api/v1/secret-sharing/"); + return data; + } + }); +}; + +export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex: string) => { + return useQuery({ + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}` + ); + return { + encryptedValue: data.encryptedValue, + iv: data.iv, + tag: data.tag, + }; + } + }); +}; diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts new file mode 100644 index 000000000..424e3525c --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/types.ts @@ -0,0 +1,26 @@ +export type TSharedSecret = { + id: string; + userId: string; + orgId: string; + createdAt: Date; + updatedAt: Date; +} & TCreateSharedSecretRequest; + +export type TCreateSharedSecretRequest = { + encryptedValue: string; + iv: string; + tag: string; + hashedHex: string; + expiresAt: Date; + expiresAfterViews: number; +}; + +export type TViewSharedSecretResponse = { + encryptedValue: string; + iv: string; + tag: string; +}; + +export type TDeleteSharedSecretRequest = { + sharedSecretId: string; +}; diff --git a/frontend/src/hooks/api/secrets/index.ts b/frontend/src/hooks/api/secrets/index.ts index b24b5ed19..b58e8779a 100644 --- a/frontend/src/hooks/api/secrets/index.ts +++ b/frontend/src/hooks/api/secrets/index.ts @@ -1,4 +1,5 @@ export { + useBackfillSecretReference, useCreateSecretBatch, useCreateSecretV3, useDeleteSecretBatch, diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index 448daee3b..e397c7b55 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -87,11 +87,11 @@ export const useCreateSecretV3 = ({ const randomBytes = latestFileKey ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) : crypto.randomBytes(16).toString("hex"); const reqBody = { @@ -148,11 +148,11 @@ export const useUpdateSecretV3 = ({ const randomBytes = latestFileKey ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) : crypto.randomBytes(16).toString("hex"); const reqBody = { @@ -244,11 +244,11 @@ export const useCreateSecretBatch = ({ const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; const randomBytes = latestFileKey ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) : crypto.randomBytes(16).toString("hex"); const reqBody = { @@ -297,11 +297,11 @@ export const useUpdateSecretBatch = ({ const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; const randomBytes = latestFileKey ? decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: PRIVATE_KEY - }) + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }) : crypto.randomBytes(16).toString("hex"); const reqBody = { @@ -379,3 +379,13 @@ export const createSecret = async (dto: CreateSecretDTO) => { const { data } = await apiRequest.post(`/api/v3/secrets/${dto.secretKey}`, dto); return data; }; + +export const useBackfillSecretReference = () => + useMutation<{ message: string }, {}, { projectId: string }>({ + mutationFn: async ({ projectId }) => { + const { data } = await apiRequest.post("/api/v3/secrets/backfill-secret-references", { + projectId + }); + return data.message; + } + }); diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 1ba9a5251..28999389e 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -98,7 +98,7 @@ export const decryptSecrets = ( return secrets; }; -const fetchProjectEncryptedSecrets = async ({ +export const fetchProjectEncryptedSecrets = async ({ workspaceId, environment, secretPath diff --git a/frontend/src/hooks/api/serverDetails/types.ts b/frontend/src/hooks/api/serverDetails/types.ts index 80d34a150..911526404 100644 --- a/frontend/src/hooks/api/serverDetails/types.ts +++ b/frontend/src/hooks/api/serverDetails/types.ts @@ -4,4 +4,5 @@ export type ServerStatus = { emailConfigured: boolean; secretScanningConfigured: boolean; redisConfigured: boolean; + samlDefaultOrgSlug: boolean }; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 46facedd3..45414292d 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -5,6 +5,8 @@ export type SubscriptionPlan = { auditLogs: boolean; dynamicSecret: boolean; auditLogsRetentionDays: number; + auditLogStreamLimit: number; + auditLogStreams: boolean; customAlerts: boolean; customRateLimits: boolean; pitRecovery: boolean; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index d6b43d674..516a5d7cf 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -1,5 +1,7 @@ import { ZodIssue } from "zod"; +export type { TAccessApprovalPolicy } from "./accessApproval/types"; +export type { TAuditLogStream } from "./auditLogStreams/types"; export type { GetAuthTokenAPI } from "./auth/types"; export type { IncidentContact } from "./incidentContacts/types"; export type { IntegrationAuth } from "./integrationAuth/types"; diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 9c51948f2..a8ad89f4c 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,4 +1,9 @@ -export { useAddUserToWsE2EE, useAddUserToWsNonE2EE } from "./mutation"; +export { + useAddUserToWsE2EE, + useAddUserToWsNonE2EE, + useSendEmailVerificationCode, + useVerifyEmailVerificationCode +} from "./mutation"; export { fetchOrgUsers, useAddUserToOrg, diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index a5c77b15f..20e986aab 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -61,3 +61,30 @@ export const useAddUserToWsNonE2EE = () => { } }); }; + +export const sendEmailVerificationCode = async (username: string) => { + return apiRequest.post("/api/v2/users/me/emails/code", { + username + }); +}; + +export const useSendEmailVerificationCode = () => { + return useMutation({ + mutationFn: async (username: string) => { + await sendEmailVerificationCode(username); + return {}; + } + }); +}; + +export const useVerifyEmailVerificationCode = () => { + return useMutation({ + mutationFn: async ({ username, code }: { username: string; code: string }) => { + await apiRequest.post("/api/v2/users/me/emails/verify", { + username, + code + }); + return {}; + } + }); +}; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 572296e75..649af434c 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -27,6 +27,11 @@ export type User = { id: string; }; +export enum UserAliasType { + LDAP = "ldap", + SAML = "saml" +} + export type UserEnc = { encryptionVersion?: number; protectedKey?: string; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index cca544df3..71dbb8e01 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -198,7 +198,8 @@ export const useGetWorkspaceIntegrations = (workspaceId: string) => useQuery({ queryKey: workspaceKeys.getWorkspaceIntegrations(workspaceId), queryFn: () => fetchWorkspaceIntegrations(workspaceId), - enabled: Boolean(workspaceId) + enabled: Boolean(workspaceId), + refetchInterval: 4000 }); export const createWorkspace = ({ diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 2550150f3..1b5df0037 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -5,7 +5,7 @@ /* eslint-disable no-var */ /* eslint-disable func-names */ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Image from "next/image"; @@ -64,6 +64,7 @@ import { fetchOrgUsers, useAddUserToWsNonE2EE, useCreateWorkspace, + useGetAccessRequestsCount, useGetOrgTrialUrl, useGetSecretApprovalRequestCount, useGetUserAction, @@ -115,7 +116,7 @@ type TAddProjectFormData = yup.InferType; export const AppLayout = ({ children }: LayoutProps) => { const router = useRouter(); - + const { mutateAsync } = useGetOrgTrialUrl(); const { workspaces, currentWorkspace } = useWorkspace(); @@ -124,9 +125,15 @@ export const AppLayout = ({ children }: LayoutProps) => { const { user } = useUser(); const { subscription } = useSubscription(); const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; const { data: updateClosed } = useGetUserAction("december_update_closed"); const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId }); + const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug }); + + const pendingRequestsCount = useMemo(() => { + return (secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0); + }, [secretApprovalReqCount, accessApprovalRequestCount]); const isAddingProjectsAllowed = subscription?.workspaceLimit ? subscription.workspacesUsed < subscription.workspaceLimit @@ -554,10 +561,13 @@ export const AppLayout = ({ children }: LayoutProps) => { } icon="system-outline-189-domain-verification" > - Secret Approvals - {Boolean(secretApprovalReqCount?.open) && ( + Approvals + {Boolean( + secretApprovalReqCount?.open || + accessApprovalRequestCount?.pendingCount + ) && ( - {secretApprovalReqCount?.open} + {pendingRequestsCount} )} @@ -620,6 +630,18 @@ export const AppLayout = ({ children }: LayoutProps) => { + + + + Secret Sharing + + + {(window.location.origin.includes("https://app.infisical.com") || window.location.origin.includes("https://gamma.infisical.com")) && ( diff --git a/frontend/src/lib/fn/debounce.ts b/frontend/src/lib/fn/debounce.ts new file mode 100644 index 000000000..16d7b4ceb --- /dev/null +++ b/frontend/src/lib/fn/debounce.ts @@ -0,0 +1,15 @@ +export const debounce = any>( + func: F, + delay: number +): ((...args: Parameters) => void) => { + let timeoutId: ReturnType | null; + return function debounced(...args: Parameters) { + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => { + func(...args); + timeoutId = null; + }, delay); + }; +}; diff --git a/frontend/src/lib/fn/string.ts b/frontend/src/lib/fn/string.ts new file mode 100644 index 000000000..9d3d01cc5 --- /dev/null +++ b/frontend/src/lib/fn/string.ts @@ -0,0 +1,9 @@ +import { ReservedFolders } from "@app/hooks/api/secretFolders/types"; + +export const formatReservedPaths = (secretPath: string) => { + const i = secretPath.indexOf(ReservedFolders.SecretReplication); + if (i !== -1) { + return `${secretPath.slice(0, i)} - (replication)`; + } + return secretPath; +}; diff --git a/frontend/src/pages/integrations/aws-parameter-store/create.tsx b/frontend/src/pages/integrations/aws-parameter-store/create.tsx index 9f52347ce..ee9bf30b9 100644 --- a/frontend/src/pages/integrations/aws-parameter-store/create.tsx +++ b/frontend/src/pages/integrations/aws-parameter-store/create.tsx @@ -89,6 +89,7 @@ export default function AWSParameterStoreCreateIntegrationPage() { const [isLoading, setIsLoading] = useState(false); const [shouldTag, setShouldTag] = useState(false); + const [shouldDisableDelete, setShouldDisableDelete] = useState(false); const [tagKey, setTagKey] = useState(""); const [tagValue, setTagValue] = useState(""); const [kmsKeyId, setKmsKeyId] = useState(""); @@ -100,19 +101,12 @@ export default function AWSParameterStoreCreateIntegrationPage() { } }, [workspace]); - const { data: integrationAuthAwsKmsKeys, isLoading: isIntegrationAuthAwsKmsKeysLoading } = useGetIntegrationAuthAwsKmsKeys({ - integrationAuthId: String(integrationAuthId), + integrationAuthId: String(integrationAuthId), region: selectedAWSRegion }); - useEffect(() => { - if (integrationAuthAwsKmsKeys) { - setKmsKeyId(String(integrationAuthAwsKmsKeys?.filter(key => key.alias === "default")[0]?.id)) - } - }, [integrationAuthAwsKmsKeys]) - const isValidAWSParameterStorePath = (awsStorePath: string) => { const pattern = /^\/([\w-]+\/)*[\w-]+\/$/; return pattern.test(awsStorePath) && awsStorePath.length <= 2048; @@ -143,16 +137,16 @@ export default function AWSParameterStoreCreateIntegrationPage() { metadata: { ...(shouldTag ? { - secretAWSTag: [{ - key: tagKey, - value: tagValue - }] + secretAWSTag: [ + { + key: tagKey, + value: tagValue + } + ] } : {}), - ...((kmsKeyId && integrationAuthAwsKmsKeys?.filter(key => key.id === kmsKeyId)[0]?.alias !== "default") ? - { - kmsKeyId - }: {}) + ...(kmsKeyId && { kmsKeyId }), + ...(shouldDisableDelete && { shouldDisableDelete }) } }); @@ -165,7 +159,10 @@ export default function AWSParameterStoreCreateIntegrationPage() { } }; - return (integrationAuth && workspace && selectedSourceEnvironment && !isIntegrationAuthAwsKmsKeysLoading) ? ( + return integrationAuth && + workspace && + selectedSourceEnvironment && + !isIntegrationAuthAwsKmsKeysLoading ? (
Set Up AWS Parameter Integration @@ -241,7 +238,10 @@ export default function AWSParameterStoreCreateIntegrationPage() { + setTagKey(e.target.value)} /> - - + setTagValue(e.target.value)} /> @@ -309,7 +314,7 @@ export default function AWSParameterStoreCreateIntegrationPage() { setSelectedAWSRegion(val)} + onValueChange={(val) => { + setSelectedAWSRegion(val); + setKmsKeyId(""); + }} className="w-full border border-mineshaft-500" > {awsRegions.map((awsRegion) => ( @@ -250,19 +271,40 @@ export default function AWSSecretManagerCreateIntegrationPage() { ))} - - setTargetSecretName(e.target.value)} - /> + + + {selectedMappingBehavior === IntegrationMappingBehavior.MANY_TO_ONE && ( + + setTargetSecretName(e.target.value)} + /> + + )} @@ -284,20 +326,16 @@ export default function AWSSecretManagerCreateIntegrationPage() {
{shouldTag && (
- - + setTagKey(e.target.value)} /> - - + setTagValue(e.target.value)} /> @@ -308,7 +346,7 @@ export default function AWSSecretManagerCreateIntegrationPage() { + + )} + /> + ( + + + + )} + /> + + + +
+ ); +} + +RundeckAuthorizeIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/rundeck/create.tsx b/frontend/src/pages/integrations/rundeck/create.tsx new file mode 100644 index 000000000..543d9f4b0 --- /dev/null +++ b/frontend/src/pages/integrations/rundeck/create.tsx @@ -0,0 +1,217 @@ +import { Controller, useForm } from "react-hook-form"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faArrowUpRightFromSquare, faBookOpen, faBugs } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import queryString from "query-string"; +import { z } from "zod"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; +import { useCreateIntegration } from "@app/hooks/api"; +import { useGetIntegrationAuthById } from "@app/hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "@app/hooks/api/workspace"; + +const schema = z.object({ + keyStoragePath: z.string().trim().min(1, { message: "Rundeck Key Storage path is required" }), + secretPath: z.string().trim().min(1, { message: "Secret path is required" }), + sourceEnvironment: z.string().trim().min(1, { message: "Source environment is required" }) +}); + +type TFormSchema = z.infer; + +export default function RundeckCreateIntegrationPage() { + const { + control, + handleSubmit, + watch, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + secretPath: "/" + } + }); + const router = useRouter(); + const { mutateAsync } = useCreateIntegration(); + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth, isLoading: isIntegrationAuthLoading } = useGetIntegrationAuthById( + (integrationAuthId as string) ?? "" + ); + + const selectedSourceEnvironment = watch("sourceEnvironment"); + + const onFormSubmit = async ({ secretPath, sourceEnvironment, keyStoragePath }: TFormSchema) => { + try { + if (!integrationAuth?.id) return; + + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + path: keyStoragePath, + sourceEnvironment, + url: integrationAuth.url, + secretPath + }); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && workspace ? ( +
+ + Set Up Rundeck Integration + + + + +
+
+ Rundeck logo +
+ Rundeck Integration + + +
+ + Docs + +
+
+ +
+
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + + +
+
+ ) : ( +
+ + Set Up Rundeck Integration + + + {isIntegrationAuthLoading ? ( + infisical loading indicator + ) : ( +
+ +

+ Something went wrong. Please contact{" "} + + support@infisical.com + {" "} + if the issue persists. +

+
+ )} +
+ ); +} + +RundeckCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/login/index.tsx b/frontend/src/pages/login/index.tsx index dd8068dd2..8fdb23f69 100644 --- a/frontend/src/pages/login/index.tsx +++ b/frontend/src/pages/login/index.tsx @@ -7,7 +7,6 @@ import { Login } from "@app/views/Login"; export default function LoginPage() { const { t } = useTranslation(); - return (
diff --git a/frontend/src/pages/login/ldap/index.tsx b/frontend/src/pages/login/ldap/index.tsx new file mode 100644 index 000000000..5d5300e6f --- /dev/null +++ b/frontend/src/pages/login/ldap/index.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; + +import { LoginLDAP } from "@app/views/Login"; + +export default function LoginLDAPPage() { + const { t } = useTranslation(); + return ( +
+ + {t("common.head-title", { title: t("login.title") })} + + + + + + +
+ Infisical logo +
+ + +
+ ); +} diff --git a/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx index 586ed62f9..22006408e 100644 --- a/frontend/src/pages/login/select-organization.tsx +++ b/frontend/src/pages/login/select-organization.tsx @@ -35,8 +35,6 @@ export default function LoginPage() { const selectOrg = useSelectOrganization(); const { user, isLoading: userLoading } = useUser(); - - const queryParams = new URLSearchParams(window.location.search); const logout = useLogoutUser(true); @@ -123,6 +121,14 @@ export default function LoginPage() { } }, [router]); + // Case: User has no organizations. + // This can happen if the user was previously a member, but the organization was deleted or the user was removed. + useEffect(() => { + if (!organizations.isLoading && organizations.data?.length === 0) { + router.push("/org/none"); + } + }, [organizations.isLoading, organizations.data]); + if (userLoading || !user) { return ; } @@ -153,7 +159,7 @@ export default function LoginPage() {

- You‘re currently logged in as {user.email} + You‘re currently logged in as {user.username}

Not you?{" "} diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index b6cc4ee46..17cf8537c 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -21,9 +21,7 @@ import { faNetworkWired, faPlug, faPlus, - faUserPlus, - faWarning, - faXmark + faUserPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; @@ -56,7 +54,6 @@ import { fetchOrgUsers, useAddUserToWsNonE2EE, useCreateWorkspace, - useGetUserAction, useRegisterUserAction } from "@app/hooks/api"; // import { fetchUserWsKey } from "@app/hooks/api/keys/queries"; @@ -312,9 +309,8 @@ const LearningItem = ({ href={link} >

null} @@ -325,11 +321,10 @@ const LearningItem = ({ await registerUserAction.mutateAsync(userAction); } }} - className={`group relative flex h-[5.5rem] w-full items-center justify-between overflow-hidden rounded-md border ${ - complete + className={`group relative flex h-[5.5rem] w-full items-center justify-between overflow-hidden rounded-md border ${complete ? "cursor-default border-mineshaft-900 bg-gradient-to-r from-[#0e1f01] to-mineshaft-700" : "cursor-pointer border-mineshaft-600 bg-mineshaft-800 shadow-xl hover:bg-mineshaft-700" - } text-mineshaft-100 duration-200`} + } text-mineshaft-100 duration-200`} >
@@ -407,9 +402,8 @@ const LearningItemSquare = ({ href={link} >
null} @@ -420,11 +414,10 @@ const LearningItemSquare = ({ await registerUserAction.mutateAsync(userAction); } }} - className={`group relative flex w-full items-center justify-between overflow-hidden rounded-md border ${ - complete + className={`group relative flex w-full items-center justify-between overflow-hidden rounded-md border ${complete ? "cursor-default border-mineshaft-900 bg-gradient-to-r from-[#0e1f01] to-mineshaft-700" : "cursor-pointer border-mineshaft-600 bg-mineshaft-800 shadow-xl hover:bg-mineshaft-700" - } text-mineshaft-100 duration-200`} + } text-mineshaft-100 duration-200`} >
@@ -438,9 +431,8 @@ const LearningItemSquare = ({
)}
{complete ? "Complete!" : `About ${time}`}
@@ -480,14 +472,8 @@ const OrganizationPage = withPermission( const { currentOrg } = useOrganization(); const routerOrgId = String(router.query.id); const orgWorkspaces = workspaces?.filter((workspace) => workspace.orgId === routerOrgId) || []; - - const addUsersToProject = useAddUserToWsNonE2EE(); - const { data: updateClosed } = useGetUserAction("april_13_2024_db_update_closed"); - const registerUserAction = useRegisterUserAction(); - const closeUpdate = async () => { - await registerUserAction.mutateAsync("april_13_2024_db_update_closed"); - }; + const addUsersToProject = useAddUserToWsNonE2EE(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "addNewWs", @@ -594,31 +580,6 @@ const OrganizationPage = withPermission(
)}
- {window.location.origin.includes("https://app.infisical.com") || window.location.origin.includes("http://localhost:8080") && ( -
- -
- Scheduled maintenance on April 13th 2024 {" "} -
- Infisical will undergo scheduled maintenance for approximately 1 hour on Saturday, April 13th, 11am EST. During these hours, read - operations will continue to function normally but no resources will be editable. - No action is required on your end — your applications will continue to fetch secrets. -
-
- -
)} -

Projects

-

Onboarding Guide

-
- - {orgWorkspaces.length !== 0 && ( - <> - - - - )} -
+
+

Onboarding Guide

+
+ {orgWorkspaces.length !== 0 && ( + <> + + + + )} +
+ +
-
- {orgWorkspaces.length !== 0 && ( -
-
-
- - {false && ( -
- -
- )} -
-
Inject secrets locally
-
- Replace .env files with a more secure and efficient alternative. + {orgWorkspaces.length !== 0 && ( +
+
+
+ + {false && ( +
+ +
+ )} +
+
Inject secrets locally
+
+ Replace .env files with a more secure and efficient alternative. +
+
+ About 2 min +
-
- About 2 min -
+ + {false &&
}
- - {false &&
} -
- )} - {orgWorkspaces.length !== 0 && ( - - )} -
- )} + )} + {orgWorkspaces.length !== 0 && ( + + )} +
+ )} { diff --git a/frontend/src/pages/org/[id]/secret-scanning/index.tsx b/frontend/src/pages/org/[id]/secret-scanning/index.tsx index 97a715e37..fd44f6961 100644 --- a/frontend/src/pages/org/[id]/secret-scanning/index.tsx +++ b/frontend/src/pages/org/[id]/secret-scanning/index.tsx @@ -3,8 +3,8 @@ import Head from "next/head"; import { useRouter } from "next/router"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Button } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { Button, NoticeBanner } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useServerConfig } from "@app/context"; import { withPermission } from "@app/hoc"; import { SecretScanningLogsTable } from "@app/views/SecretScanning/components"; @@ -17,6 +17,7 @@ const SecretScanning = withPermission( const router = useRouter(); const queryParams = router.query; const [integrationEnabled, setIntegrationStatus] = useState(false); + const { config } = useServerConfig(); useEffect(() => { const linkInstallation = async () => { @@ -69,6 +70,11 @@ const SecretScanning = withPermission(
Automatically monitor your GitHub activity and prevent secret leaks
+ {config.isSecretScanningDisabled && ( + + We are working on improving the performance of secret scanning due to increased usage. + + )}
@@ -110,7 +116,7 @@ const SecretScanning = withPermission( colorSchema="primary" onClick={generateNewIntegrationSession} className="h-min py-2" - isDisabled={!isAllowed} + isDisabled={!isAllowed || config.isSecretScanningDisabled} > Integrate with GitHub diff --git a/frontend/src/pages/org/[id]/secret-sharing/index.tsx b/frontend/src/pages/org/[id]/secret-sharing/index.tsx new file mode 100644 index 000000000..28bcb7831 --- /dev/null +++ b/frontend/src/pages/org/[id]/secret-sharing/index.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { ShareSecretPage } from "@app/views/ShareSecretPage"; + +const SecretApproval = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("approval.title") })} + + + + + +
+ +
+ + ); +}; + +export default SecretApproval; + +SecretApproval.requireAuth = true; diff --git a/frontend/src/pages/project/[id]/secrets/v2/[env].tsx b/frontend/src/pages/project/[id]/secrets/v2/[env].tsx deleted file mode 100644 index bba0704dd..000000000 --- a/frontend/src/pages/project/[id]/secrets/v2/[env].tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useTranslation } from "react-i18next"; -import Head from "next/head"; - -import { SecretMainPage } from "@app/views/SecretMainPage"; - -const Dashboard = () => { - const { t } = useTranslation(); - - return ( - <> - - {t("common.head-title", { title: t("dashboard.title") })} - - - - - -
- -
- - ); -}; - -export default Dashboard; - -Dashboard.requireAuth = true; diff --git a/frontend/src/pages/shared/secret/[id]/index.tsx b/frontend/src/pages/shared/secret/[id]/index.tsx new file mode 100644 index 000000000..7f53d962d --- /dev/null +++ b/frontend/src/pages/shared/secret/[id]/index.tsx @@ -0,0 +1,24 @@ +import Head from "next/head"; + +import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage"; + +const SecretApproval = () => { + return ( + <> + + Securely Share Secrets | Infisical + + + + + +
+ +
+ + ); +}; + +export default SecretApproval; + +SecretApproval.requireAuth = false; diff --git a/frontend/src/pages/signup/index.tsx b/frontend/src/pages/signup/index.tsx index 17316e1e6..0719111d6 100644 --- a/frontend/src/pages/signup/index.tsx +++ b/frontend/src/pages/signup/index.tsx @@ -13,7 +13,7 @@ import TeamInviteStep from "@app/components/signup/TeamInviteStep"; import UserInfoStep from "@app/components/signup/UserInfoStep"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { useServerConfig } from "@app/context"; -import { useVerifyEmailVerificationCode } from "@app/hooks/api"; +import { useVerifySignupEmailVerificationCode } from "@app/hooks/api"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; @@ -34,7 +34,7 @@ export default function SignUp() { const [isSignupWithEmail, setIsSignupWithEmail] = useState(false); const [isCodeInputCheckLoading, setIsCodeInputCheckLoading] = useState(false); const { t } = useTranslation(); - const { mutateAsync } = useVerifyEmailVerificationCode(); + const { mutateAsync } = useVerifySignupEmailVerificationCode(); const { config } = useServerConfig(); useEffect(() => { diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 9a1295d19..d3aa5c7f0 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -45,6 +45,14 @@ html { width: 1%; white-space: nowrap; } + + .w-inherit { + width: inherit; + } + + .h-inherit { + height: inherit; + } } @layer components { diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 10ef21e27..1aee4c656 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -128,6 +128,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "hasura-cloud": link = `${window.location.origin}/integrations/hasura-cloud/authorize`; break; + case "rundeck": + link = `${window.location.origin}/integrations/rundeck/authorize`; + break; default: break; } diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 185cc411f..d56010278 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -1,21 +1,27 @@ import Link from "next/link"; -import { faArrowRight, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { faCalendarCheck } from "@fortawesome/free-regular-svg-icons"; +import { faArrowRight, faRefresh, faWarning, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; import { integrationSlugNameMapping } from "public/data/frequentConstants"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Alert, AlertDescription, + Button, DeleteActionModal, EmptyState, FormLabel, IconButton, Skeleton, + Tag, Tooltip } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { usePopUp } from "@app/hooks"; +import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; +import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types"; import { TIntegration } from "@app/hooks/api/types"; type Props = { @@ -39,6 +45,8 @@ export const IntegrationsSection = ({ "deleteConfirmation" ] as const); + const { mutate: syncIntegration } = useSyncIntegration(); + return (
@@ -74,7 +82,7 @@ export const IntegrationsSection = ({
)} {!isLoading && isBotActive && ( -
+
{integrations?.map((integration) => (
)} -
- -
- {(integration.integration === "hashicorp-vault" && - `${integration.app} - path: ${integration.path}`) || - (integration.scope === "github-org" && `${integration.owner}`) || - (integration.integration === "aws-parameter-store" && `${integration.path}`) || - (integration.scope?.startsWith("github-") && - `${integration.owner}/${integration.app}`) || - integration.app} + {!( + integration.integration === "aws-secret-manager" && + integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE + ) && ( +
+ +
+ {(integration.integration === "hashicorp-vault" && + `${integration.app} - path: ${integration.path}`) || + (integration.scope === "github-org" && `${integration.owner}`) || + (["aws-parameter-store", "rundeck"].includes(integration.integration) && + `${integration.path}`) || + (integration.scope?.startsWith("github-") && + `${integration.owner}/${integration.app}`) || + integration.app} +
-
+ )} {(integration.integration === "vercel" || integration.integration === "netlify" || integration.integration === "railway" || @@ -187,13 +202,70 @@ export const IntegrationsSection = ({
)}
-
+
+ {integration.isSynced != null && integration.lastUsed != null && ( + + +
+ +
Last sync
+
+
+ {format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")} +
+ {!integration.isSynced && ( + <> +
+ +
Fail reason
+
+
+ {integration.syncMessage} +
+ + )} +
+ } + > +
+
Sync Status
+ {!integration.isSynced && } +
+ + + )} +
+ + + +
{(isAllowed: boolean) => ( -
+
handlePopUpOpen("deleteConfirmation", integration)} @@ -217,7 +289,9 @@ export const IntegrationsSection = ({ isOpen={popUp.deleteConfirmation.isOpen} title={`Are you sure want to remove ${ (popUp?.deleteConfirmation.data as TIntegration)?.integration || " " - } integration for ${(popUp?.deleteConfirmation.data as TIntegration)?.app || "this project"}?`} + } integration for ${ + (popUp?.deleteConfirmation.data as TIntegration)?.app || "this project" + }?`} onChange={(isOpen) => handlePopUpToggle("deleteConfirmation", isOpen)} deleteKey={ (popUp?.deleteConfirmation?.data as TIntegration)?.app || diff --git a/frontend/src/views/Login/Login.tsx b/frontend/src/views/Login/Login.tsx index ac56c28c3..04a24d233 100644 --- a/frontend/src/views/Login/Login.tsx +++ b/frontend/src/views/Login/Login.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { isLoggedIn } from "@app/reactQuery"; -import { InitialStep, LDAPStep, MFAStep, SAMLSSOStep } from "./components"; +import { InitialStep, MFAStep, SAMLSSOStep } from "./components"; import { navigateUserToSelectOrg } from "./Login.utils"; export const Login = () => { @@ -58,8 +58,6 @@ export const Login = () => { ); case 2: return ; - case 3: - return ; default: return
; } diff --git a/frontend/src/views/Login/components/LDAPStep/LDAPStep.tsx b/frontend/src/views/Login/LoginLDAP.tsx similarity index 88% rename from frontend/src/views/Login/components/LDAPStep/LDAPStep.tsx rename to frontend/src/views/Login/LoginLDAP.tsx index e23f16e04..021ac7334 100644 --- a/frontend/src/views/Login/components/LDAPStep/LDAPStep.tsx +++ b/frontend/src/views/Login/LoginLDAP.tsx @@ -1,24 +1,23 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { useRouter } from "next/router"; import { createNotification } from "@app/components/notifications"; import { Button, Input } from "@app/components/v2"; import { loginLDAPRedirect } from "@app/hooks/api/auth/queries"; -type Props = { - setStep: (step: number) => void; -}; +export const LoginLDAP = () => { + const router = useRouter(); + const queryParams = new URLSearchParams(window.location.search); + const passedOrgSlug = queryParams.get("organizationSlug"); + const passedUsername = queryParams.get("username"); -export const LDAPStep = ({ setStep }: Props) => { - - const [organizationSlug, setOrganizationSlug] = useState(""); - const [username, setUsername] = useState(""); + const [organizationSlug, setOrganizationSlug] = useState(passedOrgSlug || ""); + const [username, setUsername] = useState(passedUsername || ""); const [password, setPassword] = useState(""); const { t } = useTranslation(); - // const queryParams = new URLSearchParams(window.location.search); - const handleSubmission = async (e: React.FormEvent) => { e.preventDefault(); try { @@ -42,7 +41,6 @@ export const LDAPStep = ({ setStep }: Props) => { type: "success" }); - // redirects either to /login/sso or /signup/sso window.open(nextUrl); window.close(); } catch (err) { @@ -76,6 +74,7 @@ export const LDAPStep = ({ setStep }: Props) => { autoComplete="email" id="email" className="h-12" + isDisabled={passedOrgSlug !== null} />
@@ -90,6 +89,7 @@ export const LDAPStep = ({ setStep }: Props) => { autoComplete="email" id="email" className="h-12" + isDisabled={passedUsername !== null} />
@@ -122,7 +122,7 @@ export const LDAPStep = ({ setStep }: Props) => {
{typeof triesLeft === "number" && ( - + )}
diff --git a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx index f9b086f6d..3e5e45985 100644 --- a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx @@ -31,7 +31,6 @@ export const PasswordStep = ({ setPassword, setStep }: Props) => { - const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const router = useRouter(); @@ -146,13 +145,23 @@ export const PasswordStep = ({ } } } - } catch (err) { + } catch (err: any) { setIsLoading(false); + console.error(err); + + if (err.response.data.error === "User Locked") { + createNotification({ + title: err.response.data.error, + text: err.response.data.message, + type: "error" + }); + return; + } + createNotification({ text: "Login unsuccessful. Double-check your master password and try again.", type: "error" }); - console.error(err); } }; diff --git a/frontend/src/views/Login/components/index.tsx b/frontend/src/views/Login/components/index.tsx index 7c1b5f800..7c55c4acf 100644 --- a/frontend/src/views/Login/components/index.tsx +++ b/frontend/src/views/Login/components/index.tsx @@ -1,5 +1,4 @@ export { InitialStep } from "./InitialStep"; -export { LDAPStep } from "./LDAPStep"; export { MFAStep } from "./MFAStep"; export { SAMLSSOStep } from "./SAMLSSOStep"; diff --git a/frontend/src/views/Login/index.tsx b/frontend/src/views/Login/index.tsx index 2c4c1f953..722e56fad 100644 --- a/frontend/src/views/Login/index.tsx +++ b/frontend/src/views/Login/index.tsx @@ -1,2 +1,3 @@ export { Login } from "./Login"; +export { LoginLDAP } from "./LoginLDAP"; export { LoginSSO } from "./LoginSSO"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx index 7ec17dd31..a1dc5d678 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; @@ -13,6 +14,10 @@ import { import { IdentityAuthMethod } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { IdentityAwsAuthForm } from "./IdentityAwsAuthForm"; +import { IdentityAzureAuthForm } from "./IdentityAzureAuthForm"; +import { IdentityGcpAuthForm } from "./IdentityGcpAuthForm"; +import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm"; import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; type Props = { @@ -24,22 +29,28 @@ type Props = { ) => void; }; -const identityAuthMethods = [{ label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }]; +const identityAuthMethods = [ + { label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }, + { label: "Kubernetes Auth", value: IdentityAuthMethod.KUBERNETES_AUTH }, + { label: "GCP Auth", value: IdentityAuthMethod.GCP_AUTH }, + { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH }, + { label: "Azure Auth", value: IdentityAuthMethod.AZURE_AUTH } +]; const schema = yup .object({ - authMethod: yup.string().required("Auth method is required") // TODO: better enforcement here + authMethod: yup.string().required("Auth method is required") }) .required(); export type FormData = yup.InferType; export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => { - const { - control - // watch, - } = useForm({ - resolver: yupResolver(schema) + const { control, watch, setValue } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + authMethod: IdentityAuthMethod.UNIVERSAL_AUTH + } }); const identityAuthMethodData = popUp?.identityAuthMethod?.data as { @@ -48,16 +59,68 @@ export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpTog authMethod?: IdentityAuthMethod; }; - // const authMethod = watch("authMethod"); + useEffect(() => { + if (identityAuthMethodData?.authMethod) { + setValue("authMethod", identityAuthMethodData.authMethod); + return; + } + + setValue("authMethod", IdentityAuthMethod.UNIVERSAL_AUTH); + }, [identityAuthMethodData?.authMethod]); + + const authMethod = watch("authMethod"); const renderIdentityAuthForm = () => { - return ( - - ); + switch (identityAuthMethodData?.authMethod ?? authMethod) { + case IdentityAuthMethod.AWS_AUTH: { + return ( + + ); + } + case IdentityAuthMethod.KUBERNETES_AUTH: { + return ( + + ); + } + case IdentityAuthMethod.GCP_AUTH: { + return ( + + ); + } + case IdentityAuthMethod.AZURE_AUTH: { + return ( + + ); + } + case IdentityAuthMethod.UNIVERSAL_AUTH: { + return ( + + ); + } + default: { + return
; + } + } }; return ( @@ -83,6 +146,7 @@ export const IdentityAuthMethodModal = ({ popUp, handlePopUpOpen, handlePopUpTog {...field} onValueChange={(e) => onChange(e)} className="w-full" + isDisabled={!!identityAuthMethodData?.authMethod} > {identityAuthMethods.map(({ label, value }) => ( diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm.tsx new file mode 100644 index 000000000..d347d422d --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm.tsx @@ -0,0 +1,352 @@ +import { useEffect } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, IconButton, Input } from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { + useAddIdentityAwsAuth, + useGetIdentityAwsAuth, + useUpdateIdentityAwsAuth +} from "@app/hooks/api"; +import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup + .object({ + stsEndpoint: yup.string(), + allowedPrincipalArns: yup.string(), + allowedAccountIds: yup.string(), + accessTokenTTL: yup.string().required("Access Token TTL is required"), + accessTokenMaxTTL: yup.string().required("Access Max Token TTL is required"), + accessTokenNumUsesLimit: yup.string().required("Access Token Max Number of Uses is required"), + accessTokenTrustedIps: yup + .array( + yup.object({ + ipAddress: yup.string().max(50).required().label("IP Address") + }) + ) + .min(1) + .required() + .label("Access Token Trusted IP") + }) + .required(); + +export type FormData = yup.InferType; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + state?: boolean + ) => void; + identityAuthMethodData: { + identityId: string; + name: string; + authMethod?: IdentityAuthMethod; + }; +}; + +export const IdentityAwsAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityAwsAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityAwsAuth(); + + const { data } = useGetIdentityAwsAuth(identityAuthMethodData?.identityId ?? ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + stsEndpoint: "https://sts.amazonaws.com/", + allowedPrincipalArns: "", + allowedAccountIds: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + stsEndpoint: data.stsEndpoint, + allowedPrincipalArns: data.allowedPrincipalArns, + allowedAccountIds: data.allowedAccountIds, + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } else { + reset({ + stsEndpoint: "https://sts.amazonaws.com/", + allowedPrincipalArns: "", + allowedAccountIds: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + allowedPrincipalArns, + allowedAccountIds, + stsEndpoint, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityAuthMethodData) return; + + if (data) { + await updateMutateAsync({ + organizationId: orgId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + identityId: identityAuthMethodData.identityId, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + stsEndpoint: stsEndpoint || "", + allowedPrincipalArns: allowedPrincipalArns || "", + allowedAccountIds: allowedAccountIds || "", + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${ + identityAuthMethodData?.authMethod ? "updated" : "configured" + } auth method`, + type: "success" + }); + + reset(); + } catch (err) { + createNotification({ + text: `Failed to ${identityAuthMethodData?.authMethod ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {accessTokenTrustedIpsFields.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeAccessTokenTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+
+ + +
+ + ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm.tsx new file mode 100644 index 000000000..8a99c633b --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm.tsx @@ -0,0 +1,350 @@ +import { useEffect } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, IconButton, Input } from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { + useAddIdentityAzureAuth, + useGetIdentityAzureAuth, + useUpdateIdentityAzureAuth +} from "@app/hooks/api"; +import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z + .object({ + tenantId: z.string(), + resource: z.string(), + allowedServicePrincipalIds: z.string(), + accessTokenTTL: z.string(), + accessTokenMaxTTL: z.string(), + accessTokenNumUsesLimit: z.string(), + accessTokenTrustedIps: z + .array( + z.object({ + ipAddress: z.string().max(50) + }) + ) + .min(1) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + state?: boolean + ) => void; + identityAuthMethodData: { + identityId: string; + name: string; + authMethod?: IdentityAuthMethod; + }; +}; + +export const IdentityAzureAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityAzureAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityAzureAuth(); + + const { data } = useGetIdentityAzureAuth(identityAuthMethodData?.identityId ?? ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + tenantId: "", + resource: "https://management.azure.com/", + allowedServicePrincipalIds: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + tenantId: data.tenantId, + resource: data.resource, + allowedServicePrincipalIds: data.allowedServicePrincipalIds, + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } else { + reset({ + tenantId: "", + resource: "https://management.azure.com/", + allowedServicePrincipalIds: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityAuthMethodData) return; + + if (data) { + await updateMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + tenantId: tenantId || "", + resource: resource || "", + allowedServicePrincipalIds: allowedServicePrincipalIds || "", + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${ + identityAuthMethodData?.authMethod ? "updated" : "configured" + } auth method`, + type: "success" + }); + + reset(); + } catch (err) { + createNotification({ + text: `Failed to ${identityAuthMethodData?.authMethod ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {accessTokenTrustedIpsFields.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeAccessTokenTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+
+ + +
+ + ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm.tsx new file mode 100644 index 000000000..430115d35 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm.tsx @@ -0,0 +1,384 @@ +import { useEffect } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { + useAddIdentityGcpAuth, + useGetIdentityGcpAuth, + useUpdateIdentityGcpAuth +} from "@app/hooks/api"; +import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z + .object({ + type: z.enum(["iam", "gce"]), + allowedServiceAccounts: z.string(), + allowedProjects: z.string(), + allowedZones: z.string(), + accessTokenTTL: z.string(), + accessTokenMaxTTL: z.string(), + accessTokenNumUsesLimit: z.string(), + accessTokenTrustedIps: z + .array( + z.object({ + ipAddress: z.string().max(50) + }) + ) + .min(1) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + state?: boolean + ) => void; + identityAuthMethodData: { + identityId: string; + name: string; + authMethod?: IdentityAuthMethod; + }; +}; + +export const IdentityGcpAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityGcpAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityGcpAuth(); + + const { data } = useGetIdentityGcpAuth(identityAuthMethodData?.identityId ?? ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + watch + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + type: "gce", + allowedServiceAccounts: "", + allowedProjects: "", + allowedZones: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const watchedType = watch("type"); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + type: data.type, + allowedServiceAccounts: data.allowedServiceAccounts, + allowedProjects: data.allowedProjects, + allowedZones: data.allowedZones, + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } else { + reset({ + type: "iam", + allowedServiceAccounts: "", + allowedProjects: "", + allowedZones: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityAuthMethodData) return; + + if (data) { + await updateMutateAsync({ + identityId: identityAuthMethodData.identityId, + organizationId: orgId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + identityId: identityAuthMethodData.identityId, + organizationId: orgId, + type, + allowedServiceAccounts: allowedServiceAccounts || "", + allowedProjects: allowedProjects || "", + allowedZones: allowedZones || "", + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${ + identityAuthMethodData?.authMethod ? "updated" : "configured" + } auth method`, + type: "success" + }); + + reset(); + } catch (err) { + createNotification({ + text: `Failed to ${identityAuthMethodData?.authMethod ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + {watchedType === "gce" && ( + ( + + + + )} + /> + )} + {watchedType === "gce" && ( + ( + + + + )} + /> + )} + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {accessTokenTrustedIpsFields.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeAccessTokenTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+
+ + +
+ + ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx new file mode 100644 index 000000000..142b25dc3 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx @@ -0,0 +1,407 @@ +import { useEffect } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, IconButton, Input, TextArea } from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { + useAddIdentityKubernetesAuth, + useGetIdentityKubernetesAuth, + useUpdateIdentityKubernetesAuth +} from "@app/hooks/api"; +import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +// TODO: Add CA cert and token reviewer JWT fields + +const schema = z + .object({ + kubernetesHost: z.string(), + tokenReviewerJwt: z.string(), + allowedNames: z.string(), + allowedNamespaces: z.string(), + allowedAudience: z.string(), + caCert: z.string(), + accessTokenTTL: z.string(), + accessTokenMaxTTL: z.string(), + accessTokenNumUsesLimit: z.string(), + accessTokenTrustedIps: z + .array( + z.object({ + ipAddress: z.string().max(50) + }) + ) + .min(1) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + state?: boolean + ) => void; + identityAuthMethodData: { + identityId: string; + name: string; + authMethod?: IdentityAuthMethod; + }; +}; + +export const IdentityKubernetesAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityKubernetesAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityKubernetesAuth(); + + const { data } = useGetIdentityKubernetesAuth(identityAuthMethodData?.identityId ?? ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + kubernetesHost: "", // TODO + tokenReviewerJwt: "", + allowedNames: "", // TODO + allowedNamespaces: "", // TODO + allowedAudience: "", // TODO + caCert: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + kubernetesHost: data.kubernetesHost, + tokenReviewerJwt: data.tokenReviewerJwt, + allowedNames: data.allowedNames, + allowedNamespaces: data.allowedNamespaces, + allowedAudience: data.allowedAudience, + caCert: data.caCert, + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } else { + reset({ + kubernetesHost: "", // TODO + tokenReviewerJwt: "", + allowedNames: "", + allowedNamespaces: "", + allowedAudience: "", + caCert: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + kubernetesHost, + tokenReviewerJwt, + allowedNames, + allowedNamespaces, + allowedAudience, + caCert, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityAuthMethodData) return; + + if (data) { + await updateMutateAsync({ + organizationId: orgId, + kubernetesHost, + tokenReviewerJwt, + allowedNames, + allowedNamespaces, + allowedAudience, + caCert, + identityId: identityAuthMethodData.identityId, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + organizationId: orgId, + identityId: identityAuthMethodData.identityId, + kubernetesHost: kubernetesHost || "", + tokenReviewerJwt, + allowedNames: allowedNames || "", + allowedNamespaces: allowedNamespaces || "", + allowedAudience: allowedAudience || "", + caCert: caCert || "", + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${ + identityAuthMethodData?.authMethod ? "updated" : "configured" + } auth method`, + type: "success" + }); + + reset(); + } catch (err) { + createNotification({ + text: `Failed to ${identityAuthMethodData?.authMethod ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + +