diff --git a/.github/workflows/build-staging-and-deploy-aws.yml b/.github/workflows/build-staging-and-deploy-aws.yml index 78a193d30..4341d19f5 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: @@ -135,6 +135,6 @@ jobs: 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/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/backend/package-lock.json b/backend/package-lock.json index b51573688..6c674474b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -49,7 +49,7 @@ "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", - "mysql2": "^3.9.4", + "mysql2": "^3.9.7", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", "ora": "^7.0.1", @@ -1207,6 +1207,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 +1366,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 +1488,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 +1609,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", @@ -3657,60 +3813,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 +3935,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" @@ -9954,9 +10102,9 @@ } }, "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.7", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz", + "integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==", "dependencies": { "denque": "^2.1.0", "generate-function": "^2.3.1", diff --git a/backend/package.json b/backend/package.json index 31b9fdb14..113fa4d5b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -110,7 +110,7 @@ "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", - "mysql2": "^3.9.4", + "mysql2": "^3.9.7", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", "ora": "^7.0.1", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c4c825039..064927dfe 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -1,6 +1,8 @@ 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"; @@ -30,6 +32,7 @@ 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 { 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"; @@ -115,6 +118,9 @@ declare module "fastify" { identityProject: TIdentityProjectServiceFactory; identityUa: TIdentityUaServiceFactory; identityKubernetesAuth: TIdentityKubernetesAuthServiceFactory; + identityAwsAuth: TIdentityAwsAuthServiceFactory; + accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; + accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; secretApprovalRequest: TSecretApprovalRequestServiceFactory; secretRotation: TSecretRotationServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 0e75c2367..f86d22df5 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -2,6 +2,18 @@ import { Knex } from "knex"; import { TableName, + TAccessApprovalPolicies, + TAccessApprovalPoliciesApprovers, + TAccessApprovalPoliciesApproversInsert, + TAccessApprovalPoliciesApproversUpdate, + TAccessApprovalPoliciesInsert, + TAccessApprovalPoliciesUpdate, + TAccessApprovalRequests, + TAccessApprovalRequestsInsert, + TAccessApprovalRequestsReviewers, + TAccessApprovalRequestsReviewersInsert, + TAccessApprovalRequestsReviewersUpdate, + TAccessApprovalRequestsUpdate, TApiKeys, TApiKeysInsert, TApiKeysUpdate, @@ -47,6 +59,9 @@ import { TIdentityAccessTokens, TIdentityAccessTokensInsert, TIdentityAccessTokensUpdate, + TIdentityAwsAuths, + TIdentityAwsAuthsInsert, + TIdentityAwsAuthsUpdate, TIdentityKubernetesAuths, TIdentityKubernetesAuthsInsert, TIdentityKubernetesAuthsUpdate, @@ -322,6 +337,11 @@ declare module "knex/types/tables" { TIdentityKubernetesAuthsInsert, TIdentityKubernetesAuthsUpdate >; + [TableName.IdentityAwsAuth]: Knex.CompositeTableType< + TIdentityAwsAuths, + TIdentityAwsAuthsInsert, + TIdentityAwsAuthsUpdate + >; [TableName.IdentityUaClientSecret]: Knex.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, @@ -352,6 +372,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, 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/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/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/index.ts b/backend/src/db/schemas/index.ts index 28a571db7..145b24a4b 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -1,3 +1,7 @@ +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"; @@ -13,6 +17,7 @@ export * from "./group-project-memberships"; export * from "./groups"; export * from "./identities"; export * from "./identity-access-tokens"; +export * from "./identity-aws-auths"; export * from "./identity-kubernetes-auths"; export * from "./identity-org-memberships"; export * from "./identity-project-additional-privilege"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 295b49e1d..f1d528c2f 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -46,11 +46,16 @@ export enum TableName { IdentityUniversalAuth = "identity_universal_auths", IdentityKubernetesAuth = "identity_kubernetes_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", @@ -140,5 +145,6 @@ export enum ProjectUpgradeStatus { export enum IdentityAuthMethod { Univeral = "universal-auth", - Kubernetes_Auth = "kubernetes-auth" + Kubernetes_Auth = "kubernetes-auth", + AWS_AUTH = "aws-auth" } 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..d5a4d5b49 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -21,7 +21,8 @@ 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() }); 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/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts index a1a2e36fa..0fecc9d2e 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,14 @@ -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 { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { PermissionSchema, SanitizedIdentityPrivilegeSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { @@ -41,11 +39,11 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + permissions: PermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, @@ -92,7 +90,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), + permissions: PermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), temporaryMode: z .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), @@ -107,7 +105,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, @@ -157,7 +155,7 @@ 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: PermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), temporaryMode: z .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) @@ -175,7 +173,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, @@ -219,7 +217,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, @@ -260,7 +258,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), response: { 200: z.object({ - privilege: IdentityProjectAdditionalPrivilegeSchema + privilege: SanitizedIdentityPrivilegeSchema }) } }, @@ -293,16 +291,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 +308,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 cf325b2e3..16e23eb88 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,3 +1,5 @@ +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"; @@ -41,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); 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/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/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/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 95026e889..3b49e0f85 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -70,6 +70,10 @@ export enum EventType { CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", + 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", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", @@ -454,6 +458,50 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } +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 CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -712,6 +760,10 @@ export type Event = | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent | RevokeIdentityUniversalAuthClientSecretEvent + | LoginIdentityAwsAuthEvent + | AddIdentityAwsAuthEvent + | UpdateIdentityAwsAuthEvent + | GetIdentityAwsAuthEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent 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/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 a2379ddaa..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" } 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/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index efcb03bd3..483f8e54c 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -92,6 +92,18 @@ export const UNIVERSAL_AUTH = { } } 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; + export const ORGANIZATIONS = { LIST_USER_MEMBERSHIPS: { organizationId: "The ID of the organization to get memberships from." @@ -240,6 +252,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: { @@ -276,7 +289,8 @@ export const RAW_SECRETS = { 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." @@ -295,6 +309,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.", @@ -465,12 +480,21 @@ 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"}]] +- 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.", isTemporary: "Whether the privilege is temporary.", @@ -484,11 +508,19 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { 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"}]] +- 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.", isTemporary: "Whether the privilege is temporary.", temporaryMode: "Type of temporary access given. Types: relative", temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", @@ -596,7 +628,8 @@ export const 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: { 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/routes/index.ts b/backend/src/server/routes/index.ts index 53015cf09..c2eb23117 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2,6 +2,12 @@ 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"; @@ -72,6 +78,8 @@ import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { identityServiceFactory } from "@app/services/identity/identity-service"; import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; +import { identityAwsAuthDALFactory } from "@app/services/identity-aws-auth/identity-aws-auth-dal"; +import { identityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-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"; @@ -90,6 +98,7 @@ 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"; @@ -157,6 +166,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); @@ -196,6 +206,7 @@ export const registerRoutes = async ( const identityUaDAL = identityUaDALFactory(db); const identityKubernetesAuthDAL = identityKubernetesAuthDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); + const identityAwsAuthDAL = identityAwsAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(db); const auditLogStreamDAL = auditLogStreamDALFactory(db); @@ -208,6 +219,12 @@ 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); @@ -265,13 +282,19 @@ export const registerRoutes = async ( 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, @@ -300,7 +323,9 @@ export const registerRoutes = async ( licenseService, scimDAL, userDAL, + userAliasDAL, orgDAL, + orgMembershipDAL, projectDAL, projectMembershipDAL, groupDAL, @@ -316,6 +341,7 @@ export const registerRoutes = async ( ldapConfigDAL, ldapGroupMapDAL, orgDAL, + orgMembershipDAL, orgBotDAL, groupDAL, groupProjectDAL, @@ -339,8 +365,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, @@ -349,6 +380,7 @@ export const registerRoutes = async ( userDAL }); const orgService = orgServiceFactory({ + userAliasDAL, licenseService, samlConfigDAL, orgRoleDAL, @@ -509,8 +541,10 @@ export const registerRoutes = async ( folderDAL, folderVersionDAL, projectEnvDAL, - snapshotService + snapshotService, + projectDAL }); + const integrationAuthService = integrationAuthServiceFactory({ integrationAuthDAL, integrationDAL, @@ -583,6 +617,30 @@ export const registerRoutes = async ( 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 secretRotationQueue = secretRotationQueueFactory({ telemetryService, secretRotationDAL, @@ -658,6 +716,14 @@ export const registerRoutes = async ( permissionService, licenseService }); + const identityAwsAuthService = identityAwsAuthServiceFactory({ + identityAccessTokenDAL, + identityAwsAuthDAL, + identityOrgMembershipDAL, + identityDAL, + licenseService, + permissionService + }); const dynamicSecretProviders = buildDynamicSecretProviders(); const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ @@ -728,7 +794,10 @@ export const registerRoutes = async ( identityProject: identityProjectService, identityUa: identityUaService, identityKubernetesAuth: identityKubernetesAuthService, + identityAwsAuth: identityAwsAuthService, secretApprovalPolicy: sapService, + accessApprovalPolicy: accessApprovalPolicyService, + accessApprovalRequest: accessApprovalRequestService, secretApprovalRequest: sarService, secretRotation: secretRotationService, dynamicSecret: dynamicSecretService, diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index a0b792789..14155ecf9 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { DynamicSecretsSchema, + IdentityProjectAdditionalPrivilegeSchema, IntegrationAuthsSchema, SecretApprovalPoliciesSchema, UsersSchema } from "@app/db/schemas"; +import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; // sometimes the return data must be santizied to avoid leaking important values // always prefer pick over omit in zod @@ -62,6 +64,35 @@ export const secretRawSchema = z.object({ secretComment: z.string().optional() }); +export const PermissionSchema = z.object({ + action: z + .string() + .min(1) + .describe("Describe what action an entity can take. Possible actions: create, edit, delete, and read"), + subject: z + .string() + .min(1) + .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 SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.extend({ + permissions: UnpackedPermissionSchema.array() +}); + export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ inputIV: true, inputTag: true, diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index e70822128..4882411d8 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -42,7 +42,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-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/index.ts b/backend/src/server/routes/v1/index.ts index c00e2fbb6..795be96dc 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -2,6 +2,7 @@ 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 { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityUaRouter } from "./identity-ua"; @@ -30,6 +31,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityUaRouter); await authRouter.register(registerIdentityKubernetesRouter); await authRouter.register(registerIdentityAccessTokenRouter); + await authRouter.register(registerIdentityAwsAuthRouter); }, { prefix: "/auth" } ); diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index f908aa1fc..975e6e7c8 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -66,7 +66,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({}) }), 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/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 cae51f858..e2d8ddb12 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -293,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), @@ -311,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); @@ -322,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, @@ -331,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, @@ -339,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, @@ -358,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"]), diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 59f336e5a..5d68a4e94 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -27,10 +27,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 }; } diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 74787f4ac..630e36310 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -1,5 +1,6 @@ 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" diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 5d81eaae1..4d2a302c6 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -361,6 +361,7 @@ export const authLoginServiceFactory = ({ user = await userDAL.create({ username: email, email, + isEmailVerified: true, firstName, lastName, authMethods: [authMethod], @@ -374,6 +375,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-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 9475a3f56..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,7 +80,7 @@ export const authSignupServiceFactory = ({ }); await smtpService.sendMail({ - template: SmtpTemplates.EmailVerification, + template: SmtpTemplates.SignupEmailVerification, subjectLine: "Infisical confirmation code", recipients: [user.email as string], substitutions: { @@ -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-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-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-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 168159847..4e1dbd7ae 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -517,20 +517,22 @@ const syncSecretsAWSParameterStore = async ({ }) ); - // 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 (!metadata.shouldDisableDelete) { + // 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(); + } + }) + ); + } }; /** diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 56ea46350..1913dd31f 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -27,6 +27,7 @@ export type TCreateIntegrationDTO = { value: string; }[]; kmsKeyId?: string; + shouldDisableDelete?: boolean; }; } & Omit; 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-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 6d148d03b..e12114fef 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -110,7 +110,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 +119,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 +134,7 @@ export const projectMembershipServiceFactory = ({ const projectMemberships = await projectMembershipDAL.insertMany( orgMembers.map(({ userId }) => ({ projectId, - userId: userId as string + userId })), tx ); @@ -145,12 +145,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 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/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index c925d2587..da429d88a 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, @@ -151,6 +259,21 @@ export const secretFolderServiceFactory = ({ .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); @@ -239,6 +362,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..1405f8bd7 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -13,6 +13,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/secret-service.ts b/backend/src/services/secret/secret-service.ts index 3f647d8a9..feb8c01d5 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -972,7 +972,8 @@ export const secretServiceFactory = ({ path, actor, environment, - projectId, + projectId: workspaceId, + projectSlug, actorId, actorOrgId, actorAuthMethod, @@ -980,6 +981,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" }); diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index df0af5b5d..9f2addc89 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -152,7 +152,9 @@ export type TGetASecretRawDTO = { type: "shared" | "personal"; includeImports?: boolean; version?: number; -} & TProjectPermission; + projectSlug?: string; + projectId?: string; +} & Omit; export type TCreateSecretRawDTO = TProjectPermission & { secretPath: string; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 7ebeaa227..81680537d 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -17,9 +17,11 @@ export type TSmtpSendMail = { export type TSmtpService = ReturnType; export enum SmtpTemplates { + SignupEmailVerification = "signupEmailVerification.handlebars", EmailVerification = "emailVerification.handlebars", SecretReminder = "secretReminder.handlebars", EmailMfa = "emailMfa.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/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..089f3b8c6 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); @@ -72,6 +208,8 @@ export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { }; return { + sendEmailVerificationCode, + verifyEmailVerificationCode, toggleUserMfa, updateUserName, updateAuthMethods, 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. >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 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 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. + +In AWS Auth, an 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. +The query data including the request method, request body, and request headers are sent to Infisical afterwhich Infisical forwards the signed query to AWS STS API via the [sts:GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) method to verify and obtain the identity of the IAM principal. +Once obtained, the identity information is verified against specified requirements such as if the associated IAM principal ARN is allowed to authenticate with Infisical. If all is well, Infisical returns a short-lived access token that can be used 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. + + + +## Workflow + +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 is useful for AWS GovCloud or other AWS regions that have different STS endpoints. + - 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); + ```` + + #### 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=...' + ``` + + #### 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/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index daa5db7ed..e70458c9c 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -7,7 +7,7 @@ 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 using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth) or [AWS Auth](/documentation/platform/identities/aws-auth) to get back a short-lived access token to be used in subsequent requests. ![organization identities](/images/platform/organization/organization-machine-identities.png) @@ -21,7 +21,7 @@ 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 such as [Universal Auth](/documentation/platform/identities/universal-auth) or [AWS Auth](/documentation/platform/identities/aws-auth). 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. @@ -37,7 +37,8 @@ Machine Identity support for the rest of the clients is planned to be released i 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. +- [AWS Auth](/documentation/platform/identities/aws-auth): An AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to authenticate with Infisical. ## FAQ diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index a9f4dffae..bad96c0e3 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -3,19 +3,35 @@ 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 an identity 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. + +In Universal Auth, an identity is given a **Client ID** and one or more **Client Secret(s)**. Together, a **Client ID** and **Client Secret** can be exchanged for a short-lived access token to authenticate with 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 +43,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 +94,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 +105,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 +124,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 +132,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. + @@ -134,7 +152,8 @@ using the Universal Auth authentication method. 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/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/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/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/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/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index d1cff4e75..3d1b72331 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -77,6 +77,8 @@ spec: 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 @@ -89,6 +91,7 @@ spec: 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 diff --git a/docs/mint.json b/docs/mint.json index 0bb1ae9bf..d499f3cac 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -153,6 +153,7 @@ "documentation/platform/auth-methods/email-password", "documentation/platform/token", "documentation/platform/identities/universal-auth", + "documentation/platform/identities/aws-auth", "documentation/platform/mfa", { "group": "SSO", @@ -211,9 +212,7 @@ }, { "group": "Reference architectures", - "pages": [ - "self-hosting/reference-architectures/aws-ecs" - ] + "pages": ["self-hosting/reference-architectures/aws-ecs"] }, "self-hosting/ee", "self-hosting/faq" diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 4c1456d3b..5233ae910 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,51 +270,51 @@ 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. @@ -335,33 +355,39 @@ 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. + 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/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/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/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..12a9094e0 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -41,18 +41,22 @@ 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 && ( - - - - )} + + + + { + 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..f5aaabc83 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -3,6 +3,8 @@ export type TServerConfig = { allowSignUp: boolean; allowedSignUpDomain?: string | null; isMigrationModeOn?: boolean; + trustSamlEmails: boolean; + trustLdapEmails: boolean; }; export type TCreateAdminUserDTO = { 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..20209df71 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -164,7 +164,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/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 53fceb7d5..64f9469c6 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -1,5 +1,6 @@ import { IdentityAuthMethod } from "./enums"; export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { - [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth" + [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth", + [IdentityAuthMethod.AWS_AUTH]: "AWS Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index 524c3a20c..dc619f722 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -1,3 +1,4 @@ export enum IdentityAuthMethod { - UNIVERSAL_AUTH = "universal-auth" + UNIVERSAL_AUTH = "universal-auth", + AWS_AUTH = "aws-auth" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index 684a00b6f..cef827d9f 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -1,12 +1,18 @@ export { identityAuthToNameMap } from "./constants"; export { IdentityAuthMethod } from "./enums"; export { + useAddIdentityAwsAuth, useAddIdentityUniversalAuth, useCreateIdentity, useCreateIdentityUniversalAuthClientSecret, useDeleteIdentity, useRevokeIdentityUniversalAuthClientSecret, useUpdateIdentity, + useUpdateIdentityAwsAuth, useUpdateIdentityUniversalAuth } from "./mutations"; -export { useGetIdentityUniversalAuth, useGetIdentityUniversalAuthClientSecrets } from "./queries"; +export { + useGetIdentityAwsAuth, + useGetIdentityUniversalAuth, + useGetIdentityUniversalAuthClientSecrets +} from "./queries"; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index ec418659d..8780f178d 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; import { identitiesKeys } from "./queries"; import { + AddIdentityAwsAuthDTO, AddIdentityUniversalAuthDTO, ClientSecretData, CreateIdentityDTO, @@ -13,7 +14,9 @@ import { DeleteIdentityDTO, DeleteIdentityUniversalAuthClientSecretDTO, Identity, + IdentityAwsAuth, IdentityUniversalAuth, + UpdateIdentityAwsAuthDTO, UpdateIdentityDTO, UpdateIdentityUniversalAuthDTO } from "./types"; @@ -169,3 +172,74 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => { } }); }; + +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)); + } + }); +}; diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 92d2a432f..26880d5ea 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -2,27 +2,26 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { ClientSecretData, IdentityUniversalAuth } from "./types"; +import { ClientSecretData, IdentityAwsAuth, 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, + getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-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 +29,30 @@ 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 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; + } + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 4ac19c351..1a7ba263f 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,46 @@ export type UpdateIdentityUniversalAuthDTO = { }[]; }; +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 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/index.tsx b/frontend/src/hooks/api/index.tsx index 574da5a31..61e8cb666 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,3 +1,4 @@ +export * from "./accessApproval"; export * from "./admin"; export * from "./apiKeys"; export * from "./auditLogs"; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index b855dbf73..2d4c8a313 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -68,6 +68,7 @@ export const useCreateIntegration = () => { value: string; }[]; kmsKeyId?: string; + shouldDisableDelete?: boolean; }; }) => { const { diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index 5f205e585..97a90b421 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 = { 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..8fde9c63d 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -36,3 +36,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/types.ts b/frontend/src/hooks/api/types.ts index 49949d88e..516a5d7cf 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -1,5 +1,6 @@ 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"; @@ -49,13 +50,13 @@ export enum ApiErrorTypes { export type TApiErrors = | { - error: ApiErrorTypes.ValidationError; - message: ZodIssue[]; - statusCode: 403; - } + error: ApiErrorTypes.ValidationError; + message: ZodIssue[]; + statusCode: 403; + } | { error: ApiErrorTypes.ForbiddenError; message: string; statusCode: 401 } | { - statusCode: 400; - message: string; - error: ApiErrorTypes.BadRequestError; - }; + statusCode: 400; + message: string; + error: ApiErrorTypes.BadRequestError; + }; 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/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 2550150f3..2fcdc9339 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} )} 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/pages/integrations/aws-parameter-store/create.tsx b/frontend/src/pages/integrations/aws-parameter-store/create.tsx index cc80d2f29..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(""); @@ -144,7 +145,8 @@ export default function AWSParameterStoreCreateIntegrationPage() { ] } : {}), - ...(kmsKeyId && { kmsKeyId }) + ...(kmsKeyId && { kmsKeyId }), + ...(shouldDisableDelete && { shouldDisableDelete }) } }); @@ -273,6 +275,15 @@ export default function AWSParameterStoreCreateIntegrationPage() { exit={{ opacity: 0, translateX: 30 }} >
+ setShouldDisableDelete(!shouldDisableDelete)} + isChecked={shouldDisableDelete} + > + Disable deleting secrets in AWS Parameter Store + +
+
setShouldTag(!shouldTag)} 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..de866a323 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); @@ -153,7 +151,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/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/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/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) => {
+
+
+ + +
+ + ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx index 4ab44e947..e8b16cb5d 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -15,7 +15,10 @@ import { } from "@app/components/v2"; import { useOrganization } from "@app/context"; import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api"; -import { IdentityAuthMethod, useAddIdentityUniversalAuth } from "@app/hooks/api/identities"; +import { + IdentityAuthMethod + // useAddIdentityUniversalAuth +} from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = yup @@ -40,9 +43,7 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void; }; -export const IdentityModal = ({ popUp, /* handlePopUpOpen, */ handlePopUpToggle }: Props) => { - - +export const IdentityModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => { const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; @@ -50,7 +51,7 @@ export const IdentityModal = ({ popUp, /* handlePopUpOpen, */ handlePopUpToggle const { mutateAsync: createMutateAsync } = useCreateIdentity(); const { mutateAsync: updateMutateAsync } = useUpdateIdentity(); - const { mutateAsync: addMutateAsync } = useAddIdentityUniversalAuth(); + // const { mutateAsync: addMutateAsync } = useAddIdentityUniversalAuth(); const { control, @@ -113,31 +114,31 @@ export const IdentityModal = ({ popUp, /* handlePopUpOpen, */ handlePopUpToggle // create const { - id: createdId - // name: createdName, - // authMethod + id: createdId, + name: createdName, + authMethod } = await createMutateAsync({ name, role: role || undefined, organizationId: orgId }); - await addMutateAsync({ - organizationId: orgId, - identityId: createdId, - clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], - accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], - accessTokenTTL: 2592000, - accessTokenMaxTTL: 2592000, - accessTokenNumUsesLimit: 0 - }); + // await addMutateAsync({ + // organizationId: orgId, + // identityId: createdId, + // clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + // accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + // accessTokenTTL: 2592000, + // accessTokenMaxTTL: 2592000, + // accessTokenNumUsesLimit: 0 + // }); handlePopUpToggle("identity", false); - // handlePopUpOpen("identityAuthMethod", { - // identityId: createdId, - // name: createdName, - // authMethod - // }); + handlePopUpOpen("identityAuthMethod", { + identityId: createdId, + name: createdName, + authMethod + }); } createNotification({ diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index 804c2d054..0ed9cd9a4 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -23,8 +23,6 @@ import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from import { IdentityAuthMethod, identityAuthToNameMap } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; -// TODO: some kind of map - type Props = { handlePopUpOpen: ( popUpName: keyof UsePopUpState< @@ -44,7 +42,6 @@ type Props = { }; export const IdentityTable = ({ handlePopUpOpen }: Props) => { - const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx index d8b4ac042..af82082a5 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx @@ -63,7 +63,6 @@ export const IdentityUniversalAuthForm = ({ handlePopUpToggle, identityAuthMethodData }: Props) => { - const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); @@ -384,7 +383,7 @@ export const IdentityUniversalAuthForm = ({ variant="plain" onClick={() => handlePopUpToggle("identityAuthMethod", false)} > - Cancel + {identityAuthMethodData?.authMethod ? "Cancel" : "Skip"}
diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index 02afdefbf..a14e09a67 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -49,7 +49,6 @@ type Props = { }; export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Props) => { - const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); const { user } = useUser(); @@ -218,7 +217,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop variant="outline_bg" onClick={() => onResendInvite(email)} > - Resend Invite + Resend invite )} diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx index 56248145b..6c1d4654d 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx @@ -142,7 +142,7 @@ const SpecificPrivilegeSecretForm = ({ .filter(({ allowed }) => allowed) .map(({ action }) => ({ action, - subject: [ProjectPermissionSub.Secrets], + subject: ProjectPermissionSub.Secrets, conditions })) }, @@ -477,7 +477,7 @@ export const SpecificPrivilegeSection = ({ identityId }: Props) => { permissions: [ { action: ProjectPermissionActions.Read, - subject: [ProjectPermissionSub.Secrets], + subject: ProjectPermissionSub.Secrets, conditions: { environment: currentWorkspace?.environments?.[0].slug } @@ -512,6 +512,7 @@ export const SpecificPrivilegeSection = ({ identityId }: Props) => { ?.filter(({ permissions }) => permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets) ) + .sort((a, b) => a.id.localeCompare(b.id)) ?.map((privilege) => ( ; -const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => { +export const SpecificPrivilegeSecretForm = ({ + privilege, + policies, + onClose +}: { + privilege?: TProjectUserPrivilege; + policies?: TAccessApprovalPolicy[]; + onClose?: () => void; +}) => { const { currentWorkspace } = useWorkspace(); + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ - "deletePrivilege" + "deletePrivilege", + "requestAccess" ] as const); const { permission } = useProjectPermission(); - const isMemberEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, - ProjectPermissionSub.Member - ); + const isMemberEditDisabled = + permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege; const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege(); const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege(); + const requestAccess = useCreateAccessRequest(); const privilegeForm = useForm({ resolver: zodResolver(secretPermissionSchema), values: { - environmentSlug: privilege.permissions?.[0]?.conditions?.environment, - // secret path will be inside $glob operator - secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", - read: privilege.permissions?.some(({ action }) => - action.includes(ProjectPermissionActions.Read) - ), - edit: privilege.permissions?.some(({ action }) => - action.includes(ProjectPermissionActions.Edit) - ), - create: privilege.permissions?.some(({ action }) => - action.includes(ProjectPermissionActions.Create) - ), - delete: privilege.permissions?.some(({ action }) => - action.includes(ProjectPermissionActions.Delete) - ), - // zod will pick it - temporaryAccess: privilege + ...(privilege + ? { + environmentSlug: privilege.permissions?.[0]?.conditions?.environment, + // secret path will be inside $glob operator + secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", + read: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Read) + ), + edit: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Edit) + ), + create: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Create) + ), + delete: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Delete) + ), + // zod will pick it + temporaryAccess: privilege + } + : { + environmentSlug: currentWorkspace?.environments?.[0].slug!, + read: false, + edit: false, + create: false, + delete: false, + temporaryAccess: { + isTemporary: false + } + }) } }); const temporaryAccessField = privilegeForm.watch("temporaryAccess"); - const selectedEnvironmentSlug = privilegeForm.watch("environmentSlug"); + const selectedEnvironment = privilegeForm.watch("environmentSlug"); + const secretPath = privilegeForm.watch("secretPath"); + + const readAccess = privilegeForm.watch("read"); + const createAccess = privilegeForm.watch("create"); + const editAccess = privilegeForm.watch("edit"); + const deleteAccess = privilegeForm.watch("delete"); + + const accessSelected = readAccess || createAccess || editAccess || deleteAccess; + + const selectablePaths = useMemo(() => { + if (!policies) return []; + const environmentPolicies = policies.filter( + (policy) => policy.environment.slug === selectedEnvironment + ); + + privilegeForm.setValue("secretPath", "", { + shouldValidate: true + }); + + return [...environmentPolicies.map((policy) => policy.secretPath)]; + }, [policies, selectedEnvironment]); + const isTemporary = temporaryAccessField?.isTemporary; const isExpired = temporaryAccessField.isTemporary && new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || ""); const handleUpdatePrivilege = async (data: TSecretPermissionForm) => { + if (!privilege) { + createNotification({ + type: "error", + text: "No privilege to update found.", + title: "Error" + }); + + return; + } + if (updateUserPrivilege.isLoading) return; try { const actions = [ @@ -152,6 +210,15 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri }; const handleDeletePrivilege = async () => { + if (!privilege) { + createNotification({ + type: "error", + text: "No privilege to delete found.", + title: "Error" + }); + return; + } + if (deleteUserPrivilege.isLoading) return; try { await deleteUserPrivilege.mutateAsync({ @@ -170,35 +237,100 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri } }; + // This is used for requesting access additional privileges, not directly creating a privilege! + const handleRequestAccess = async (data: TSecretPermissionForm) => { + if (!policies) return; + if (!currentWorkspace) { + createNotification({ + type: "error", + text: "No workspace found.", + title: "Error" + }); + return; + } + + if (!data.secretPath) { + createNotification({ + type: "error", + text: "Please select a secret path", + title: "Error" + }); + return; + } + + const actions = [ + { action: ProjectPermissionActions.Read, allowed: data.read }, + { action: ProjectPermissionActions.Create, allowed: data.create }, + { action: ProjectPermissionActions.Delete, allowed: data.delete }, + { action: ProjectPermissionActions.Edit, allowed: data.edit } + ]; + const conditions: Record = { environment: data.environmentSlug }; + if (data.secretPath) { + conditions.secretPath = { $glob: data.secretPath }; + } + await requestAccess.mutateAsync({ + ...data, + ...(data.temporaryAccess.isTemporary && { + temporaryRange: data.temporaryAccess.temporaryRange + }), + projectSlug: currentWorkspace.slug, + isTemporary: data.temporaryAccess.isTemporary, + permissions: actions + .filter(({ allowed }) => allowed) + .map(({ action }) => ({ + action, + subject: [ProjectPermissionSub.Secrets], + conditions + })) + }); + + createNotification({ + type: "success", + text: "Successfully requested access" + }); + privilegeForm.reset(); + if (onClose) onClose(); + }; + + const handleSubmit = async (data: TSecretPermissionForm) => { + if (privilege) { + handleUpdatePrivilege(data); + } else { + handleRequestAccess(data); + } + }; + const getAccessLabel = (exactTime = false) => { if (isExpired) return "Access expired"; if (!temporaryAccessField?.isTemporary) return "Permanent"; - if (exactTime) + + if (exactTime && !policies) { return `Until ${format( new Date(temporaryAccessField.temporaryAccessEndTime || ""), "yyyy-MM-dd HH:mm:ss" )}`; + } return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); }; return ( -
-
-
+
+ +
( - + @@ -208,16 +340,43 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri ( - - - - )} + render={({ field }) => { + if (policies) { + return ( + +
+ + + +
+
+ ); + } + return ( + + + + ); + }} />
-
+
@@ -301,7 +460,7 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri isExpired && "text-red-600" )} > - {getAccessLabel()} + {getAccessLabel(false)}
@@ -362,8 +521,9 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri ); }} > - {temporaryAccessField.isTemporary ? "Restart" : "Grant"} + {temporaryAccessField.isTemporary && !policies ? "Restart" : "Grant"} + {temporaryAccessField.isTemporary && ( )}
- {privilegeForm.formState.isDirty ? ( + {/* eslint-disable-next-line no-nested-ternary */} + {privilegeForm.formState.isDirty && privilege ? ( <> - ) : ( + ) : // eslint-disable-next-line no-nested-ternary + privilege ? ( + ) : ( +
)}
+ {!!policies && ( + + )} { ?.filter(({ permissions }) => permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets) ) + .sort((a, b) => a.id.localeCompare(b.id)) ?.map((privilege) => ( { const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; + const projectId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; return (
-

Secret Approval Workflows

+

Approval Workflows

Create approval policies for any modifications to secrets in sensitive environments and folders. @@ -39,16 +45,25 @@ export const SecretApprovalPage = () => {

- + - Secret PRs - Policies + Secret Requests + Secret Policies + + Access Requests + Access Request Policies - + + + + - - + + + + +
diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx new file mode 100644 index 000000000..aa47cce80 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx @@ -0,0 +1,174 @@ +import { faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, + UpgradePlanModal +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteAccessApprovalPolicy, useGetWorkspaceUsers } from "@app/hooks/api"; +import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries"; +import { TAccessApprovalPolicy } from "@app/hooks/api/types"; + +import { AccessApprovalPolicyRow } from "./components/AccessApprovalPolicyRow"; +import { AccessPolicyForm } from "./components/AccessPolicyModal"; + +interface IProps { + workspaceId: string; +} + +export const AccessApprovalPolicyList = ({ workspaceId }: IProps) => { + const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([ + "secretPolicyForm", + "deletePolicy", + "upgradePlan" + ] as const); + const { permission } = useProjectPermission(); + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + + const { data: members } = useGetWorkspaceUsers(workspaceId); + const { data: policies, isLoading: isPoliciesLoading } = useGetAccessApprovalPolicies({ + projectSlug: currentWorkspace?.slug as string, + options: { + enabled: + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && + !!currentWorkspace?.slug + } + }); + + const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteAccessApprovalPolicy(); + + const handleDeletePolicy = async () => { + const { id } = popUp.deletePolicy.data as TAccessApprovalPolicy; + if (!currentWorkspace?.slug) return; + + try { + await deleteSecretApprovalPolicy({ + projectSlug: currentWorkspace?.slug, + id + }); + createNotification({ + type: "success", + text: "Successfully deleted policy" + }); + handlePopUpClose("deletePolicy"); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to delete policy" + }); + } + }; + + return ( +
+
+
+ Access Request Policies +
+ Implement secret request policies for specific secrets and environments. +
+
+
+ + {(isAllowed) => ( + + )} + +
+
+ + + + + + + + + + + + + {isPoliciesLoading && ( + + )} + {!isPoliciesLoading && !policies?.length && ( + + + + )} + {!!currentWorkspace && + policies?.map((policy) => ( + handlePopUpOpen("secretPolicyForm", policy)} + onDelete={() => handlePopUpOpen("deletePolicy", policy)} + /> + ))} + +
NameEnvironmentSecret PathEligible ApproversApproval Required +
+ +
+
+ handlePopUpToggle("secretPolicyForm", isOpen)} + members={members} + editValues={popUp.secretPolicyForm.data as TAccessApprovalPolicy} + /> + handlePopUpToggle("deletePolicy", isOpen)} + onDeleteApproved={handleDeletePolicy} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add secret approval policy if you switch to Infisical's Enterprise plan." + /> +
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx new file mode 100644 index 000000000..8476bac8d --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; +import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + IconButton, + Input, + Td, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { useUpdateAccessApprovalPolicy } from "@app/hooks/api"; +import { TAccessApprovalPolicy } from "@app/hooks/api/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + policy: TAccessApprovalPolicy; + members?: TWorkspaceUser[]; + projectSlug: string; + onEdit: () => void; + onDelete: () => void; +}; + +export const AccessApprovalPolicyRow = ({ + policy, + members = [], + projectSlug, + onEdit, + onDelete +}: Props) => { + const [selectedApprovers, setSelectedApprovers] = useState([]); + const { mutate: updateAccessApprovalPolicy, isLoading } = useUpdateAccessApprovalPolicy(); + const { permission } = useProjectPermission(); + + return ( + + {policy.name} + {policy.environment.slug} + {policy.secretPath || "*"} + + { + if (!isOpen) { + updateAccessApprovalPolicy( + { + projectSlug, + id: policy.id, + approvers: selectedApprovers + }, + { + onSettled: () => { + setSelectedApprovers([]); + } + } + ); + } else { + setSelectedApprovers(policy.approvers); + } + }} + > + + + + + + Select members that are allowed to approve changes + + {members?.map(({ id, user }) => { + const isChecked = selectedApprovers.includes(id); + return ( + { + evt.preventDefault(); + setSelectedApprovers((state) => + isChecked ? state.filter((el) => el !== id) : [...state, id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {user.username} + + ); + })} + + + + {policy.approvals} + +
+ + {(isAllowed) => ( + + + + )} + + + {(isAllowed) => ( + + + + )} + +
+ + + ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyModal.tsx new file mode 100644 index 000000000..6c0ee3fb6 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyModal.tsx @@ -0,0 +1,266 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheckCircle } 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, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useCreateAccessApprovalPolicy, + useUpdateAccessApprovalPolicy +} from "@app/hooks/api/accessApproval"; +import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + members?: TWorkspaceUser[]; + projectSlug: string; + editValues?: TAccessApprovalPolicy; +}; + +const formSchema = z + .object({ + environment: z.string(), + name: z.string().optional(), + secretPath: z.string().optional(), + approvals: z.number().min(1), + approvers: z.string().array().min(1) + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }); + +type TFormSchema = z.infer; + +export const AccessPolicyForm = ({ + isOpen, + onToggle, + members = [], + projectSlug, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined + }); + const { currentWorkspace } = useWorkspace(); + + const environments = currentWorkspace?.environments || []; + useEffect(() => { + if (!isOpen) reset({}); + }, [isOpen]); + + const isEditMode = Boolean(editValues); + + const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); + const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); + + const handleCreatePolicy = async (data: TFormSchema) => { + if (!projectSlug) return; + + try { + await createAccessApprovalPolicy({ + ...data, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully created policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create policy" + }); + } + }; + + const handleUpdatePolicy = async (data: TFormSchema) => { + if (!projectSlug) return; + if (!editValues?.id) return; + + try { + await updateAccessApprovalPolicy({ + id: editValues?.id, + ...data, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully updated policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "failed to update policy" + }); + } + }; + + const handleFormSubmit = async (data: TFormSchema) => { + if (isEditMode) { + await handleUpdatePolicy(data); + } else { + await handleCreatePolicy(data); + } + }; + + return ( + + +
+ ( + + + + )} + /> + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + + + + + Select members that are allowed to approve changes + + {members.map(({ id, user }) => { + const isChecked = value?.includes(id); + return ( + { + evt.preventDefault(); + onChange( + isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {user.username} + + ); + })} + + + + )} + /> + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx new file mode 100644 index 000000000..f6db07c94 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx @@ -0,0 +1 @@ +export { AccessApprovalPolicyList } from "./AccessApprovalPolicyList"; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx new file mode 100644 index 000000000..235737d4a --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -0,0 +1,436 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable react/jsx-no-useless-fragment */ +import { useMemo, useState } from "react"; +import { + faCheck, + faCheckCircle, + faChevronDown, + faLock, + faPlus +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { formatDistance } from "date-fns"; +import { AnimatePresence, motion } from "framer-motion"; + +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + Tooltip, + UpgradePlanModal +} from "@app/components/v2"; +import { Badge } from "@app/components/v2/Badge"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetWorkspaceUsers } from "@app/hooks/api"; +import { + accessApprovalKeys, + useGetAccessApprovalPolicies, + useGetAccessApprovalRequests, + useGetAccessRequestsCount +} from "@app/hooks/api/accessApproval/queries"; +import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; +import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types"; +import { queryClient } from "@app/reactQuery"; + +import { RequestAccessModal } from "./components/RequestAccessModal"; +import { ReviewAccessRequestModal } from "./components/ReviewAccessModal"; + +const generateRequestText = (request: TAccessApprovalRequest, membershipId: string) => { + const { isTemporary } = request; + + return ( +
+
+ Requested {isTemporary ? "temporary" : "permanent"} access to{" "} + + {request.policy.secretPath} + + in + + {request.environmentName} + +
+
+ {request.requestedBy === membershipId && ( + + Requested By You + + )} +
+
+ ); +}; + +export const AccessApprovalRequest = ({ + projectSlug, + projectId +}: { + projectSlug: string; + projectId: string; +}) => { + const [selectedRequest, setSelectedRequest] = useState< + (TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null }) | null + >(null); + + const { handlePopUpOpen, popUp, handlePopUpClose } = usePopUp([ + "requestAccess", + "reviewRequest", + "upgradePlan" + ] as const); + const { membership, permission } = useProjectPermission(); + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + + const { data: members } = useGetWorkspaceUsers(projectId); + const membersGroupById = members?.reduce>( + (prev, curr) => ({ ...prev, [curr.id]: curr }), + {} + ); + + const [statusFilter, setStatusFilter] = useState<"open" | "close">("open"); + const [requestedByFilter, setRequestedByFilter] = useState(undefined); + const [envFilter, setEnvFilter] = useState(undefined); + + const { data: requestCount } = useGetAccessRequestsCount({ + projectSlug + }); + + const { data: policies, isLoading: policiesLoading } = useGetAccessApprovalPolicies({ + projectSlug + }); + + const { data: requests } = useGetAccessApprovalRequests({ + projectSlug, + authorProjectMembershipId: requestedByFilter, + envSlug: envFilter + }); + + const filteredRequests = useMemo(() => { + if (statusFilter === "open") + return requests?.filter( + (request) => + !request.isApproved && + !request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) + ); + if (statusFilter === "close") + return requests?.filter( + (request) => + request.isApproved || + request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) + ); + + return requests; + }, [requests, statusFilter, requestedByFilter, envFilter]); + + const generateRequestDetails = (request: TAccessApprovalRequest) => { + const isReviewedByUser = + request.reviewers.findIndex(({ member }) => member === membership.id) !== -1; + const isRejectedByAnyone = request.reviewers.some( + ({ status }) => status === ApprovalStatus.REJECTED + ); + const isApprover = request.policy.approvers.indexOf(membership.id || "") !== -1; + const isAccepted = request.isApproved; + + const userReviewStatus = request.reviewers.find( + ({ member }) => member === membership.id + )?.status; + + let displayData: { label: string; type: "primary" | "danger" | "success" } = { + label: "", + type: "primary" + }; + + const isExpired = + request.privilege && + request.isApproved && + new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string)); + + if (isExpired) displayData = { label: "Access Expired", type: "danger" }; + else if (isAccepted) displayData = { label: "Access Granted", type: "success" }; + else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" }; + else if (userReviewStatus === ApprovalStatus.APPROVED) { + displayData = { + label: `Pending ${request.policy.approvals - request.reviewers.length} review${ + request.policy.approvals - request.reviewers.length > 1 ? "s" : "" + }`, + type: "primary" + }; + } else if (!isReviewedByUser) + displayData = { + label: "Review Required", + type: "primary" + }; + + return { + displayData, + isReviewedByUser, + isRejectedByAnyone, + isApprover, + userReviewStatus, + isAccepted + }; + }; + + return ( +
+
+
+ Access Requests +
+ Request access to secrets in sensitive environments and folders. +
+
+
+ + + +
+
+ + + +
+
setStatusFilter("open")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("open"); + }} + className={ + statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" + } + > + + {!!requestCount && requestCount?.pendingCount} Pending +
+
setStatusFilter("close")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("close"); + }} + > + + {!!requestCount && requestCount.finalizedCount} Completed +
+
+ + + + + + Select an environment + {currentWorkspace?.environments.map(({ slug, name }) => ( + setEnvFilter((state) => (state === slug ? undefined : slug))} + key={`request-filter-${slug}`} + icon={envFilter === slug && } + iconPos="right" + > + {name} + + ))} + + + {!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && ( + + + + + + Select an author + {members?.map(({ user, id }) => ( + + setRequestedByFilter((state) => (state === id ? undefined : id)) + } + key={`request-filter-member-${id}`} + icon={requestedByFilter === id && } + iconPos="right" + > + {user.username} + + ))} + + + )} +
+
+
+ {filteredRequests?.length === 0 && ( +
+ +
+ )} + {!!filteredRequests?.length && + filteredRequests?.map((request) => { + const details = generateRequestDetails(request); + + return ( +
{ + if ( + !details.isApprover || + details.isReviewedByUser || + details.isRejectedByAnyone || + details.isAccepted + ) + return; + + setSelectedRequest({ + ...request, + user: membersGroupById?.[request.requestedBy].user! + }); + handlePopUpOpen("reviewRequest"); + }} + onKeyDown={(evt) => { + if ( + !details.isApprover || + details.isAccepted || + details.isReviewedByUser || + details.isRejectedByAnyone + ) + return; + if (evt.key === "Enter") { + setSelectedRequest({ + ...request, + user: membersGroupById?.[request.requestedBy].user! + }); + handlePopUpOpen("reviewRequest"); + } + }} + > +
+
+
+ + {generateRequestText(request, membership.id)} +
+
+
+ {membersGroupById?.[request.requestedBy]?.user && ( + <> + Requested {formatDistance(new Date(request.createdAt), new Date())}{" "} + ago by {membersGroupById?.[request.requestedBy]?.user?.firstName}{" "} + {membersGroupById?.[request.requestedBy]?.user?.lastName} ( + {membersGroupById?.[request.requestedBy]?.user?.email}){" "} + + )} +
+
+ {details.isApprover && ( + + {details.displayData.label} + + )} +
+
+
+
+
+ ); + })} +
+
+
+ + {!!policies && ( + { + queryClient.invalidateQueries( + accessApprovalKeys.getAccessApprovalRequests( + projectSlug, + envFilter, + requestedByFilter + ) + ); + handlePopUpClose("requestAccess"); + }} + /> + )} + + {!!selectedRequest && ( + { + handlePopUpClose("reviewRequest"); + setSelectedRequest(null); + }} + /> + )} + + handlePopUpClose("upgradePlan")} + /> +
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx new file mode 100644 index 000000000..735ca3b53 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx @@ -0,0 +1,25 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { TAccessApprovalPolicy } from "@app/hooks/api/types"; +import { SpecificPrivilegeSecretForm } from "@app/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection"; + +export const RequestAccessModal = ({ + isOpen, + onOpenChange, + policies +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + policies: TAccessApprovalPolicy[]; +}) => { + return ( + + + onOpenChange(false)} policies={policies} /> + + + ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx new file mode 100644 index 000000000..481cee9c6 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -0,0 +1,158 @@ +import { useCallback, useMemo, useState } from "react"; +import ms from "ms"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalContent } from "@app/components/v2"; +import { Badge } from "@app/components/v2/Badge"; +import { ProjectPermissionActions } from "@app/context"; +import { useReviewAccessRequest } from "@app/hooks/api"; +import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; + +export const ReviewAccessRequestModal = ({ + isOpen, + onOpenChange, + request, + projectSlug, + selectedRequester, + selectedEnvSlug +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + request: TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null }; + projectSlug: string; + selectedRequester: string | undefined; + selectedEnvSlug: string | undefined; +}) => { + const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); + + const accessDetails = { + env: request.environmentName, + // secret path will be inside $glob operator + secretPath: request.policy.secretPath, + read: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)), + edit: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)), + create: request.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Create) + ), + delete: request.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Delete) + ), + + temporaryAccess: { + isTemporary: request.isTemporary, + temporaryRange: request.temporaryRange + } + }; + + const requestedAccess = useMemo(() => { + const access: string[] = []; + if (accessDetails.read) access.push("Read"); + if (accessDetails.edit) access.push("Edit"); + if (accessDetails.create) access.push("Create"); + if (accessDetails.delete) access.push("Delete"); + + return access.join(", "); + }, [accessDetails]); + + const getAccessLabel = () => { + if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange) + return "Permanent"; + + // convert the range to human readable format + ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true }); + + return ( + + {`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), { + long: true + })} after approval`} + + ); + }; + + const reviewAccessRequest = useReviewAccessRequest(); + + const handleReview = useCallback(async (status: "approved" | "rejected") => { + setIsLoading(status); + try { + await reviewAccessRequest.mutateAsync({ + requestId: request.id, + status, + projectSlug, + envSlug: selectedEnvSlug, + requestedBy: selectedRequester + }); + } catch (error) { + console.error(error); + setIsLoading(null); + return; + } + + createNotification({ + title: `Request ${status}`, + text: `The request has been ${status}`, + type: status === "approved" ? "success" : "info" + }); + + setIsLoading(null); + onOpenChange(false); + }, []); + + return ( + + +
+ + + {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) + {" "} + is requesting access to the following resource: + + +
+
+ Requested path: + {accessDetails.env + accessDetails.secretPath || ""} +
+ +
+ Permissions: + {requestedAccess} +
+ +
+ Access Type: + {getAccessLabel()} +
+
+ +
+ + +
+
+
+
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/index.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/index.tsx new file mode 100644 index 000000000..ec0a8d744 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/index.tsx @@ -0,0 +1 @@ +export { AccessApprovalRequest } from "./AccessApprovalRequest"; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx index 345ff7d52..d3a36988c 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx @@ -46,7 +46,6 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => { ] as const); const { permission } = useProjectPermission(); const { subscription } = useSubscription(); - const { data: members } = useGetWorkspaceUsers(workspaceId); const { data: policies, isLoading: isPoliciesLoading } = useGetSecretApprovalPolicies({ @@ -120,7 +119,6 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => { Secret Path Eligible Approvers Approval Required - diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx index c256af1c7..e0e4dd5fc 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx @@ -98,7 +98,7 @@ export const SecretApprovalPolicyRow = ({ iconPos="right" icon={isChecked && } > - {user.email} + {user.username} ); })} diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx index 185c596c2..db59761c1 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx @@ -222,7 +222,7 @@ export const SecretPolicyForm = ({ iconPos="right" icon={isChecked && } > - {user.email} + {user.username} ); })} diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index 90133803b..0d0c6213a 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -19,7 +19,13 @@ import { EmptyState, Skeleton } from "@app/components/v2"; -import { useUser, useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useUser, + useWorkspace +} from "@app/context"; import { useGetSecretApprovalRequestCount, useGetSecretApprovalRequests, @@ -58,6 +64,7 @@ export const SecretApprovalRequest = () => { const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } = useGetSecretApprovalRequestCount({ workspaceId }); const { user: presentUser } = useUser(); + const { permission } = useProjectPermission(); const { data: members } = useGetWorkspaceUsers(workspaceId); const membersGroupById = members?.reduce>( (prev, curr) => ({ ...prev, [curr.id]: curr }), @@ -156,34 +163,40 @@ export const SecretApprovalRequest = () => { ))} - - - - - - Select an author - {members?.map(({ user, id }) => ( - setCommitterFilter((state) => (state === id ? undefined : id))} - key={`request-filter-member-${id}`} - icon={committerFilter === id && } - iconPos="right" + {!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && ( + + + + + + Select an author + {members?.map(({ user, id }) => ( + + setCommitterFilter((state) => (state === id ? undefined : id)) + } + key={`request-filter-member-${id}`} + icon={committerFilter === id && } + iconPos="right" + > + {user.username} + + ))} + + + )}
-
+
{isRequestListEmpty && (
diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx index 51f26a549..c3d804779 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx @@ -37,7 +37,6 @@ export const SecretApprovalRequestAction = ({ workspaceId, canApprove }: Props) => { - const { mutateAsync: performSecretApprovalMerge, isLoading: isMerging } = usePerformSecretApprovalRequestMerge(); @@ -136,7 +135,7 @@ export const SecretApprovalRequestAction = ({
- Change request merged + Secret approval merged Merged by {statusChangeByEmail} @@ -150,7 +149,7 @@ export const SecretApprovalRequestAction = ({
- Change request has been closed + Secret approval has been closed Closed by {statusChangeByEmail} diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index e1437b563..80dbe9f73 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -83,7 +83,6 @@ export const SecretApprovalRequestChanges = ({ workspaceId, members = {} }: Props) => { - const { user } = useUser(); const { data: decryptFileKey } = useGetUserWsKey(workspaceId); const { @@ -94,7 +93,6 @@ export const SecretApprovalRequestChanges = ({ id: approvalRequestId, decryptKey: decryptFileKey! }); - console.log(secretApprovalRequestDetails); const { mutateAsync: updateSecretApprovalRequestStatus, diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index f670ea496..61c7cf8c0 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { subject } from "@casl/ability"; import { faAngleDown, @@ -45,6 +46,7 @@ import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from import { usePopUp } from "@app/hooks"; import { useCreateFolder, useDeleteSecretBatch } from "@app/hooks/api"; import { DecryptedSecret, TImportedSecrets, WsTag } from "@app/hooks/api/types"; +import { debounce } from "@app/lib/fn/debounce"; import { PopUpNames, @@ -106,6 +108,7 @@ export const ActionBar = ({ ] as const); const { subscription } = useSubscription(); const { openPopUp } = usePopUpAction(); + const [search, setSearch] = useState(filter.searchFilter); const { mutateAsync: createFolder } = useCreateFolder(); const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); @@ -114,6 +117,8 @@ export const ActionBar = ({ const { reset: resetSelectedSecret } = useSelectedSecretActions(); const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length); + const debouncedOnSearch = debounce(onSearchChange, 500); + const handleFolderCreate = async (folderName: string) => { try { await createFolder({ @@ -199,8 +204,11 @@ export const ActionBar = ({ className="bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80" placeholder="Search by folder name, key name, comment..." leftIcon={} - value={filter.searchFilter} - onChange={(evt) => onSearchChange(evt.target.value)} + value={search} + onChange={(evt) => { + setSearch(evt.target.value); + debouncedOnSearch(evt.target.value); + }} />
diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx index 455b7bbe6..7996d932c 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx @@ -14,6 +14,7 @@ import { Popover, PopoverContent, PopoverTrigger, + SecretInput, Spinner, TextArea, Tooltip @@ -48,7 +49,6 @@ import { memo, useEffect } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { twMerge } from "tailwind-merge"; -import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { CreateReminderForm } from "./CreateReminderForm"; import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils"; @@ -263,12 +263,10 @@ export const SecretItem = memo( key="value-overriden" control={control} render={({ field }) => ( - @@ -280,12 +278,10 @@ export const SecretItem = memo( key="secret-value" control={control} render={({ field }) => ( - diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 692bdcbe3..afe806c5e 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -47,6 +47,7 @@ import { ProjectPermissionActions, ProjectPermissionSub, useOrganization, + useProjectPermission, useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; @@ -61,6 +62,9 @@ import { useGetUserWsKey, useUpdateSecretV3 } from "@app/hooks/api"; +import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; +import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; +import { TSecretFolder } from "@app/hooks/api/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm"; @@ -70,6 +74,12 @@ import { ProjectIndexSecretsSection } from "./components/ProjectIndexSecretsSect import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow"; import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow"; +import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel"; + +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} export const SecretOverviewPage = () => { const { t } = useTranslation(); @@ -81,15 +91,7 @@ export const SecretOverviewPage = () => { const parentTableRef = useRef(null); const [expandableTableWidth, setExpandableTableWidth] = useState(0); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); - - useEffect(() => { - const handleParentTableWidthResize = () => { - setExpandableTableWidth(parentTableRef.current?.clientWidth || 0); - }; - - window.addEventListener("resize", handleParentTableWidthResize); - return () => window.removeEventListener("resize", handleParentTableWidthResize); - }, []); + const { permission } = useProjectPermission(); useEffect(() => { if (parentTableRef.current) { @@ -105,6 +107,56 @@ export const SecretOverviewPage = () => { const [searchFilter, setSearchFilter] = useState(""); const secretPath = (router.query?.secretPath as string) || "/"; + const [selectedEntries, setSelectedEntries] = useState<{ + [EntryType.FOLDER]: Record; + [EntryType.SECRET]: Record; + }>({ + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }); + + const toggleSelectedEntry = useCallback( + (type: EntryType, key: string) => { + const isChecked = Boolean(selectedEntries[type]?.[key]); + const newChecks = { ...selectedEntries }; + + // remove selection if its present else add it + if (isChecked) { + delete newChecks[type][key]; + } else { + newChecks[type][key] = true; + } + + setSelectedEntries(newChecks); + }, + [selectedEntries] + ); + + const resetSelectedEntries = useCallback(() => { + setSelectedEntries({ + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }); + }, []); + + useEffect(() => { + const handleParentTableWidthResize = () => { + setExpandableTableWidth(parentTableRef.current?.clientWidth || 0); + }; + + const onRouteChangeStart = () => { + resetSelectedEntries(); + }; + + router.events.on("routeChangeStart", onRouteChangeStart); + + window.addEventListener("resize", handleParentTableWidthResize); + return () => { + window.removeEventListener("resize", handleParentTableWidthResize); + router.events.off("routeChangeStart", onRouteChangeStart); + }; + }, []); + useEffect(() => { if (!isWorkspaceLoading && !workspaceId && router.isReady) { router.push(`/org/${currentOrg?.id}/overview`); @@ -129,7 +181,8 @@ export const SecretOverviewPage = () => { secretPath, decryptFileKey: latestFileKey! }); - const { folders, folderNames, isFolderPresentInEnv } = useGetFoldersByEnv({ + + const { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv } = useGetFoldersByEnv({ projectId: workspaceId, path: secretPath, environments: userAvailableEnvs.map(({ slug }) => slug) @@ -153,11 +206,13 @@ export const SecretOverviewPage = () => { const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3(); const { mutateAsync: createFolder } = useCreateFolder(); + const { mutateAsync: updateFolderBatch } = useUpdateFolderBatch(); const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addSecretsInAllEnvs", "addFolder", - "misc" + "misc", + "updateFolder" ] as const); const handleFolderCreate = async (folderName: string) => { @@ -188,6 +243,59 @@ export const SecretOverviewPage = () => { } }; + const handleFolderUpdate = async (newFolderName: string) => { + const { name: oldFolderName } = popUp.updateFolder.data as TSecretFolder; + + const updatedFolders: TUpdateFolderBatchDTO["folders"] = []; + userAvailableEnvs.forEach((env) => { + if ( + permission.can( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath }) + ) + ) { + const folder = getFolderByNameAndEnv(oldFolderName, env.slug); + if (folder) { + updatedFolders.push({ + environment: env.slug, + name: newFolderName, + id: folder.id, + path: secretPath + }); + } + } + }); + + if (updatedFolders.length === 0) { + createNotification({ + type: "info", + text: "You don't have access to rename selected folder" + }); + + handlePopUpClose("updateFolder"); + return; + } + + try { + await updateFolderBatch({ + projectSlug, + folders: updatedFolders, + projectId: workspaceId + }); + createNotification({ + type: "success", + text: "Successfully renamed folder across environments" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to rename folder across environments" + }); + } finally { + handlePopUpClose("updateFolder"); + } + }; + const handleSecretCreate = async (env: string, key: string, value: string) => { try { // create folder if not existing @@ -543,6 +651,13 @@ export const SecretOverviewPage = () => {
+
@@ -666,9 +781,14 @@ export const SecretOverviewPage = () => { toggleSelectedEntry(EntryType.FOLDER, folderName)} environments={visibleEnvs} key={`overview-${folderName}-${index + 1}`} onClick={handleFolderClick} + onToggleFolderEdit={(name: string) => + handlePopUpOpen("updateFolder", { name }) + } /> ))} {!isTableLoading && @@ -684,6 +804,8 @@ export const SecretOverviewPage = () => { visibleEnvs?.length > 0 && filteredSecretNames.map((key, index) => ( toggleSelectedEntry(EntryType.SECRET, key)} secretPath={secretPath} isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} onSecretCreate={handleSecretCreate} @@ -741,6 +863,18 @@ export const SecretOverviewPage = () => { + handlePopUpToggle("updateFolder", isOpen)} + > + + )?.name} + onUpdateFolder={handleFolderUpdate} + /> + + ); }; diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx index 597541fe7..2625d14c6 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx @@ -1,21 +1,26 @@ -import { faCheck, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faFolder, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { Td, Tr } from "@app/components/v2"; +import { Checkbox, IconButton, Td, Tr } from "@app/components/v2"; type Props = { folderName: string; environments: { name: string; slug: string }[]; isFolderPresentInEnv: (name: string, env: string) => boolean; onClick: (path: string) => void; + isSelected: boolean; + onToggleFolderSelect: (folderName: string) => void; + onToggleFolderEdit: (name: string) => void; }; export const SecretOverviewFolderRow = ({ folderName, environments = [], isFolderPresentInEnv, - + isSelected, + onToggleFolderSelect, + onToggleFolderEdit, onClick }: Props) => { return ( @@ -23,9 +28,35 @@ export const SecretOverviewFolderRow = ({ {environments.map(({ slug }, i) => { diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index 076ff22c8..f1476c167 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -11,7 +11,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { Button, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; +import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; import { useToggle } from "@app/hooks"; import { DecryptedSecret } from "@app/hooks/api/secrets/types"; @@ -23,6 +23,8 @@ type Props = { secretPath: string; environments: { name: string; slug: string }[]; expandableColWidth: number; + isSelected: boolean; + onToggleSecretSelect: (key: string) => void; getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; onSecretCreate: (env: string, key: string, value: string) => Promise; onSecretUpdate: (env: string, key: string, value: string, secretId?: string) => Promise; @@ -39,7 +41,9 @@ export const SecretOverviewTableRow = ({ onSecretCreate, onSecretDelete, isImportedSecretPresentInEnv, - expandableColWidth + expandableColWidth, + onToggleSecretSelect, + isSelected }: Props) => { const [isFormExpanded, setIsFormExpanded] = useToggle(); const totalCols = environments.length + 1; // secret key row @@ -56,7 +60,21 @@ export const SecretOverviewTableRow = ({
- + { + onToggleSecretSelect(secretKey); + }} + onClick={(e) => { + e.stopPropagation(); + }} + className={twMerge("hidden group-hover:flex", isSelected && "flex")} + /> +
{secretKey}
diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx new file mode 100644 index 000000000..6a2fe9bf6 --- /dev/null +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -0,0 +1,184 @@ +import { subject } from "@casl/ability"; +import { faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal, IconButton, Tooltip } from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api"; +import { DecryptedSecret, TDeleteSecretBatchDTO, TSecretFolder } from "@app/hooks/api/types"; + +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} + +type Props = { + secretPath: string; + getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; + getFolderByNameAndEnv: (name: string, env: string) => TSecretFolder | undefined; + resetSelectedEntries: () => void; + selectedEntries: { + [EntryType.FOLDER]: Record; + [EntryType.SECRET]: Record; + }; +}; + +export const SelectionPanel = ({ + getFolderByNameAndEnv, + getSecretByKey, + secretPath, + resetSelectedEntries, + selectedEntries +}: Props) => { + const { permission } = useProjectPermission(); + + const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ + "bulkDeleteEntries" + ] as const); + + const selectedCount = + Object.keys(selectedEntries.folder).length + Object.keys(selectedEntries.secret).length; + + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const userAvailableEnvs = currentWorkspace?.environments || []; + const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); + const { mutateAsync: deleteFolder } = useDeleteFolder(); + + const isMultiSelectActive = selectedCount > 0; + + // user should have the ability to delete secrets/folders in at least one of the envs + const shouldShowDelete = userAvailableEnvs.some((env) => + permission.can( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath }) + ) + ); + + const handleBulkDelete = async () => { + let processedEntries = 0; + + const promises = userAvailableEnvs.map(async (env) => { + // additional check: ensure that bulk delete is only executed on envs that user has access to + if ( + permission.cannot( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath }) + ) + ) { + return; + } + + await Promise.all( + Object.keys(selectedEntries.folder).map(async (folderName) => { + const folder = getFolderByNameAndEnv(folderName, env.slug); + if (folder) { + processedEntries += 1; + await deleteFolder({ + folderId: folder?.id, + path: secretPath, + environment: env.slug, + projectId: workspaceId + }); + } + }) + ); + + const secretsToDelete = Object.keys(selectedEntries.secret).reduce( + (accum: TDeleteSecretBatchDTO["secrets"], secretName) => { + const entry = getSecretByKey(env.slug, secretName); + if (entry) { + return [ + ...accum, + { + secretName: entry.key, + type: "shared" as "shared" + } + ]; + } + return accum; + }, + [] + ); + + if (secretsToDelete.length > 0) { + processedEntries += secretsToDelete.length; + await deleteBatchSecretV3({ + secretPath, + workspaceId, + environment: env.slug, + secrets: secretsToDelete + }); + } + }); + + const results = await Promise.allSettled(promises); + const areEntriesDeleted = results.some((result) => result.status === "fulfilled"); + if (processedEntries === 0) { + handlePopUpClose("bulkDeleteEntries"); + createNotification({ + type: "info", + text: "You don't have access to delete selected items" + }); + } else if (areEntriesDeleted) { + handlePopUpClose("bulkDeleteEntries"); + resetSelectedEntries(); + createNotification({ + type: "success", + text: "Successfully deleted selected secrets and folders" + }); + } else { + createNotification({ + type: "error", + text: "Failed to delete selected secrets and folders" + }); + } + }; + + return ( + <> +
+
+ + + + + +
{selectedCount} Selected
+ {shouldShowDelete && ( + + )} +
+
+ handlePopUpToggle("bulkDeleteEntries", isOpen)} + onDeleteApproved={handleBulkDelete} + /> + + ); +}; diff --git a/frontend/src/views/Signup/SignupSSO.tsx b/frontend/src/views/Signup/SignupSSO.tsx index 141865026..2510c9fa4 100644 --- a/frontend/src/views/Signup/SignupSSO.tsx +++ b/frontend/src/views/Signup/SignupSSO.tsx @@ -1,7 +1,7 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import jwt_decode from "jwt-decode"; -import { BackupPDFStep, UserInfoSSOStep } from "./components"; +import { BackupPDFStep, EmailConfirmationStep, UserInfoSSOStep } from "./components"; type Props = { providerAuthToken: string; @@ -11,11 +11,38 @@ export const SignupSSO = ({ providerAuthToken }: Props) => { const [step, setStep] = useState(0); const [password, setPassword] = useState(""); - const { username, organizationName, firstName, lastName } = jwt_decode(providerAuthToken) as any; + const { + username, + email, + organizationName, + organizationSlug, + firstName, + lastName, + authType, + isEmailVerified + } = jwt_decode(providerAuthToken) as any; + + useEffect(() => { + if (!isEmailVerified) { + setStep(0); + } else { + setStep(1); + } + }, []); const renderView = () => { switch (step) { case 0: + return ( + + ); + case 1: return ( { providerAuthToken={providerAuthToken} /> ); - case 1: + case 2: return ( ); diff --git a/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx new file mode 100644 index 000000000..c608fb99c --- /dev/null +++ b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -0,0 +1,185 @@ +// confirm email +// if same email exists, then trigger fn to merge automatically +import { useState } from "react"; +import ReactCodeInput from "react-code-input"; +import { useRouter } from "next/router"; + +import Error from "@app/components/basic/Error"; +import { createNotification } from "@app/components/notifications"; +import { Button } from "@app/components/v2"; +import { useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "@app/hooks/api"; +import { UserAliasType } from "@app/hooks/api/users/types"; + +type Props = { + authType?: UserAliasType; + username: string; + email: string; + organizationSlug: string; + setStep: (step: number) => void; +}; + +// The style for the verification code input +const props = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "55px", + borderRadius: "5px", + fontSize: "24px", + height: "55px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + } +} as const; +const propsPhone = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "40px", + borderRadius: "5px", + fontSize: "24px", + height: "40px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + } +} as const; + +export const EmailConfirmationStep = ({ + authType, + username, + email, + organizationSlug, + setStep +}: Props) => { + const router = useRouter(); + const [code, setCode] = useState(""); + const [codeError, setCodeError] = useState(false); + const [isResendingVerificationEmail] = useState(false); + const [isLoading] = useState(false); + + const { mutateAsync: sendEmailVerificationCode } = useSendEmailVerificationCode(); + const { mutateAsync: verifyEmailVerificationCode } = useVerifyEmailVerificationCode(); + + const checkCode = async () => { + try { + await verifyEmailVerificationCode({ username, code }); + setCodeError(false); + + createNotification({ + text: "Successfully verified code", + type: "success" + }); + + switch (authType) { + case UserAliasType.SAML: { + window.open(`/api/v1/sso/redirect/saml2/organizations/${organizationSlug}`); + window.close(); + break; + } + case UserAliasType.LDAP: { + router.push(`/login/ldap?organizationSlug=${organizationSlug}`); + break; + } + default: { + setStep(1); + break; + } + } + } catch (err) { + createNotification({ + text: "Failed to verify code", + type: "error" + }); + } + + setCode(""); + }; + + const resendCode = async () => { + try { + await sendEmailVerificationCode(username); + createNotification({ + text: "Successfully resent code", + type: "success" + }); + } catch (err) { + createNotification({ + text: "Failed to resend code", + type: "error" + }); + } + }; + + return ( +
+

+ We've sent a verification code to {email} +

+
+ +
+
+ +
+ {codeError && } +
+
+ +
+
+
+
+ Don't see the code? +
+ +
+
+

Make sure to check your spam inbox.

+
+
+ ); +}; diff --git a/frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx b/frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx new file mode 100644 index 000000000..32f3a636e --- /dev/null +++ b/frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx @@ -0,0 +1 @@ +export { EmailConfirmationStep } from "./EmailConfirmationStep"; diff --git a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx index 1cef143d5..69168e0af 100644 --- a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -201,7 +201,7 @@ export const UserInfoSSOStep = ({ localStorage.setItem("orgData.id", orgId); localStorage.setItem("projectData.id", project.id); - setStep(1); + setStep(2); } catch (error) { setIsLoading(false); console.error(error); diff --git a/frontend/src/views/Signup/components/index.tsx b/frontend/src/views/Signup/components/index.tsx index a4628de35..7ab3d853c 100644 --- a/frontend/src/views/Signup/components/index.tsx +++ b/frontend/src/views/Signup/components/index.tsx @@ -1,2 +1,3 @@ export { BackupPDFStep } from "./BackupPDFStep"; +export { EmailConfirmationStep } from "./EmailConfirmationStep"; export { UserInfoSSOStep } from "./UserInfoSSOStep"; diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index d5bc4097e..ce1b117f3 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -14,11 +14,11 @@ import { Input, Select, SelectItem, + Switch, Tab, TabList, TabPanel, - Tabs -} from "@app/components/v2"; + Tabs} from "@app/components/v2"; import { useOrganization, useServerConfig, useUser } from "@app/context"; import { useUpdateServerConfig } from "@app/hooks/api"; @@ -33,7 +33,9 @@ enum SignUpModes { const formSchema = z.object({ signUpMode: z.nativeEnum(SignUpModes), - allowedSignUpDomain: z.string().optional().nullable() + allowedSignUpDomain: z.string().optional().nullable(), + trustSamlEmails: z.boolean(), + trustLdapEmails: z.boolean() }); type TDashboardForm = z.infer; @@ -52,7 +54,9 @@ export const AdminDashboardPage = () => { values: { // eslint-disable-next-line signUpMode: config.allowSignUp ? SignUpModes.Anyone : SignUpModes.Disabled, - allowedSignUpDomain: config.allowedSignUpDomain + allowedSignUpDomain: config.allowedSignUpDomain, + trustSamlEmails: config.trustSamlEmails, + trustLdapEmails: config.trustLdapEmails } }); @@ -62,8 +66,6 @@ export const AdminDashboardPage = () => { const { orgs } = useOrganization(); const { mutateAsync: updateServerConfig } = useUpdateServerConfig(); - - const isNotAllowed = !user?.superAdmin; // TODO(akhilmhdh): on nextjs 14 roadmap this will be properly addressed with context split @@ -78,10 +80,13 @@ export const AdminDashboardPage = () => { const onFormSubmit = async (formData: TDashboardForm) => { try { - const { signUpMode, allowedSignUpDomain } = formData; + const { signUpMode, allowedSignUpDomain, trustSamlEmails, trustLdapEmails } = formData; + await updateServerConfig({ allowSignUp: signUpMode !== SignUpModes.Disabled, - allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null + allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null, + trustSamlEmails, + trustLdapEmails }); createNotification({ text: "Successfully changed sign up setting.", @@ -123,8 +128,9 @@ export const AdminDashboardPage = () => {
Allow user signups
-
- Select if you want users to be able to signup freely into your Infisical instance. +
+ Select if you want users to be able to signup freely into your Infisical + instance.
{ />
)} +
+
Trust emails
+
+ Select if you want Infisical to trust external emails from SAML/LDAP identity + providers. If set to false, then Infisical will prompt SAML/LDAP provisioned + users to verify their email upon their first login. +
+ { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Trust SAML emails

+
+
+ ); + }} + /> + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Trust LDAP emails

+
+
+ ); + }} + /> +
- + { + onToggleFolderSelect(folderName); + }} + onClick={(e) => { + e.stopPropagation(); + }} + className={twMerge("hidden group-hover:flex", isSelected && "flex")} + /> +
{folderName}
+ { + onToggleFolderEdit(folderName); + e.stopPropagation(); + }} + > + +