diff --git a/.github/workflows/release_helm_gateway.yaml b/.github/workflows/release_helm_gateway.yaml new file mode 100644 index 000000000..1b067bd73 --- /dev/null +++ b/.github/workflows/release_helm_gateway.yaml @@ -0,0 +1,27 @@ +name: Release Gateway Helm Chart +on: + workflow_dispatch: + +jobs: + release-helm: + name: Release Helm Chart + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Helm + uses: azure/setup-helm@v3 + with: + version: v3.10.0 + + - name: Install python + uses: actions/setup-python@v4 + + - name: Install Cloudsmith CLI + run: pip install --upgrade cloudsmith-cli + + - name: Build and push helm package to CloudSmith + run: cd helm-charts && sh upload-gateway-cloudsmith.sh + env: + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} diff --git a/.infisicalignore b/.infisicalignore index 4ccf734b6..b00bf0995 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -24,3 +24,7 @@ frontend/src/hooks/api/secretRotationsV2/types/index.ts:generic-api-key:65 frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx:generic-api-key:26 docs/documentation/platform/kms/overview.mdx:generic-api-key:281 docs/documentation/platform/kms/overview.mdx:generic-api-key:344 +frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx:generic-api-key:85 +docs/cli/commands/user.mdx:generic-api-key:51 +frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx:generic-api-key:76 +docs/integrations/app-connections/hashicorp-vault.mdx:generic-api-key:188 diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index b23cf05ae..f901f0df9 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -69,6 +69,15 @@ module.exports = { ["^\\."] ] } + ], + "import/extensions": [ + "error", + "ignorePackages", + { + "": "never", // this is required to get the .tsx to work... + ts: "never", + tsx: "never" + } ] } }; diff --git a/backend/package-lock.json b/backend/package-lock.json index eaf32ae4c..93c28c9e2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -48,6 +48,7 @@ "@opentelemetry/semantic-conventions": "^1.27.0", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/x509": "^1.12.1", + "@react-email/components": "0.0.36", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.2", @@ -59,6 +60,7 @@ "axios": "^1.6.7", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", + "botbuilder": "^4.23.2", "bullmq": "^5.4.2", "cassandra-driver": "^4.7.2", "connect-redis": "^7.1.1", @@ -106,6 +108,8 @@ "posthog-node": "^3.6.2", "probot": "^13.3.8", "re2": "^1.21.4", + "react": "19.1.0", + "react-dom": "19.1.0", "safe-regex": "^2.1.1", "scim-patch": "^0.8.3", "scim2-parse-filter": "^0.2.10", @@ -141,6 +145,7 @@ "@types/picomatch": "^2.3.3", "@types/pkcs11js": "^1.0.4", "@types/prompt-sync": "^4.2.3", + "@types/react": "^19.1.2", "@types/resolve": "^1.20.6", "@types/safe-regex": "^1.1.6", "@types/sjcl": "^1.0.34", @@ -160,6 +165,7 @@ "nodemon": "^3.0.2", "pino-pretty": "^10.2.3", "prompt-sync": "^4.2.0", + "react-email": "4.0.7", "rimraf": "^5.0.5", "ts-node": "^10.9.2", "tsc-alias": "^1.8.8", @@ -2358,12 +2364,13 @@ } }, "node_modules/@azure/core-auth": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.7.2.tgz", - "integrity": "sha512-Igm/S3fDYmnMq1uKS38Ae1/m37B3zigdlZw+kocwEhh5GjyKjPrXKO2J6rzpC1wAxrNil/jX9BJRqBshyjnF3g==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.9.0.tgz", + "integrity": "sha512-FPwHpZywuyasDSLMqJ6fhbOK3TqUdviZNF8OqRGA4W5Ewib2lEEZ+pBsYcBa88B2NGO/SEnYPGhyBqNlE8ilSw==", + "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", - "@azure/core-util": "^1.1.0", + "@azure/core-util": "^1.11.0", "tslib": "^2.6.2" }, "engines": { @@ -2518,14 +2525,15 @@ } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.16.1.tgz", - "integrity": "sha512-ExPSbgjwCoht6kB7B4MeZoBAxcQSIl29r/bPeazZJx50ej4JJCByimLOrZoIsurISNyJQQHf30b3JfqC3Hb88A==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.19.1.tgz", + "integrity": "sha512-zHeoI3NCs53lLBbWNzQycjnYKsA1CVKlnzSNuSFcUDwBp8HHVObePxrM7HaX+Ha5Ks639H7chNC9HOaIhNS03w==", + "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.4.0", + "@azure/core-auth": "^1.8.0", "@azure/core-tracing": "^1.0.1", - "@azure/core-util": "^1.9.0", + "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", @@ -2602,9 +2610,10 @@ } }, "node_modules/@azure/core-util": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.9.0.tgz", - "integrity": "sha512-AfalUQ1ZppaKuxPPMsFEUdX6GZPB3d9paR9d/TTL7Ow2De8cJaC7ibi7kWVlFAVPCYo31OcnGymc0R89DX8Oaw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.11.0.tgz", + "integrity": "sha512-DxOSLua+NdpWoSqULhjDyAZTXFdP/LKkqtYuxxz1SCN289zk3OG8UOpnCQAz/tygyACBtWp/BoO72ptK7msY8g==", + "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", "tslib": "^2.6.2" @@ -2625,46 +2634,60 @@ } }, "node_modules/@azure/identity": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.3.0.tgz", - "integrity": "sha512-LHZ58/RsIpIWa4hrrE2YuJ/vzG1Jv9f774RfTTAVDZDriubvJ0/S5u4pnw4akJDlS0TiJb6VMphmVUFsWmgodQ==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.9.1.tgz", + "integrity": "sha512-986D7Cf1AOwYqSDtO/FnMAyk/Jc8qpftkGsxuehoh4F85MhQ4fICBGX/44+X1y78lN4Sqib3Bsoaoh/FvOGgmg==", + "license": "MIT", "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.5.0", + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", - "@azure/core-rest-pipeline": "^1.1.0", + "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.3.0", + "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^3.11.1", - "@azure/msal-node": "^2.9.2", - "events": "^3.0.0", - "jws": "^4.0.0", - "open": "^8.0.0", - "stoppable": "^1.1.0", + "@azure/msal-browser": "^4.2.0", + "@azure/msal-node": "^3.5.0", + "open": "^10.1.0", "tslib": "^2.2.0" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@azure/identity/node_modules/jwa": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", - "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "node_modules/@azure/identity/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@azure/identity/node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "node_modules/@azure/identity/node_modules/@azure/msal-node": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.5.1.tgz", + "integrity": "sha512-dkgMYM5B6tI88r/oqf5bYd93WkenQpaWwiszJDk7avVjso8cmuKRTW97dA1RMi6RhihZFLtY1VtWxU9+sW2T5g==", + "license": "MIT", "dependencies": { - "jwa": "^2.0.0", - "safe-buffer": "^5.0.1" + "@azure/msal-common": "15.5.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@azure/identity/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==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" } }, "node_modules/@azure/keyvault-keys": { @@ -2700,30 +2723,33 @@ } }, "node_modules/@azure/msal-browser": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.18.0.tgz", - "integrity": "sha512-jvK5bDUWbpOaJt2Io/rjcaOVcUzkqkrCme/WntdV1SMUc67AiTcEdKuY6G/nMQ7N5Cfsk9SfpugflQwDku53yg==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.11.0.tgz", + "integrity": "sha512-0p5Ut3wORMP+975AKvaSPIO4UytgsfAvJ7RxaTx+nkP+Hpkmm93AuiMkBWKI2x9tApU/SLgIyPz/ZwLYUIWb5Q==", + "license": "MIT", "dependencies": { - "@azure/msal-common": "14.13.0" + "@azure/msal-common": "15.5.1" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "14.13.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.13.0.tgz", - "integrity": "sha512-b4M/tqRzJ4jGU91BiwCsLTqChveUEyFK3qY2wGfZ0zBswIBZjAxopx5CYt5wzZFKuN15HqRDYXQbztttuIC3nA==", + "version": "15.5.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.5.1.tgz", + "integrity": "sha512-oxK0khbc4Bg1bKQnqDr7ikULhVL2OHgSrIq0Vlh4b6+hm4r0lr6zPMQE8ZvmacJuh+ZZGKBM5iIObhF1q1QimQ==", + "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-node": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.10.0.tgz", - "integrity": "sha512-JxsSE0464a8IA/+q5EHKmchwNyUFJHtCH00tSXsLaOddwLjG6yVvTH6lGgPcWMhO7YWUXj/XVgVgeE9kZtsPUQ==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.2.tgz", + "integrity": "sha512-An7l1hEr0w1HMMh1LU+rtDtqL7/jw74ORlc9Wnh06v7TU/xpG39/Zdr1ZJu3QpjUfKJ+E0/OXMW8DRSWTlh7qQ==", + "license": "MIT", "dependencies": { - "@azure/msal-common": "14.13.0", + "@azure/msal-common": "14.16.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" }, @@ -2731,6 +2757,15 @@ "node": ">=16" } }, + "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { + "version": "14.16.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.0.tgz", + "integrity": "sha512-1KOZj9IpcDSwpNiQNjt0jDYZpQvNZay7QAEi/5DLubay40iGYtLzya/jbjRPLyOTZhEKyL1MzPuw2HqBCjceYA==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@azure/msal-node/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -2872,13 +2907,15 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", - "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.24.7", - "picocolors": "^1.0.0" + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -2956,15 +2993,17 @@ } }, "node_modules/@babel/generator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.7.tgz", - "integrity": "sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", + "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.24.7", + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" @@ -2980,6 +3019,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@babel/generator/node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@babel/helper-annotate-as-pure": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz", @@ -3318,19 +3370,21 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", - "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -3372,76 +3426,15 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/@babel/parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", - "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.1.tgz", + "integrity": "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==", "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -4777,33 +4770,32 @@ } }, "node_modules/@babel/template": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", - "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz", + "integrity": "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.7.tgz", - "integrity": "sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz", + "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", + "@babel/generator": "^7.25.6", + "@babel/parser": "^7.25.6", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -4844,14 +4836,14 @@ "dev": true }, "node_modules/@babel/types": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz", - "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -5162,6 +5154,17 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "license": "MIT" }, + "node_modules/@emnapi/runtime": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -5435,6 +5438,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", @@ -5451,6 +5471,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", @@ -6192,6 +6229,386 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@infisical/quic": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@infisical/quic/-/quic-1.0.8.tgz", @@ -6729,6 +7146,149 @@ "win32" ] }, + "node_modules/@next/env": { + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", + "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz", + "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz", + "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz", + "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz", + "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz", + "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz", + "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz", + "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz", + "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nicolo-ribaudo/chokidar-2": { "version": "2.1.8-no-fsevents.3", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", @@ -8489,6 +9049,286 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, + "node_modules/@react-email/body": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.11.tgz", + "integrity": "sha512-ZSD2SxVSgUjHGrB0Wi+4tu3MEpB4fYSbezsFNEJk2xCWDBkFiOeEsjTmR5dvi+CxTK691hQTQlHv0XWuP7ENTg==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/button": { + "version": "0.0.19", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.19.tgz", + "integrity": "sha512-HYHrhyVGt7rdM/ls6FuuD6XE7fa7bjZTJqB2byn6/oGsfiEZaogY77OtoLL/mrQHjHjZiJadtAMSik9XLcm7+A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/code-block": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.12.tgz", + "integrity": "sha512-Faw3Ij9+/Qwq6moWaeHnV8Hn7ekc/EqyAzPi6yUar21dhcqYugCC4Da1x4d9nA9zC0H9KU3lYVJczh8D3cA+Eg==", + "license": "MIT", + "dependencies": { + "prismjs": "1.30.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/code-inline": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.5.tgz", + "integrity": "sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/column": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.13.tgz", + "integrity": "sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/components": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.36.tgz", + "integrity": "sha512-VMh+OQplAnG8JMLlJjdnjt+ThJZ+JVkp0q2YMS2NEz+T88N22bLD2p7DZO0QgtNaKgumOhJI/0a2Q7VzCrwu5g==", + "license": "MIT", + "dependencies": { + "@react-email/body": "0.0.11", + "@react-email/button": "0.0.19", + "@react-email/code-block": "0.0.12", + "@react-email/code-inline": "0.0.5", + "@react-email/column": "0.0.13", + "@react-email/container": "0.0.15", + "@react-email/font": "0.0.9", + "@react-email/head": "0.0.12", + "@react-email/heading": "0.0.15", + "@react-email/hr": "0.0.11", + "@react-email/html": "0.0.11", + "@react-email/img": "0.0.11", + "@react-email/link": "0.0.12", + "@react-email/markdown": "0.0.14", + "@react-email/preview": "0.0.12", + "@react-email/render": "1.0.6", + "@react-email/row": "0.0.12", + "@react-email/section": "0.0.16", + "@react-email/tailwind": "1.0.4", + "@react-email/text": "0.1.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/container": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.15.tgz", + "integrity": "sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/font": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.9.tgz", + "integrity": "sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/head": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.12.tgz", + "integrity": "sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/heading": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.15.tgz", + "integrity": "sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/hr": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.11.tgz", + "integrity": "sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/html": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.11.tgz", + "integrity": "sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/img": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.11.tgz", + "integrity": "sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/link": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.12.tgz", + "integrity": "sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/markdown": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.14.tgz", + "integrity": "sha512-5IsobCyPkb4XwnQO8uFfGcNOxnsg3311GRXhJ3uKv51P7Jxme4ycC/MITnwIZ10w2zx7HIyTiqVzTj4XbuIHbg==", + "license": "MIT", + "dependencies": { + "md-to-react-email": "5.0.5" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/preview": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.12.tgz", + "integrity": "sha512-g/H5fa9PQPDK6WUEG7iTlC19sAktI23qyoiJtMLqQiXFCfWeQMhqjLGKeLSKkfzszqmfJCjZtpSiKtBoOdxp3Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/render": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.6.tgz", + "integrity": "sha512-zNueW5Wn/4jNC1c5LFgXzbUdv5Lhms+FWjOvWAhal7gx5YVf0q6dPJ0dnR70+ifo59gcMLwCZEaTS9EEuUhKvQ==", + "license": "MIT", + "dependencies": { + "html-to-text": "9.0.5", + "prettier": "3.5.3", + "react-promise-suspense": "0.3.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/row": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.12.tgz", + "integrity": "sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/section": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.16.tgz", + "integrity": "sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/tailwind": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-1.0.4.tgz", + "integrity": "sha512-tJdcusncdqgvTUYZIuhNC6LYTfL9vNTSQpwWdTCQhQ1lsrNCEE4OKCSdzSV3S9F32pi0i0xQ+YPJHKIzGjdTSA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/text": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.1.tgz", + "integrity": "sha512-Zo9tSEzkO3fODLVH1yVhzVCiwETfeEL5wU93jXKWo2DHoMuiZ9Iabaso3T0D0UjhrCB1PBMeq2YiejqeToTyIQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", @@ -8713,6 +9553,19 @@ "win32" ] }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@sentry-internal/tracing": { "version": "7.119.2", "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.119.2.tgz", @@ -9629,6 +10482,13 @@ "node": ">=16.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.3.107", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.107.tgz", @@ -9840,22 +10700,20 @@ } }, "node_modules/@swc/counter": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.2.tgz", - "integrity": "sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", "dev": true, - "optional": true, - "peer": true + "license": "Apache-2.0" }, "node_modules/@swc/helpers": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.3.tgz", - "integrity": "sha512-FaruWX6KdudYloq1AHD/4nU+UsMTdNE8CKyrseXWEcgjDAbvkwJg2QGPAnfIJLIWsjZOSPLOAykK6fuYp4vp4A==", + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "dev": true, - "optional": true, - "peer": true, + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" + "tslib": "^2.8.0" } }, "node_modules/@swc/types": { @@ -9949,6 +10807,16 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -10011,9 +10879,10 @@ "dev": true }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", - "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -10256,6 +11125,16 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, + "node_modules/@types/react": { + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz", + "integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, "node_modules/@types/readable-stream": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.14.tgz", @@ -10398,6 +11277,15 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/ws": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-6.0.4.tgz", + "integrity": "sha512-PpPrX7SZW9re6+Ha8ojZG4Se8AZXgf0GK6zmfqEuCsY49LFDNXO3SByp44X3dFEqtB73lkCDAdUazhAjVPiNwg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/xml-encryption": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.4.tgz", @@ -11150,6 +12038,12 @@ "node": ">=0.4.0" } }, + "node_modules/adaptivecards": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/adaptivecards/-/adaptivecards-1.2.3.tgz", + "integrity": "sha512-amQ5OSW3OpIkrxVKLjxVBPk/T49yuOtnqs1z5ZPfZr0+OpTovzmiHbyoAGDIsu5SNYHwOZFp/3LGOnRaALFa/g==", + "license": "MIT" + }, "node_modules/adm-zip": { "version": "0.5.12", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.12.tgz", @@ -11868,6 +12762,16 @@ } ] }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/base64url": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", @@ -12012,6 +12916,245 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/botbuilder": { + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/botbuilder/-/botbuilder-4.23.2.tgz", + "integrity": "sha512-E3UjkPlAmT8TidZIAW1ucVRejz0KBbWEn0wNxJ37GncPl8txhmWvs21xn3hBrifSR4++Y6q/hp/A5cHFQcFGJw==", + "license": "MIT", + "dependencies": { + "@azure/core-http": "^3.0.4", + "@azure/msal-node": "^2.13.1", + "axios": "^1.7.7", + "botbuilder-core": "4.23.2", + "botbuilder-stdlib": "4.23.2-internal", + "botframework-connector": "4.23.2", + "botframework-schema": "4.23.2", + "botframework-streaming": "4.23.2", + "dayjs": "^1.11.13", + "filenamify": "^6.0.0", + "fs-extra": "^11.2.0", + "htmlparser2": "^9.0.1", + "uuid": "^10.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/botbuilder-core": { + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/botbuilder-core/-/botbuilder-core-4.23.2.tgz", + "integrity": "sha512-GwrfkfbEJqCLnhDVc6uKlzKtrptfYTxQxHYfF22s1AxTKdTiA9vsDN9rXq8We7QUPXFOF1ylF1e87k0fQ3Sf+A==", + "license": "MIT", + "dependencies": { + "botbuilder-dialogs-adaptive-runtime-core": "4.23.2-preview", + "botbuilder-stdlib": "4.23.2-internal", + "botframework-connector": "4.23.2", + "botframework-schema": "4.23.2", + "uuid": "^10.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/botbuilder-core/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/botbuilder-dialogs-adaptive-runtime-core": { + "version": "4.23.2-preview", + "resolved": "https://registry.npmjs.org/botbuilder-dialogs-adaptive-runtime-core/-/botbuilder-dialogs-adaptive-runtime-core-4.23.2-preview.tgz", + "integrity": "sha512-+b5oHSDNodYXPnQbub+hTNmQLtBB4hj/ZW73g4Sqv5oAdqHoK/dX181UpiFAvDpHGe8Kx3SNYtRHJIj71u4t0Q==", + "license": "MIT", + "dependencies": { + "dependency-graph": "^1.0.0" + } + }, + "node_modules/botbuilder-stdlib": { + "version": "4.23.2-internal", + "resolved": "https://registry.npmjs.org/botbuilder-stdlib/-/botbuilder-stdlib-4.23.2-internal.tgz", + "integrity": "sha512-5WAu59gCZX3lz2NNw28q+IlAAFIQjXij0wXmN8qh+Tg4PQOCl+5P3hoYqcHIWtGd5Kgn+dpaHtBIewl2LaOXKQ==", + "license": "MIT" + }, + "node_modules/botbuilder/node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/botbuilder/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/botframework-connector": { + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/botframework-connector/-/botframework-connector-4.23.2.tgz", + "integrity": "sha512-G4gDpEHhA8AUKbgHMJ1LUjsuDlRPFEcXnH8ouxLI0opT2p1LcUSAAgS4hoOrkaylr04zxrUI0nEWkuWDiWDwzw==", + "license": "MIT", + "dependencies": { + "@azure/core-http": "^3.0.4", + "@azure/identity": "^4.4.1", + "@azure/msal-node": "^2.13.1", + "@types/jsonwebtoken": "9.0.6", + "axios": "^1.7.7", + "base64url": "^3.0.0", + "botbuilder-stdlib": "4.23.2-internal", + "botframework-schema": "4.23.2", + "buffer": "^6.0.3", + "cross-fetch": "^4.0.0", + "https-proxy-agent": "^7.0.5", + "jsonwebtoken": "^9.0.2", + "node-fetch": "^2.7.0", + "openssl-wrapper": "^0.3.4", + "rsa-pem-from-mod-exp": "^0.8.6", + "zod": "^3.23.8" + } + }, + "node_modules/botframework-connector/node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/botframework-connector/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/botframework-connector/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/botframework-schema": { + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/botframework-schema/-/botframework-schema-4.23.2.tgz", + "integrity": "sha512-eO1fmvfCEVJfnqNNAerQU8CHp0FMYTyE459ztNx2k1QJYMl/ds+LNNkGIUlQQFsdVbi2umadK+6hL2a9kqXMqQ==", + "license": "MIT", + "dependencies": { + "adaptivecards": "1.2.3", + "uuid": "^10.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/botframework-schema/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/botframework-streaming": { + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/botframework-streaming/-/botframework-streaming-4.23.2.tgz", + "integrity": "sha512-UBF0puC2RX8Z0dkN/ag9BuSNWdB5MUtobZLzeaH1h5t7QAYAVNk/SrsUgkBwUsqWpwaZqU+vrGOeByLShDcvaQ==", + "license": "MIT", + "dependencies": { + "@types/node": "18.19.47", + "@types/ws": "^6.0.3", + "uuid": "^10.0.0", + "ws": "^7.5.10" + } + }, + "node_modules/botframework-streaming/node_modules/@types/node": { + "version": "18.19.47", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.47.tgz", + "integrity": "sha512-1f7dB3BL/bpd9tnDJrrHb66Y+cVrhxSOTGorRNdHwYTUlTay3HuTDPKo9a/4vX9pMQkhYBcAbL4jQdNlhCFP9A==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/botframework-streaming/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/botframework-streaming/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/botframework-streaming/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", @@ -12139,6 +13282,21 @@ "uuid": "^9.0.0" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bundle-require": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-4.0.2.tgz", @@ -12154,6 +13312,18 @@ "esbuild": ">=0.17" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dev": true, + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -12494,6 +13664,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "dev": true, + "license": "MIT" + }, "node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -12563,6 +13740,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -12753,6 +13940,20 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -12791,6 +13992,15 @@ "node": ">=12.0.0" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12821,6 +14031,13 @@ "node": ">=18" } }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -12872,6 +14089,12 @@ "node": "*" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/dc-polyfill": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/dc-polyfill/-/dc-polyfill-0.1.6.tgz", @@ -12947,6 +14170,19 @@ "node": ">= 8" } }, + "node_modules/debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.0.0.tgz", + "integrity": "sha512-xRetU6gL1VJbs85Mc4FoEGSjQxzpdxRyFhe3lmWFyy2EzydIcD4xzUvRJMD+NPDfMwKNhxa3PvsIOU32luIWeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -13011,6 +14247,47 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -13029,11 +14306,15 @@ } }, "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/define-properties": { @@ -13093,6 +14374,15 @@ "node": ">= 0.8" } }, + "node_modules/dependency-graph": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", + "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -13109,9 +14399,10 @@ } }, "node_modules/detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", "engines": { "node": ">=8" } @@ -13166,6 +14457,47 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", @@ -13174,6 +14506,20 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz", @@ -13286,6 +14632,77 @@ "once": "^1.4.0" } }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/enhanced-resolve": { "version": "5.15.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", @@ -14494,6 +15911,33 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/filename-reserved-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz", + "integrity": "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/filenamify": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz", + "integrity": "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==", + "license": "MIT", + "dependencies": { + "filename-reserved-regex": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -15678,6 +17122,60 @@ ], "license": "MIT" }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/html-to-text/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -16191,6 +17689,39 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -16485,7 +18016,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.0", @@ -16718,7 +18250,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, "dependencies": { "universalify": "^2.0.0" }, @@ -16998,6 +18529,15 @@ "integrity": "sha512-8s46m/r2lSFO2+DqMxqWiJ10iiL4tuR5LC/KndV+E5//OAOzOx5s3HS5O34PJ5+kyaCA+K2oCaEPaDRfXUnQow==", "license": "MIT" }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", @@ -17383,6 +18923,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/marked": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-7.0.4.tgz", + "integrity": "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -17391,6 +18943,18 @@ "node": ">= 0.4" } }, + "node_modules/md-to-react-email": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/md-to-react-email/-/md-to-react-email-5.0.5.tgz", + "integrity": "sha512-OvAXqwq57uOk+WZqFFNCMZz8yDp8BD3WazW1wAKHUrPbbdr89K9DWS6JXY09vd9xNdPNeurI8DU/X4flcfaD8A==", + "license": "MIT", + "dependencies": { + "marked": "7.0.4" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0" + } + }, "node_modules/md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -18051,6 +19615,90 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node_modules/next": { + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz", + "integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/env": "15.2.4", + "@swc/counter": "0.1.3", + "@swc/helpers": "0.5.15", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.2.4", + "@next/swc-darwin-x64": "15.2.4", + "@next/swc-linux-arm64-gnu": "15.2.4", + "@next/swc-linux-arm64-musl": "15.2.4", + "@next/swc-linux-x64-gnu": "15.2.4", + "@next/swc-linux-x64-musl": "15.2.4", + "@next/swc-win32-arm64-msvc": "15.2.4", + "@next/swc-win32-x64-msvc": "15.2.4", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/node-abi": { "version": "3.65.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.65.0.tgz", @@ -18767,16 +20415,33 @@ } }, "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.1.tgz", + "integrity": "sha512-zy1wx4+P3PfhXSEPJNtZmJXfhkkIaxU1VauWIrDZw1O7uJRDRJtKr9n3Ic4NgbA16KyOxOXO2ng9gYwCdXuSXA==", + "license": "MIT", "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open/node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -18801,6 +20466,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/openssl-wrapper": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/openssl-wrapper/-/openssl-wrapper-0.3.4.tgz", + "integrity": "sha512-iITsrx6Ho8V3/2OVtmZzzX8wQaKAaFXEJQdzoPUZDtyf5jWFlqo+h+OhGT4TATQ47f9ACKHua8nw7Qoy85aeKQ==", + "license": "MIT" + }, "node_modules/opentracing": { "version": "0.14.7", "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", @@ -19045,6 +20716,19 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -19220,6 +20904,15 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/pg": { "version": "8.13.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", @@ -19808,11 +21501,10 @@ } }, "node_modules/prettier": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz", - "integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==", - "dev": true, - "peer": true, + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -19861,6 +21553,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/probot": { "version": "13.3.8", "resolved": "https://registry.npmjs.org/probot/-/probot-13.3.8.tgz", @@ -20416,12 +22117,817 @@ "node-gyp": "^10.2.0" } }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-email": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-4.0.7.tgz", + "integrity": "sha512-XCXlfZLKv9gHd/ZwUEhCpRGc/FJLZGYczeuG1kVR/be2PlkwEB4gjX9ARBbRFv86ncbtpOu/wI6jD6kadRyAKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "7.24.5", + "@babel/traverse": "7.25.6", + "chalk": "4.1.2", + "chokidar": "4.0.3", + "commander": "11.1.0", + "debounce": "2.0.0", + "esbuild": "0.25.0", + "glob": "10.3.4", + "log-symbols": "4.1.0", + "mime-types": "2.1.35", + "next": "15.2.4", + "normalize-path": "3.0.0", + "ora": "5.4.1", + "socket.io": "4.8.1" + }, + "bin": { + "email": "dist/cli/index.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/react-email/node_modules/@babel/parser": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", + "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==", + "dev": true, + "license": "MIT", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/react-email/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/react-email/node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/react-email/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/react-email/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/react-email/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/react-email/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/react-email/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-email/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/react-email/node_modules/esbuild": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/react-email/node_modules/glob": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.4.tgz", + "integrity": "sha512-6LFElP3A+i/Q8XQKEvZjkEWEOTgAIALR9AO2rwT8bgPhDd1anmqDJDZ6lLddI4ehxxxR1S5RIqKe1uapMQfYaQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.0.3", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/react-email/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/react-email/node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/react-email/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/react-email/node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/react-email/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/react-email/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-email/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-email/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/react-promise-suspense": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", + "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^2.0.1" + } + }, + "node_modules/react-promise-suspense/node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "license": "MIT" + }, "node_modules/readable-stream": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", @@ -20854,6 +23360,24 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==" }, + "node_modules/rsa-pem-from-mod-exp": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/rsa-pem-from-mod-exp/-/rsa-pem-from-mod-exp-0.8.6.tgz", + "integrity": "sha512-c5ouQkOvGHF1qomUUDJGFcXsomeSO2gbEs6hVhMAtlkE1CuaZase/WzoaKFG/EZQuNmq6pw/EMCeEnDvOgCJYQ==", + "license": "MIT" + }, + "node_modules/run-applescript": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -20979,6 +23503,12 @@ "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, "node_modules/scim-patch": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/scim-patch/-/scim-patch-0.8.3.tgz", @@ -21007,18 +23537,28 @@ "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==" }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semifies": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==" }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -21178,6 +23718,62 @@ "sha.js": "bin.js" } }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/sharp/node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -21550,6 +24146,126 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/socks": { "version": "2.8.4", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", @@ -21728,15 +24444,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stoppable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", - "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", - "engines": { - "node": ">=4", - "npm": ">=6" - } - }, "node_modules/stream-events": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", @@ -21805,6 +24512,15 @@ "node": ">=4.0.0" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -22000,6 +24716,30 @@ "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", "license": "MIT" }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/sucrase": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", @@ -23520,7 +26260,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "engines": { "node": ">= 10.0.0" } @@ -24403,6 +27142,16 @@ "node": ">=18" } }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -24951,9 +27700,10 @@ } }, "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/backend/package.json b/backend/package.json index 5db1dffe0..c19d30441 100644 --- a/backend/package.json +++ b/backend/package.json @@ -72,7 +72,8 @@ "seed:new": "tsx ./scripts/create-seed-file.ts", "seed": "knex --knexfile ./dist/db/knexfile.ts --client pg seed:run", "seed-dev": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run", - "db:reset": "npm run migration:rollback -- --all && npm run migration:latest" + "db:reset": "npm run migration:rollback -- --all && npm run migration:latest", + "email:dev": "email dev --dir src/services/smtp/emails" }, "keywords": [], "author": "", @@ -96,6 +97,7 @@ "@types/picomatch": "^2.3.3", "@types/pkcs11js": "^1.0.4", "@types/prompt-sync": "^4.2.3", + "@types/react": "^19.1.2", "@types/resolve": "^1.20.6", "@types/safe-regex": "^1.1.6", "@types/sjcl": "^1.0.34", @@ -115,6 +117,7 @@ "nodemon": "^3.0.2", "pino-pretty": "^10.2.3", "prompt-sync": "^4.2.0", + "react-email": "4.0.7", "rimraf": "^5.0.5", "ts-node": "^10.9.2", "tsc-alias": "^1.8.8", @@ -164,6 +167,7 @@ "@opentelemetry/semantic-conventions": "^1.27.0", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/x509": "^1.12.1", + "@react-email/components": "0.0.36", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.2", @@ -175,6 +179,7 @@ "axios": "^1.6.7", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", + "botbuilder": "^4.23.2", "bullmq": "^5.4.2", "cassandra-driver": "^4.7.2", "connect-redis": "^7.1.1", @@ -222,6 +227,8 @@ "posthog-node": "^3.6.2", "probot": "^13.3.8", "re2": "^1.21.4", + "react": "19.1.0", + "react-dom": "19.1.0", "safe-regex": "^2.1.1", "scim-patch": "^0.8.3", "scim2-parse-filter": "^0.2.10", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 34d816b9b..6ec542c6b 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -41,6 +41,7 @@ import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/ import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; import { TSshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service"; +import { TSshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service"; import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; @@ -71,6 +72,7 @@ import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-a import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; +import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; @@ -213,6 +215,7 @@ declare module "fastify" { sshCertificateAuthority: TSshCertificateAuthorityServiceFactory; sshCertificateTemplate: TSshCertificateTemplateServiceFactory; sshHost: TSshHostServiceFactory; + sshHostGroup: TSshHostGroupServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; @@ -246,6 +249,7 @@ declare module "fastify" { kmipOperation: TKmipOperationServiceFactory; gateway: TGatewayServiceFactory; secretRotationV2: TSecretRotationV2ServiceFactory; + microsoftTeams: TMicrosoftTeamsServiceFactory; assumePrivileges: TAssumePrivilegeServiceFactory; githubOrgSync: TGithubOrgSyncServiceFactory; }; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 091199938..13f3bc306 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -386,6 +386,12 @@ import { TSshCertificateTemplates, TSshCertificateTemplatesInsert, TSshCertificateTemplatesUpdate, + TSshHostGroupMemberships, + TSshHostGroupMembershipsInsert, + TSshHostGroupMembershipsUpdate, + TSshHostGroups, + TSshHostGroupsInsert, + TSshHostGroupsUpdate, TSshHostLoginUserMappings, TSshHostLoginUserMappingsInsert, TSshHostLoginUserMappingsUpdate, @@ -426,6 +432,16 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { + TMicrosoftTeamsIntegrations, + TMicrosoftTeamsIntegrationsInsert, + TMicrosoftTeamsIntegrationsUpdate +} from "@app/db/schemas/microsoft-teams-integrations"; +import { + TProjectMicrosoftTeamsConfigs, + TProjectMicrosoftTeamsConfigsInsert, + TProjectMicrosoftTeamsConfigsUpdate +} from "@app/db/schemas/project-microsoft-teams-configs"; import { TSecretReminderRecipients, TSecretReminderRecipientsInsert, @@ -445,6 +461,16 @@ declare module "knex/types/tables" { interface Tables { [TableName.Users]: KnexOriginal.CompositeTableType; [TableName.Groups]: KnexOriginal.CompositeTableType; + [TableName.SshHostGroup]: KnexOriginal.CompositeTableType< + TSshHostGroups, + TSshHostGroupsInsert, + TSshHostGroupsUpdate + >; + [TableName.SshHostGroupMembership]: KnexOriginal.CompositeTableType< + TSshHostGroupMemberships, + TSshHostGroupMembershipsInsert, + TSshHostGroupMembershipsUpdate + >; [TableName.SshHost]: KnexOriginal.CompositeTableType; [TableName.SshCertificateAuthority]: KnexOriginal.CompositeTableType< TSshCertificateAuthorities, @@ -1002,6 +1028,16 @@ declare module "knex/types/tables" { TSecretRotationV2SecretMappingsInsert, TSecretRotationV2SecretMappingsUpdate >; + [TableName.MicrosoftTeamsIntegrations]: KnexOriginal.CompositeTableType< + TMicrosoftTeamsIntegrations, + TMicrosoftTeamsIntegrationsInsert, + TMicrosoftTeamsIntegrationsUpdate + >; + [TableName.ProjectMicrosoftTeamsConfigs]: KnexOriginal.CompositeTableType< + TProjectMicrosoftTeamsConfigs, + TProjectMicrosoftTeamsConfigsInsert, + TProjectMicrosoftTeamsConfigsUpdate + >; [TableName.SecretReminderRecipients]: KnexOriginal.CompositeTableType< TSecretReminderRecipients, TSecretReminderRecipientsInsert, diff --git a/backend/src/db/migrations/20250422125635_microsoft-teams-workflow-integration.ts b/backend/src/db/migrations/20250422125635_microsoft-teams-workflow-integration.ts new file mode 100644 index 000000000..ed1b9333c --- /dev/null +++ b/backend/src/db/migrations/20250422125635_microsoft-teams-workflow-integration.ts @@ -0,0 +1,130 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const superAdminHasEncryptedMicrosoftTeamsClientIdColumn = await knex.schema.hasColumn( + TableName.SuperAdmin, + "encryptedMicrosoftTeamsAppId" + ); + const superAdminHasEncryptedMicrosoftTeamsClientSecret = await knex.schema.hasColumn( + TableName.SuperAdmin, + "encryptedMicrosoftTeamsClientSecret" + ); + const superAdminHasEncryptedMicrosoftTeamsBotId = await knex.schema.hasColumn( + TableName.SuperAdmin, + "encryptedMicrosoftTeamsBotId" + ); + + if ( + !superAdminHasEncryptedMicrosoftTeamsClientIdColumn || + !superAdminHasEncryptedMicrosoftTeamsClientSecret || + !superAdminHasEncryptedMicrosoftTeamsBotId + ) { + await knex.schema.alterTable(TableName.SuperAdmin, (table) => { + if (!superAdminHasEncryptedMicrosoftTeamsClientIdColumn) { + table.binary("encryptedMicrosoftTeamsAppId").nullable(); + } + if (!superAdminHasEncryptedMicrosoftTeamsClientSecret) { + table.binary("encryptedMicrosoftTeamsClientSecret").nullable(); + } + if (!superAdminHasEncryptedMicrosoftTeamsBotId) { + table.binary("encryptedMicrosoftTeamsBotId").nullable(); + } + }); + } + + if (!(await knex.schema.hasColumn(TableName.WorkflowIntegrations, "status"))) { + await knex.schema.alterTable(TableName.WorkflowIntegrations, (table) => { + table.enu("status", ["pending", "installed", "failed"]).notNullable().defaultTo("installed"); // defaults to installed so we can have backwards compatibility with existing workflow integrations + }); + } + + if (!(await knex.schema.hasTable(TableName.MicrosoftTeamsIntegrations))) { + await knex.schema.createTable(TableName.MicrosoftTeamsIntegrations, (table) => { + table.uuid("id", { primaryKey: true }).notNullable(); + table.foreign("id").references("id").inTable(TableName.WorkflowIntegrations).onDelete("CASCADE"); // the ID itself is the workflow integration ID + + table.string("internalTeamsAppId").nullable(); + table.string("tenantId").notNullable(); + table.binary("encryptedAccessToken").nullable(); + table.binary("encryptedBotAccessToken").nullable(); + + table.timestamp("accessTokenExpiresAt").nullable(); + table.timestamp("botAccessTokenExpiresAt").nullable(); + + table.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.MicrosoftTeamsIntegrations); + } + + if (!(await knex.schema.hasTable(TableName.ProjectMicrosoftTeamsConfigs))) { + await knex.schema.createTable(TableName.ProjectMicrosoftTeamsConfigs, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.string("projectId").notNullable().unique(); + tb.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + tb.uuid("microsoftTeamsIntegrationId").notNullable(); + tb.foreign("microsoftTeamsIntegrationId") + .references("id") + .inTable(TableName.MicrosoftTeamsIntegrations) + .onDelete("CASCADE"); + tb.boolean("isAccessRequestNotificationEnabled").notNullable().defaultTo(false); + tb.boolean("isSecretRequestNotificationEnabled").notNullable().defaultTo(false); + + tb.jsonb("accessRequestChannels").notNullable(); // {teamId: string, channelIds: string[]} + tb.jsonb("secretRequestChannels").notNullable(); // {teamId: string, channelIds: string[]} + tb.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.ProjectMicrosoftTeamsConfigs); + } +} + +export async function down(knex: Knex): Promise { + const hasEncryptedMicrosoftTeamsClientIdColumn = await knex.schema.hasColumn( + TableName.SuperAdmin, + "encryptedMicrosoftTeamsAppId" + ); + const hasEncryptedMicrosoftTeamsClientSecret = await knex.schema.hasColumn( + TableName.SuperAdmin, + "encryptedMicrosoftTeamsClientSecret" + ); + const hasEncryptedMicrosoftTeamsBotId = await knex.schema.hasColumn( + TableName.SuperAdmin, + "encryptedMicrosoftTeamsBotId" + ); + + if ( + hasEncryptedMicrosoftTeamsClientIdColumn || + hasEncryptedMicrosoftTeamsClientSecret || + hasEncryptedMicrosoftTeamsBotId + ) { + await knex.schema.alterTable(TableName.SuperAdmin, (table) => { + if (hasEncryptedMicrosoftTeamsClientIdColumn) { + table.dropColumn("encryptedMicrosoftTeamsAppId"); + } + if (hasEncryptedMicrosoftTeamsClientSecret) { + table.dropColumn("encryptedMicrosoftTeamsClientSecret"); + } + if (hasEncryptedMicrosoftTeamsBotId) { + table.dropColumn("encryptedMicrosoftTeamsBotId"); + } + }); + } + if (await knex.schema.hasColumn(TableName.WorkflowIntegrations, "status")) { + await knex.schema.alterTable(TableName.WorkflowIntegrations, (table) => { + table.dropColumn("status"); + }); + } + + if (await knex.schema.hasTable(TableName.ProjectMicrosoftTeamsConfigs)) { + await knex.schema.dropTableIfExists(TableName.ProjectMicrosoftTeamsConfigs); + await dropOnUpdateTrigger(knex, TableName.ProjectMicrosoftTeamsConfigs); + } + if (await knex.schema.hasTable(TableName.MicrosoftTeamsIntegrations)) { + await knex.schema.dropTableIfExists(TableName.MicrosoftTeamsIntegrations); + await dropOnUpdateTrigger(knex, TableName.MicrosoftTeamsIntegrations); + } +} diff --git a/backend/src/db/migrations/20250428134716_add-org-user-token-expiration-setting.ts b/backend/src/db/migrations/20250428134716_add-org-user-token-expiration-setting.ts new file mode 100644 index 000000000..3f24e4f2c --- /dev/null +++ b/backend/src/db/migrations/20250428134716_add-org-user-token-expiration-setting.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { getConfig } from "@app/lib/config/env"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const appCfg = getConfig(); + const tokenDuration = appCfg?.JWT_REFRESH_LIFETIME; + + if (!(await knex.schema.hasColumn(TableName.Organization, "userTokenExpiration"))) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.string("userTokenExpiration"); + }); + if (tokenDuration) { + await knex(TableName.Organization).update({ userTokenExpiration: tokenDuration }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Organization, "userTokenExpiration")) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("userTokenExpiration"); + }); + } +} diff --git a/backend/src/db/migrations/20250428173025_ssh-host-groups.ts b/backend/src/db/migrations/20250428173025_ssh-host-groups.ts new file mode 100644 index 000000000..6bac07ae6 --- /dev/null +++ b/backend/src/db/migrations/20250428173025_ssh-host-groups.ts @@ -0,0 +1,55 @@ +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.SshHostGroup))) { + await knex.schema.createTable(TableName.SshHostGroup, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("name").notNullable(); + t.unique(["projectId", "name"]); + }); + await createOnUpdateTrigger(knex, TableName.SshHostGroup); + } + + if (!(await knex.schema.hasTable(TableName.SshHostGroupMembership))) { + await knex.schema.createTable(TableName.SshHostGroupMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshHostGroupId").notNullable(); + t.foreign("sshHostGroupId").references("id").inTable(TableName.SshHostGroup).onDelete("CASCADE"); + t.uuid("sshHostId").notNullable(); + t.foreign("sshHostId").references("id").inTable(TableName.SshHost).onDelete("CASCADE"); + t.unique(["sshHostGroupId", "sshHostId"]); + }); + await createOnUpdateTrigger(knex, TableName.SshHostGroupMembership); + } + + const hasGroupColumn = await knex.schema.hasColumn(TableName.SshHostLoginUser, "sshHostGroupId"); + if (!hasGroupColumn) { + await knex.schema.alterTable(TableName.SshHostLoginUser, (t) => { + t.uuid("sshHostGroupId").nullable(); + t.foreign("sshHostGroupId").references("id").inTable(TableName.SshHostGroup).onDelete("CASCADE"); + t.uuid("sshHostId").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasGroupColumn = await knex.schema.hasColumn(TableName.SshHostLoginUser, "sshHostGroupId"); + if (hasGroupColumn) { + await knex.schema.alterTable(TableName.SshHostLoginUser, (t) => { + t.dropColumn("sshHostGroupId"); + }); + } + + await knex.schema.dropTableIfExists(TableName.SshHostGroupMembership); + await dropOnUpdateTrigger(knex, TableName.SshHostGroupMembership); + + await knex.schema.dropTableIfExists(TableName.SshHostGroup); + await dropOnUpdateTrigger(knex, TableName.SshHostGroup); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 7ccd71376..b71d51908 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -58,6 +58,7 @@ export * from "./kms-keys"; export * from "./kms-root-config"; export * from "./ldap-configs"; export * from "./ldap-group-maps"; +export * from "./microsoft-teams-integrations"; export * from "./models"; export * from "./oidc-configs"; export * from "./org-bots"; @@ -127,6 +128,8 @@ export * from "./ssh-certificate-authority-secrets"; export * from "./ssh-certificate-bodies"; export * from "./ssh-certificate-templates"; export * from "./ssh-certificates"; +export * from "./ssh-host-group-memberships"; +export * from "./ssh-host-groups"; export * from "./ssh-host-login-user-mappings"; export * from "./ssh-host-login-users"; export * from "./ssh-hosts"; diff --git a/backend/src/db/schemas/microsoft-teams-integrations.ts b/backend/src/db/schemas/microsoft-teams-integrations.ts new file mode 100644 index 000000000..36aae5f78 --- /dev/null +++ b/backend/src/db/schemas/microsoft-teams-integrations.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const MicrosoftTeamsIntegrationsSchema = z.object({ + id: z.string().uuid(), + internalTeamsAppId: z.string().nullable().optional(), + tenantId: z.string(), + encryptedAccessToken: zodBuffer.nullable().optional(), + encryptedBotAccessToken: zodBuffer.nullable().optional(), + accessTokenExpiresAt: z.date().nullable().optional(), + botAccessTokenExpiresAt: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TMicrosoftTeamsIntegrations = z.infer; +export type TMicrosoftTeamsIntegrationsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TMicrosoftTeamsIntegrationsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index dd23c26da..7fd77da6c 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,8 @@ import { z } from "zod"; export enum TableName { Users = "users", + SshHostGroup = "ssh_host_groups", + SshHostGroupMembership = "ssh_host_group_memberships", SshHost = "ssh_hosts", SshHostLoginUser = "ssh_host_login_users", SshHostLoginUserMapping = "ssh_host_login_user_mappings", @@ -147,6 +149,8 @@ export enum TableName { KmipClientCertificates = "kmip_client_certificates", SecretRotationV2 = "secret_rotations_v2", SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings", + MicrosoftTeamsIntegrations = "microsoft_teams_integrations", + ProjectMicrosoftTeamsConfigs = "project_microsoft_teams_configs", SecretReminderRecipients = "secret_reminder_recipients", GithubOrgSyncConfig = "github_org_sync_configs" } diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index eea1808e0..8d8279802 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -27,7 +27,8 @@ export const OrganizationsSchema = z.object({ shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), privilegeUpgradeInitiatedAt: z.date().nullable().optional(), - bypassOrgAuthEnabled: z.boolean().default(false) + bypassOrgAuthEnabled: z.boolean().default(false), + userTokenExpiration: z.string().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/db/schemas/project-microsoft-teams-configs.ts b/backend/src/db/schemas/project-microsoft-teams-configs.ts new file mode 100644 index 000000000..27d0f7ef3 --- /dev/null +++ b/backend/src/db/schemas/project-microsoft-teams-configs.ts @@ -0,0 +1,29 @@ +// 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 ProjectMicrosoftTeamsConfigsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + microsoftTeamsIntegrationId: z.string().uuid(), + isAccessRequestNotificationEnabled: z.boolean().default(false), + isSecretRequestNotificationEnabled: z.boolean().default(false), + accessRequestChannels: z.unknown(), + secretRequestChannels: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProjectMicrosoftTeamsConfigs = z.infer; +export type TProjectMicrosoftTeamsConfigsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TProjectMicrosoftTeamsConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-host-group-memberships.ts b/backend/src/db/schemas/ssh-host-group-memberships.ts new file mode 100644 index 000000000..80a891e07 --- /dev/null +++ b/backend/src/db/schemas/ssh-host-group-memberships.ts @@ -0,0 +1,22 @@ +// 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 SshHostGroupMembershipsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshHostGroupId: z.string().uuid(), + sshHostId: z.string().uuid() +}); + +export type TSshHostGroupMemberships = z.infer; +export type TSshHostGroupMembershipsInsert = Omit, TImmutableDBKeys>; +export type TSshHostGroupMembershipsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-host-groups.ts b/backend/src/db/schemas/ssh-host-groups.ts new file mode 100644 index 000000000..5476e7fa1 --- /dev/null +++ b/backend/src/db/schemas/ssh-host-groups.ts @@ -0,0 +1,20 @@ +// 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 SshHostGroupsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + projectId: z.string(), + name: z.string() +}); + +export type TSshHostGroups = z.infer; +export type TSshHostGroupsInsert = Omit, TImmutableDBKeys>; +export type TSshHostGroupsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ssh-host-login-users.ts b/backend/src/db/schemas/ssh-host-login-users.ts index 62454d3c9..6060db903 100644 --- a/backend/src/db/schemas/ssh-host-login-users.ts +++ b/backend/src/db/schemas/ssh-host-login-users.ts @@ -11,8 +11,9 @@ export const SshHostLoginUsersSchema = z.object({ id: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - sshHostId: z.string().uuid(), - loginUser: z.string() + sshHostId: z.string().uuid().nullable().optional(), + loginUser: z.string(), + sshHostGroupId: z.string().uuid().nullable().optional() }); export type TSshHostLoginUsers = z.infer; diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 01aac280b..ec35042ad 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -26,7 +26,10 @@ export const SuperAdminSchema = z.object({ encryptedSlackClientSecret: zodBuffer.nullable().optional(), authConsentContent: z.string().nullable().optional(), pageFrameContent: z.string().nullable().optional(), - adminIdentityIds: z.string().array().nullable().optional() + adminIdentityIds: z.string().array().nullable().optional(), + encryptedMicrosoftTeamsAppId: zodBuffer.nullable().optional(), + encryptedMicrosoftTeamsClientSecret: zodBuffer.nullable().optional(), + encryptedMicrosoftTeamsBotId: zodBuffer.nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/db/schemas/workflow-integrations.ts b/backend/src/db/schemas/workflow-integrations.ts index ae1ae9a25..cab02fced 100644 --- a/backend/src/db/schemas/workflow-integrations.ts +++ b/backend/src/db/schemas/workflow-integrations.ts @@ -14,7 +14,8 @@ export const WorkflowIntegrationsSchema = z.object({ orgId: z.string().uuid(), description: z.string().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + status: z.string().default("installed") }); export type TWorkflowIntegrations = z.infer; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index a88ebf258..0b8c78586 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -34,6 +34,7 @@ import { registerSnapshotRouter } from "./snapshot-router"; import { registerSshCaRouter } from "./ssh-certificate-authority-router"; import { registerSshCertRouter } from "./ssh-certificate-router"; import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router"; +import { registerSshHostGroupRouter } from "./ssh-host-group-router"; import { registerSshHostRouter } from "./ssh-host-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; @@ -88,6 +89,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await sshRouter.register(registerSshCertRouter, { prefix: "/certificates" }); await sshRouter.register(registerSshCertificateTemplateRouter, { prefix: "/certificate-templates" }); await sshRouter.register(registerSshHostRouter, { prefix: "/hosts" }); + await sshRouter.register(registerSshHostGroupRouter, { prefix: "/host-groups" }); }, { prefix: "/ssh" } ); diff --git a/backend/src/ee/routes/v1/ssh-host-group-router.ts b/backend/src/ee/routes/v1/ssh-host-group-router.ts new file mode 100644 index 000000000..c6f35c7e1 --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-host-group-router.ts @@ -0,0 +1,360 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema"; +import { EHostGroupMembershipFilter } from "@app/ee/services/ssh-host-group/ssh-host-group-types"; +import { ApiDocsTags, SSH_HOST_GROUPS } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:sshHostGroupId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Get SSH Host Group", + params: z.object({ + sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.GET.sshHostGroupId) + }), + response: { + 200: sanitizedSshHostGroup.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshHostGroup = await server.services.sshHostGroup.getSshHostGroup({ + sshHostGroupId: req.params.sshHostGroupId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.GET_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + name: sshHostGroup.name + } + } + }); + + return sshHostGroup; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Create SSH Host Group", + body: z.object({ + projectId: z.string().describe(SSH_HOST_GROUPS.CREATE.projectId), + name: slugSchema({ min: 1, max: 64, field: "name" }).describe(SSH_HOST_GROUPS.CREATE.name), + loginMappings: z.array(loginMappingSchema).default([]).describe(SSH_HOST_GROUPS.CREATE.loginMappings) + }), + response: { + 200: sanitizedSshHostGroup.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshHostGroup = await server.services.sshHostGroup.createSshHostGroup({ + ...req.body, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.CREATE_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + name: sshHostGroup.name, + loginMappings: sshHostGroup.loginMappings + } + } + }); + + return sshHostGroup; + } + }); + + server.route({ + method: "PATCH", + url: "/:sshHostGroupId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Update SSH Host Group", + params: z.object({ + sshHostGroupId: z.string().trim().describe(SSH_HOST_GROUPS.UPDATE.sshHostGroupId) + }), + body: z.object({ + name: slugSchema({ min: 1, max: 64, field: "name" }).describe(SSH_HOST_GROUPS.UPDATE.name).optional(), + loginMappings: z.array(loginMappingSchema).optional().describe(SSH_HOST_GROUPS.UPDATE.loginMappings) + }), + response: { + 200: sanitizedSshHostGroup.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + handler: async (req) => { + const sshHostGroup = await server.services.sshHostGroup.updateSshHostGroup({ + sshHostGroupId: req.params.sshHostGroupId, + ...req.body, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.UPDATE_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + name: sshHostGroup.name, + loginMappings: sshHostGroup.loginMappings + } + } + }); + + return sshHostGroup; + } + }); + + server.route({ + method: "DELETE", + url: "/:sshHostGroupId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Delete SSH Host Group", + params: z.object({ + sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.DELETE.sshHostGroupId) + }), + response: { + 200: sanitizedSshHostGroup.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshHostGroup = await server.services.sshHostGroup.deleteSshHostGroup({ + sshHostGroupId: req.params.sshHostGroupId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.DELETE_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + name: sshHostGroup.name + } + } + }); + + return sshHostGroup; + } + }); + + server.route({ + method: "GET", + url: "/:sshHostGroupId/hosts", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Get SSH Hosts in a Host Group", + params: z.object({ + sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.GET.sshHostGroupId) + }), + querystring: z.object({ + filter: z.nativeEnum(EHostGroupMembershipFilter).optional().describe(SSH_HOST_GROUPS.GET.filter) + }), + response: { + 200: z.object({ + hosts: sanitizedSshHost + .pick({ + id: true, + hostname: true, + alias: true + }) + .merge( + z.object({ + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() + }) + ) + .array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { sshHostGroup, hosts, totalCount } = await server.services.sshHostGroup.listSshHostGroupHosts({ + sshHostGroupId: req.params.sshHostGroupId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.GET_SSH_HOST_GROUP_HOSTS, + metadata: { + sshHostGroupId: req.params.sshHostGroupId, + name: sshHostGroup.name + } + } + }); + + return { hosts, totalCount }; + } + }); + + server.route({ + method: "POST", + url: "/:sshHostGroupId/hosts/:hostId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Add an SSH Host to a Host Group", + params: z.object({ + sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.ADD_HOST.sshHostGroupId), + hostId: z.string().describe(SSH_HOST_GROUPS.ADD_HOST.hostId) + }), + response: { + 200: sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { sshHostGroup, sshHost } = await server.services.sshHostGroup.addHostToSshHostGroup({ + sshHostGroupId: req.params.sshHostGroupId, + hostId: req.params.hostId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHost.projectId, + event: { + type: EventType.ADD_HOST_TO_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + sshHostId: sshHost.id, + hostname: sshHost.hostname + } + } + }); + + return sshHost; + } + }); + + server.route({ + method: "DELETE", + url: "/:sshHostGroupId/hosts/:hostId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SshHostGroups], + description: "Remove an SSH Host from a Host Group", + params: z.object({ + sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.DELETE_HOST.sshHostGroupId), + hostId: z.string().describe(SSH_HOST_GROUPS.DELETE_HOST.hostId) + }), + response: { + 200: sanitizedSshHost.extend({ + loginMappings: z.array(loginMappingSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { sshHostGroup, sshHost } = await server.services.sshHostGroup.removeHostFromSshHostGroup({ + sshHostGroupId: req.params.sshHostGroupId, + hostId: req.params.hostId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHost.projectId, + event: { + type: EventType.REMOVE_HOST_FROM_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + sshHostId: sshHost.id, + hostname: sshHost.hostname + } + } + }); + + return sshHost; + } + }); +}; diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts index 9db642d4d..93748c27f 100644 --- a/backend/src/ee/routes/v1/ssh-host-router.ts +++ b/backend/src/ee/routes/v1/ssh-host-router.ts @@ -3,8 +3,9 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { LoginMappingSource } from "@app/ee/services/ssh-host/ssh-host-types"; import { isValidHostname } from "@app/ee/services/ssh-host/ssh-host-validators"; -import { SSH_HOSTS } from "@app/lib/api-docs"; +import { ApiDocsTags, SSH_HOSTS } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { publicSshCaLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; @@ -21,10 +22,16 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], response: { 200: z.array( sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) ) } @@ -49,12 +56,18 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], params: z.object({ sshHostId: z.string().describe(SSH_HOSTS.GET.sshHostId) }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) } }, @@ -91,7 +104,9 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { - description: "Add an SSH Host", + hide: false, + tags: [ApiDocsTags.SshHosts], + description: "Register SSH Host", body: z.object({ projectId: z.string().describe(SSH_HOSTS.CREATE.projectId), hostname: z @@ -119,7 +134,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) } }, @@ -163,6 +182,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Update SSH Host", params: z.object({ sshHostId: z.string().trim().describe(SSH_HOSTS.UPDATE.sshHostId) @@ -192,7 +213,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) } }, @@ -235,12 +260,19 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], + description: "Delete SSH Host", params: z.object({ sshHostId: z.string().describe(SSH_HOSTS.DELETE.sshHostId) }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) } }, @@ -278,6 +310,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Issue SSH certificate for user", params: z.object({ sshHostId: z.string().describe(SSH_HOSTS.ISSUE_SSH_CREDENTIALS.sshHostId) @@ -350,6 +384,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Issue SSH certificate for host", params: z.object({ sshHostId: z.string().describe(SSH_HOSTS.ISSUE_HOST_CERT.sshHostId) @@ -414,6 +450,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: publicSshCaLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Get public key of the user SSH CA linked to the host", params: z.object({ sshHostId: z.string().trim().describe(SSH_HOSTS.GET_USER_CA_PUBLIC_KEY.sshHostId) @@ -435,6 +473,8 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { rateLimit: publicSshCaLimit }, schema: { + hide: false, + tags: [ApiDocsTags.SshHosts], description: "Get public key of the host SSH CA linked to the host", params: z.object({ sshHostId: z.string().trim().describe(SSH_HOSTS.GET_HOST_CA_PUBLIC_KEY.sshHostId) diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/azure-client-secret-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/azure-client-secret-rotation-router.ts new file mode 100644 index 000000000..d8ccbc12c --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/azure-client-secret-rotation-router.ts @@ -0,0 +1,19 @@ +import { + AzureClientSecretRotationGeneratedCredentialsSchema, + AzureClientSecretRotationSchema, + CreateAzureClientSecretRotationSchema, + UpdateAzureClientSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/azure-client-secret"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerAzureClientSecretRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.AzureClientSecret, + server, + responseSchema: AzureClientSecretRotationSchema, + createSchema: CreateAzureClientSecretRotationSchema, + updateSchema: UpdateAzureClientSecretRotationSchema, + generatedCredentialsSchema: AzureClientSecretRotationGeneratedCredentialsSchema + }); diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts index 3dcdac30b..90edc1306 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts @@ -2,6 +2,7 @@ import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotat import { registerAuth0ClientSecretRotationRouter } from "./auth0-client-secret-rotation-router"; import { registerAwsIamUserSecretRotationRouter } from "./aws-iam-user-secret-rotation-router"; +import { registerAzureClientSecretRotationRouter } from "./azure-client-secret-rotation-router"; import { registerLdapPasswordRotationRouter } from "./ldap-password-rotation-router"; import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router"; import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; @@ -15,6 +16,7 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< [SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter, [SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter, [SecretRotation.Auth0ClientSecret]: registerAuth0ClientSecretRotationRouter, - [SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter, - [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter + [SecretRotation.AzureClientSecret]: registerAzureClientSecretRotationRouter, + [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter, + [SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter }; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts index 772f70035..298f2c412 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { Auth0ClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret"; import { AwsIamUserSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/aws-iam-user-secret"; +import { AzureClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; import { LdapPasswordRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; @@ -16,8 +17,9 @@ const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ PostgresCredentialsRotationListItemSchema, MsSqlCredentialsRotationListItemSchema, Auth0ClientSecretRotationListItemSchema, - LdapPasswordRotationListItemSchema, - AwsIamUserSecretRotationListItemSchema + AzureClientSecretRotationListItemSchema, + AwsIamUserSecretRotationListItemSchema, + LdapPasswordRotationListItemSchema ]); export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 3606b4bdc..2b2758b2e 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -6,13 +6,15 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification"; +import { TriggerFeature } from "@app/lib/workflow-integrations/types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { TProjectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; 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 { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; -import { triggerSlackNotification } from "@app/services/slack/slack-fns"; -import { SlackTriggerFeature } from "@app/services/slack/slack-types"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -67,6 +69,8 @@ type TSecretApprovalRequestServiceFactoryDep = { >; kmsService: Pick; projectSlackConfigDAL: Pick; + microsoftTeamsService: Pick; + projectMicrosoftTeamsConfigDAL: Pick; }; export type TAccessApprovalRequestServiceFactory = ReturnType; @@ -84,6 +88,8 @@ export const accessApprovalRequestServiceFactory = ({ smtpService, userDAL, kmsService, + microsoftTeamsService, + projectMicrosoftTeamsConfigDAL, projectSlackConfigDAL }: TSecretApprovalRequestServiceFactoryDep) => { const createAccessApprovalRequest = async ({ @@ -219,24 +225,30 @@ export const accessApprovalRequestServiceFactory = ({ const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; const approvalUrl = `${cfg.SITE_URL}/secret-manager/${project.id}/approval`; - await triggerSlackNotification({ - projectId: project.id, - projectSlackConfigDAL, - projectDAL, - kmsService, - notification: { - type: SlackTriggerFeature.ACCESS_REQUEST, - payload: { - projectName: project.name, - requesterFullName, - isTemporary, - requesterEmail: requestedByUser.email as string, - secretPath, - environment: envSlug, - permissions: accessTypes, - approvalUrl, - note - } + await triggerWorkflowIntegrationNotification({ + input: { + notification: { + type: TriggerFeature.ACCESS_REQUEST, + payload: { + projectName: project.name, + requesterFullName, + isTemporary, + requesterEmail: requestedByUser.email as string, + secretPath, + environment: envSlug, + permissions: accessTypes, + approvalUrl, + note + } + }, + projectId: project.id + }, + dependencies: { + projectDAL, + projectSlackConfigDAL, + kmsService, + microsoftTeamsService, + projectMicrosoftTeamsConfigDAL } }); 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 93d3f03c3..d7cad74be 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -12,6 +12,7 @@ import { import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; +import { TLoginMapping } from "@app/ee/services/ssh-host/ssh-host-types"; import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign/types"; import { TProjectPermission } from "@app/lib/types"; @@ -29,6 +30,7 @@ import { TSecretSyncRaw, TUpdateSecretSyncDTO } from "@app/services/secret-sync/secret-sync-types"; +import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types"; import { KmipPermission } from "../kmip/kmip-enum"; import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types"; @@ -191,12 +193,19 @@ export enum EventType { UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template", DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template", GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template", + GET_SSH_HOST = "get-ssh-host", CREATE_SSH_HOST = "create-ssh-host", UPDATE_SSH_HOST = "update-ssh-host", DELETE_SSH_HOST = "delete-ssh-host", - GET_SSH_HOST = "get-ssh-host", ISSUE_SSH_HOST_USER_CERT = "issue-ssh-host-user-cert", ISSUE_SSH_HOST_HOST_CERT = "issue-ssh-host-host-cert", + GET_SSH_HOST_GROUP = "get-ssh-host-group", + CREATE_SSH_HOST_GROUP = "create-ssh-host-group", + UPDATE_SSH_HOST_GROUP = "update-ssh-host-group", + DELETE_SSH_HOST_GROUP = "delete-ssh-host-group", + GET_SSH_HOST_GROUP_HOSTS = "get-ssh-host-group-hosts", + ADD_HOST_TO_SSH_HOST_GROUP = "add-host-to-ssh-host-group", + REMOVE_HOST_FROM_SSH_HOST_GROUP = "remove-host-from-ssh-host-group", CREATE_CA = "create-certificate-authority", GET_CA = "get-certificate-authority", UPDATE_CA = "update-certificate-authority", @@ -246,11 +255,14 @@ export enum EventType { GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", + GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", + UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", GET_SLACK_INTEGRATION = "get-slack-integration", UPDATE_SLACK_INTEGRATION = "update-slack-integration", DELETE_SLACK_INTEGRATION = "delete-slack-integration", - GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", - UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", + GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG = "get-project-workflow-integration-config", + UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG = "update-project-workflow-integration-config", + GET_PROJECT_SSH_CONFIG = "get-project-ssh-config", UPDATE_PROJECT_SSH_CONFIG = "update-project-ssh-config", INTEGRATION_SYNCED = "integration-synced", @@ -323,6 +335,15 @@ export enum EventType { SECRET_ROTATION_ROTATE_SECRETS = "secret-rotation-rotate-secrets", PROJECT_ACCESS_REQUEST = "project-access-request", + + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CREATE = "microsoft-teams-workflow-integration-create", + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_DELETE = "microsoft-teams-workflow-integration-delete", + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_UPDATE = "microsoft-teams-workflow-integration-update", + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CHECK_INSTALLATION_STATUS = "microsoft-teams-workflow-integration-check-installation-status", + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET_TEAMS = "microsoft-teams-workflow-integration-get-teams", + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET = "microsoft-teams-workflow-integration-get", + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list", + PROJECT_ASSUME_PRIVILEGE_SESSION_START = "project-assume-privileges-session-start", PROJECT_ASSUME_PRIVILEGE_SESSION_END = "project-assume-privileges-session-end" } @@ -1501,12 +1522,7 @@ interface CreateSshHost { alias: string | null; userCertTtl: string; hostCertTtl: string; - loginMappings: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings: TLoginMapping[]; userSshCaId: string; hostSshCaId: string; }; @@ -1520,12 +1536,7 @@ interface UpdateSshHost { alias?: string | null; userCertTtl?: string; hostCertTtl?: string; - loginMappings?: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings?: TLoginMapping[]; userSshCaId?: string; hostSshCaId?: string; }; @@ -1569,6 +1580,66 @@ interface IssueSshHostHostCert { }; } +interface GetSshHostGroupEvent { + type: EventType.GET_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + name: string; + }; +} + +interface CreateSshHostGroupEvent { + type: EventType.CREATE_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + name: string; + loginMappings: TLoginMapping[]; + }; +} + +interface UpdateSshHostGroupEvent { + type: EventType.UPDATE_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + name?: string; + loginMappings?: TLoginMapping[]; + }; +} + +interface DeleteSshHostGroupEvent { + type: EventType.DELETE_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + name: string; + }; +} + +interface GetSshHostGroupHostsEvent { + type: EventType.GET_SSH_HOST_GROUP_HOSTS; + metadata: { + sshHostGroupId: string; + name: string; + }; +} + +interface AddHostToSshHostGroupEvent { + type: EventType.ADD_HOST_TO_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + sshHostId: string; + hostname: string; + }; +} + +interface RemoveHostFromSshHostGroupEvent { + type: EventType.REMOVE_HOST_FROM_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + sshHostId: string; + hostname: string; + }; +} + interface CreateCa { type: EventType.CREATE_CA; metadata: { @@ -2000,22 +2071,24 @@ interface GetSlackIntegration { }; } -interface UpdateProjectSlackConfig { - type: EventType.UPDATE_PROJECT_SLACK_CONFIG; +interface UpdateProjectWorkflowIntegrationConfig { + type: EventType.UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG; metadata: { id: string; - slackIntegrationId: string; + integrationId: string; + integration: WorkflowIntegration; isAccessRequestNotificationEnabled: boolean; - accessRequestChannels: string; + accessRequestChannels?: string | { teamId: string; channelIds: string[] }; isSecretRequestNotificationEnabled: boolean; - secretRequestChannels: string; + secretRequestChannels?: string | { teamId: string; channelIds: string[] }; }; } -interface GetProjectSlackConfig { - type: EventType.GET_PROJECT_SLACK_CONFIG; +interface GetProjectWorkflowIntegrationConfig { + type: EventType.GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG; metadata: { id: string; + integration: WorkflowIntegration; }; } @@ -2581,6 +2654,66 @@ interface RotateSecretRotationEvent { }; } +interface MicrosoftTeamsWorkflowIntegrationCreateEvent { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CREATE; + metadata: { + tenantId: string; + slug: string; + description?: string; + }; +} + +interface MicrosoftTeamsWorkflowIntegrationDeleteEvent { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_DELETE; + metadata: { + tenantId: string; + id: string; + slug: string; + }; +} + +interface MicrosoftTeamsWorkflowIntegrationCheckInstallationStatusEvent { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CHECK_INSTALLATION_STATUS; + metadata: { + tenantId: string; + slug: string; + }; +} + +interface MicrosoftTeamsWorkflowIntegrationGetTeamsEvent { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET_TEAMS; + metadata: { + tenantId: string; + slug: string; + id: string; + }; +} + +interface MicrosoftTeamsWorkflowIntegrationGetEvent { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET; + metadata: { + tenantId: string; + slug: string; + id: string; + }; +} + +interface MicrosoftTeamsWorkflowIntegrationListEvent { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST; + metadata: Record; +} + +interface MicrosoftTeamsWorkflowIntegrationUpdateEvent { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_UPDATE; + metadata: { + tenantId: string; + slug: string; + id: string; + newSlug?: string; + newDescription?: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -2745,8 +2878,8 @@ export type Event = | UpdateSlackIntegration | DeleteSlackIntegration | GetSlackIntegration - | UpdateProjectSlackConfig - | GetProjectSlackConfig + | UpdateProjectWorkflowIntegrationConfig + | GetProjectWorkflowIntegrationConfig | GetProjectSshConfig | UpdateProjectSshConfig | IntegrationSyncedEvent @@ -2775,6 +2908,13 @@ export type Event = | CreateAppConnectionEvent | UpdateAppConnectionEvent | DeleteAppConnectionEvent + | GetSshHostGroupEvent + | CreateSshHostGroupEvent + | UpdateSshHostGroupEvent + | DeleteSshHostGroupEvent + | GetSshHostGroupHostsEvent + | AddHostToSshHostGroupEvent + | RemoveHostFromSshHostGroupEvent | CreateSharedSecretEvent | DeleteSharedSecretEvent | ReadSharedSecretEvent @@ -2816,4 +2956,11 @@ export type Event = | CreateSecretRotationEvent | UpdateSecretRotationEvent | DeleteSecretRotationEvent - | RotateSecretRotationEvent; + | RotateSecretRotationEvent + | MicrosoftTeamsWorkflowIntegrationCreateEvent + | MicrosoftTeamsWorkflowIntegrationDeleteEvent + | MicrosoftTeamsWorkflowIntegrationCheckInstallationStatusEvent + | MicrosoftTeamsWorkflowIntegrationGetTeamsEvent + | MicrosoftTeamsWorkflowIntegrationGetEvent + | MicrosoftTeamsWorkflowIntegrationListEvent + | MicrosoftTeamsWorkflowIntegrationUpdateEvent; diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 59f82c05d..2458454da 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -153,7 +153,7 @@ export const groupDALFactory = (db: TDbClient) => { totalCount: Number(members?.[0]?.total_count ?? 0) }; } catch (error) { - throw new DatabaseError({ error, name: "Find all org members" }); + throw new DatabaseError({ error, name: "Find all user group members" }); } }; diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index 360b39f28..6a8f807ad 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -28,7 +28,8 @@ export const getDefaultOnPremFeatures = () => { has_used_trial: true, secretApproval: true, secretRotation: true, - caCrl: false + caCrl: false, + sshHostGroups: false }; }; diff --git a/backend/src/ee/services/license/licence-enums.ts b/backend/src/ee/services/license/licence-enums.ts index 047eb0a38..8812621f2 100644 --- a/backend/src/ee/services/license/licence-enums.ts +++ b/backend/src/ee/services/license/licence-enums.ts @@ -10,6 +10,7 @@ export const BillingPlanRows = { CustomAlerts: { name: "Custom alerts", field: "customAlerts" }, AuditLogs: { name: "Audit logs", field: "auditLogs" }, SamlSSO: { name: "SAML SSO", field: "samlSSO" }, + SshHostGroups: { name: "SSH Host Groups", field: "sshHostGroups" }, Hsm: { name: "Hardware Security Module (HSM)", field: "hsm" }, OidcSSO: { name: "OIDC SSO", field: "oidcSSO" }, SecretApproval: { name: "Secret approvals", field: "secretApproval" }, diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 548f6e82b..b7ae6f7ee 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -53,7 +53,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ enforceMfa: false, projectTemplates: false, kmip: false, - gateway: false + gateway: false, + sshHostGroups: false }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 6f0d82344..358849fb2 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -71,6 +71,7 @@ export type TFeatureSet = { projectTemplates: false; kmip: false; gateway: false; + sshHostGroups: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 03c8ed61a..993653045 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -142,6 +142,7 @@ export enum ProjectPermissionSub { SshCertificates = "ssh-certificates", SshCertificateTemplates = "ssh-certificate-templates", SshHosts = "ssh-hosts", + SshHostGroups = "ssh-host-groups", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -248,6 +249,7 @@ export type ProjectPermissionSet = ProjectPermissionSshHostActions, ProjectPermissionSub.SshHosts | (ForcedSubject & SshHostSubjectFields) ] + | [ProjectPermissionActions, ProjectPermissionSub.SshHostGroups] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] @@ -516,6 +518,12 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), + z.object({ + subject: z.literal(ProjectPermissionSub.SshHostGroups).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(ProjectPermissionSub.PkiAlerts).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( @@ -693,7 +701,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.PkiCollections, ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificates, - ProjectPermissionSub.SshCertificateTemplates + ProjectPermissionSub.SshCertificateTemplates, + ProjectPermissionSub.SshHostGroups ].forEach((el) => { can( [ diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 2f340626b..262e8e5cf 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -17,9 +17,13 @@ import { groupBy, pick, unique } from "@app/lib/fn"; import { setKnexStringValue } from "@app/lib/knex"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { EnforcementLevel } from "@app/lib/types"; +import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification"; +import { TriggerFeature } from "@app/lib/workflow-integrations/types"; import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { TProjectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; @@ -52,8 +56,6 @@ import { import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; -import { triggerSlackNotification } from "@app/services/slack/slack-fns"; -import { SlackTriggerFeature } from "@app/services/slack/slack-types"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -126,6 +128,8 @@ type TSecretApprovalRequestServiceFactoryDep = { secretApprovalPolicyDAL: Pick; projectSlackConfigDAL: Pick; licenseService: Pick; + projectMicrosoftTeamsConfigDAL: Pick; + microsoftTeamsService: Pick; }; export type TSecretApprovalRequestServiceFactory = ReturnType; @@ -155,7 +159,9 @@ export const secretApprovalRequestServiceFactory = ({ secretVersionTagV2BridgeDAL, licenseService, projectSlackConfigDAL, - resourceMetadataDAL + resourceMetadataDAL, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsService }: TSecretApprovalRequestServiceFactoryDep) => { const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); @@ -1171,21 +1177,28 @@ export const secretApprovalRequestServiceFactory = ({ const env = await projectEnvDAL.findOne({ id: policy.envId }); const user = await userDAL.findById(secretApprovalRequest.committerUserId); - await triggerSlackNotification({ - projectId, - projectDAL, - kmsService, - projectSlackConfigDAL, - notification: { - type: SlackTriggerFeature.SECRET_APPROVAL, - payload: { - userEmail: user.email as string, - environment: env.name, - secretPath, - projectId, - requestId: secretApprovalRequest.id, - secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))] + + await triggerWorkflowIntegrationNotification({ + input: { + projectId, + notification: { + type: TriggerFeature.SECRET_APPROVAL, + payload: { + userEmail: user.email as string, + environment: env.name, + secretPath, + projectId, + requestId: secretApprovalRequest.id, + secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))] + } } + }, + dependencies: { + projectDAL, + projectSlackConfigDAL, + kmsService, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsService } }); @@ -1503,21 +1516,28 @@ export const secretApprovalRequestServiceFactory = ({ const user = await userDAL.findById(secretApprovalRequest.committerUserId); const env = await projectEnvDAL.findOne({ id: policy.envId }); - await triggerSlackNotification({ - projectId, - projectDAL, - kmsService, - projectSlackConfigDAL, - notification: { - type: SlackTriggerFeature.SECRET_APPROVAL, - payload: { - userEmail: user.email as string, - environment: env.name, - secretPath, - projectId, - requestId: secretApprovalRequest.id, - secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))] + + await triggerWorkflowIntegrationNotification({ + input: { + projectId, + notification: { + type: TriggerFeature.SECRET_APPROVAL, + payload: { + userEmail: user.email as string, + environment: env.name, + secretPath, + projectId, + requestId: secretApprovalRequest.id, + secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))] + } } + }, + dependencies: { + projectDAL, + kmsService, + projectSlackConfigDAL, + microsoftTeamsService, + projectMicrosoftTeamsConfigDAL } }); diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-constants.ts new file mode 100644 index 000000000..3e25da403 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-constants.ts @@ -0,0 +1,15 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "Azure Client Secret", + type: SecretRotation.AzureClientSecret, + connection: AppConnection.AzureClientSecrets, + template: { + secretsMapping: { + clientId: "AZURE_CLIENT_ID", + clientSecret: "AZURE_CLIENT_SECRET" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts new file mode 100644 index 000000000..037df50ac --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts @@ -0,0 +1,202 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { + AzureAddPasswordResponse, + TAzureClientSecretRotationGeneratedCredentials, + TAzureClientSecretRotationWithConnection +} from "@app/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-types"; +import { + TRotationFactory, + TRotationFactoryGetSecretsPayload, + TRotationFactoryIssueCredentials, + TRotationFactoryRevokeCredentials, + TRotationFactoryRotateCredentials +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-client-secrets"; + +const GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"; + +type AzureErrorResponse = { error: { message: string } }; + +const sleep = async () => + new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + +export const azureClientSecretRotationFactory: TRotationFactory< + TAzureClientSecretRotationWithConnection, + TAzureClientSecretRotationGeneratedCredentials +> = (secretRotation, appConnectionDAL, kmsService) => { + const { + connection, + parameters: { objectId, clientId: clientIdParam }, + secretsMapping + } = secretRotation; + + /** + * Creates a new client secret for the Azure app. + */ + const $rotateClientSecret = async () => { + const accessToken = await getAzureConnectionAccessToken(connection.id, appConnectionDAL, kmsService); + const endpoint = `${GRAPH_API_BASE}/applications/${objectId}/addPassword`; + + const now = new Date(); + const formattedDate = `${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart( + 2, + "0" + )}-${now.getFullYear()}`; + + const endDateTime = new Date(); + endDateTime.setFullYear(now.getFullYear() + 5); + + try { + const { data } = await request.post( + endpoint, + { + passwordCredential: { + displayName: `Infisical Rotated Secret (${formattedDate})`, + endDateTime: endDateTime.toISOString() + } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + + if (!data?.secretText || !data?.keyId) { + throw new Error("Invalid response from Azure: missing secretText or keyId."); + } + + return { + clientSecret: data.secretText, + keyId: data.keyId, + clientId: clientIdParam + }; + } catch (error: unknown) { + if (error instanceof AxiosError) { + let message; + if ( + error.response?.data && + typeof error.response.data === "object" && + "error" in error.response.data && + typeof (error.response.data as AzureErrorResponse).error.message === "string" + ) { + message = (error.response.data as AzureErrorResponse).error.message; + } + throw new BadRequestError({ + message: `Failed to add client secret to Azure app ${objectId}: ${ + message || error.message || "Unknown error" + }` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + }; + + /** + * Revokes a client secret from the Azure app using its keyId. + */ + const revokeCredential = async (keyId: string) => { + const accessToken = await getAzureConnectionAccessToken(connection.id, appConnectionDAL, kmsService); + const endpoint = `${GRAPH_API_BASE}/applications/${objectId}/removePassword`; + + try { + await request.post( + endpoint, + { keyId }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + } catch (error: unknown) { + if (error instanceof AxiosError) { + let message; + if ( + error.response?.data && + typeof error.response.data === "object" && + "error" in error.response.data && + typeof (error.response.data as AzureErrorResponse).error.message === "string" + ) { + message = (error.response.data as AzureErrorResponse).error.message; + } + throw new BadRequestError({ + message: `Failed to remove client secret with keyId ${keyId} from app ${objectId}: ${ + message || error.message || "Unknown error" + }` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + }; + + /** + * Issues a new set of credentials. + */ + const issueCredentials: TRotationFactoryIssueCredentials = async ( + callback + ) => { + const credentials = await $rotateClientSecret(); + return callback(credentials); + }; + + /** + * Revokes a list of credentials. + */ + const revokeCredentials: TRotationFactoryRevokeCredentials = async ( + credentials, + callback + ) => { + if (!credentials?.length) return callback(); + + for (const { keyId } of credentials) { + await revokeCredential(keyId); + await sleep(); + } + return callback(); + }; + + /** + * Rotates credentials by issuing new ones and revoking the old. + */ + const rotateCredentials: TRotationFactoryRotateCredentials = async ( + oldCredentials, + callback + ) => { + const newCredentials = await $rotateClientSecret(); + if (oldCredentials?.keyId) { + await revokeCredential(oldCredentials.keyId); + } + + return callback(newCredentials); + }; + + /** + * Maps the generated credentials into the secret payload format. + */ + const getSecretsPayload: TRotationFactoryGetSecretsPayload = ({ + clientSecret + }) => [ + { key: secretsMapping.clientSecret, value: clientSecret }, + { key: secretsMapping.clientId, value: clientIdParam } + ]; + + return { + issueCredentials, + revokeCredentials, + rotateCredentials, + getSecretsPayload + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-schemas.ts new file mode 100644 index 000000000..9d98cac49 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-schemas.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { SecretRotations } from "@app/lib/api-docs"; +import { SecretNameSchema } from "@app/server/lib/schemas"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const AzureClientSecretRotationGeneratedCredentialsSchema = z + .object({ + clientId: z.string(), + clientSecret: z.string(), + keyId: z.string() + }) + .array() + .min(1) + .max(2); + +const AzureClientSecretRotationParametersSchema = z.object({ + objectId: z + .string() + .trim() + .min(1, "Object ID Required") + .describe(SecretRotations.PARAMETERS.AZURE_CLIENT_SECRET.objectId), + appName: z.string().trim().describe(SecretRotations.PARAMETERS.AZURE_CLIENT_SECRET.appName).optional(), + clientId: z + .string() + .trim() + .min(1, "Client ID Required") + .describe(SecretRotations.PARAMETERS.AZURE_CLIENT_SECRET.clientId) +}); + +const AzureClientSecretRotationSecretsMappingSchema = z.object({ + clientId: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.AZURE_CLIENT_SECRET.clientId), + clientSecret: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.AZURE_CLIENT_SECRET.clientSecret) +}); + +export const AzureClientSecretRotationTemplateSchema = z.object({ + secretsMapping: z.object({ + clientId: z.string(), + clientSecret: z.string() + }) +}); + +export const AzureClientSecretRotationSchema = BaseSecretRotationSchema(SecretRotation.AzureClientSecret).extend({ + type: z.literal(SecretRotation.AzureClientSecret), + parameters: AzureClientSecretRotationParametersSchema, + secretsMapping: AzureClientSecretRotationSecretsMappingSchema +}); + +export const CreateAzureClientSecretRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.AzureClientSecret +).extend({ + parameters: AzureClientSecretRotationParametersSchema, + secretsMapping: AzureClientSecretRotationSecretsMappingSchema +}); + +export const UpdateAzureClientSecretRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.AzureClientSecret +).extend({ + parameters: AzureClientSecretRotationParametersSchema.optional(), + secretsMapping: AzureClientSecretRotationSecretsMappingSchema.optional() +}); + +export const AzureClientSecretRotationListItemSchema = z.object({ + name: z.literal("Azure Client Secret"), + connection: z.literal(AppConnection.AzureClientSecrets), + type: z.literal(SecretRotation.AzureClientSecret), + template: AzureClientSecretRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-types.ts new file mode 100644 index 000000000..91f66a883 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-types.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { TAzureClientSecretsConnection } from "@app/services/app-connection/azure-client-secrets"; + +import { + AzureClientSecretRotationGeneratedCredentialsSchema, + AzureClientSecretRotationListItemSchema, + AzureClientSecretRotationSchema, + CreateAzureClientSecretRotationSchema +} from "./azure-client-secret-rotation-schemas"; + +export type TAzureClientSecretRotation = z.infer; + +export type TAzureClientSecretRotationInput = z.infer; + +export type TAzureClientSecretRotationListItem = z.infer; + +export type TAzureClientSecretRotationWithConnection = TAzureClientSecretRotation & { + connection: TAzureClientSecretsConnection; +}; + +export type TAzureClientSecretRotationGeneratedCredentials = z.infer< + typeof AzureClientSecretRotationGeneratedCredentialsSchema +>; + +export interface TAzureClientSecretRotationParameters { + appId: string; + keyId?: string; + displayName?: string; +} + +export interface TAzureClientSecretRotationSecretsMapping { + appId: string; + clientSecret: string; + keyId: string; +} + +export interface AzureAddPasswordResponse { + secretText: string; + keyId: string; +} diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/index.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/index.ts new file mode 100644 index 000000000..8c741bdc6 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/index.ts @@ -0,0 +1,3 @@ +export * from "./azure-client-secret-rotation-constants"; +export * from "./azure-client-secret-rotation-schemas"; +export * from "./azure-client-secret-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts index 4ddf4ee0c..d67abea2b 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts @@ -2,8 +2,9 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", Auth0ClientSecret = "auth0-client-secret", - LdapPassword = "ldap-password", - AwsIamUserSecret = "aws-iam-user-secret" + AzureClientSecret = "azure-client-secret", + AwsIamUserSecret = "aws-iam-user-secret", + LdapPassword = "ldap-password" } export enum SecretRotationStatus { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts index 23452d7d4..a25482c8c 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts @@ -5,6 +5,7 @@ import { KmsDataKey } from "@app/services/kms/kms-types"; import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret"; import { AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION } from "./aws-iam-user-secret"; +import { AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./azure-client-secret"; import { LDAP_PASSWORD_ROTATION_LIST_OPTION } from "./ldap-password"; import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; @@ -21,8 +22,9 @@ const SECRET_ROTATION_LIST_OPTIONS: Record { @@ -217,7 +219,7 @@ export const parseRotationErrorMessage = (err: unknown): string => { if (err instanceof AxiosError) { errorMessage += err?.response?.data ? JSON.stringify(err?.response?.data) - : err?.message ?? "An unknown error occurred."; + : (err?.message ?? "An unknown error occurred."); } else { errorMessage += (err as Error)?.message || "An unknown error occurred."; } diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts index 134aaeafc..f4ea75558 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts @@ -5,14 +5,16 @@ export const SECRET_ROTATION_NAME_MAP: Record = { [SecretRotation.PostgresCredentials]: "PostgreSQL Credentials", [SecretRotation.MsSqlCredentials]: "Microsoft SQL Server Credentials", [SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret", - [SecretRotation.LdapPassword]: "LDAP Password", - [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret" + [SecretRotation.AzureClientSecret]: "Azure Client Secret", + [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret", + [SecretRotation.LdapPassword]: "LDAP Password" }; export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: AppConnection.Postgres, [SecretRotation.MsSqlCredentials]: AppConnection.MsSql, [SecretRotation.Auth0ClientSecret]: AppConnection.Auth0, - [SecretRotation.LdapPassword]: AppConnection.LDAP, - [SecretRotation.AwsIamUserSecret]: AppConnection.AWS + [SecretRotation.AzureClientSecret]: AppConnection.AzureClientSecrets, + [SecretRotation.AwsIamUserSecret]: AppConnection.AWS, + [SecretRotation.LdapPassword]: AppConnection.LDAP }; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts index b1be4ea22..d7b885c92 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts @@ -20,7 +20,7 @@ export const BaseSecretRotationSchema = (type: SecretRotation) => // unique to provider type: true, parameters: true, - secretMappings: true + secretsMapping: true }).extend({ connection: z.object({ app: z.literal(SECRET_ROTATION_CONNECTION_MAP[type]), diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index 70543c9b4..69743f133 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -14,6 +14,7 @@ import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { auth0ClientSecretRotationFactory } from "@app/ee/services/secret-rotation-v2/auth0-client-secret/auth0-client-secret-rotation-fns"; +import { azureClientSecretRotationFactory } from "@app/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns"; import { ldapPasswordRotationFactory } from "@app/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns"; import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; import { @@ -102,7 +103,7 @@ export type TSecretRotationV2ServiceFactoryDep = { secretQueueService: Pick; snapshotService: Pick; queueService: Pick; - appConnectionDAL: Pick; + appConnectionDAL: Pick; }; export type TSecretRotationV2ServiceFactory = ReturnType; @@ -117,8 +118,9 @@ const SECRET_ROTATION_FACTORY_MAP: Record = ( secretRotation: T, - appConnectionDAL: Pick, + appConnectionDAL: Pick, kmsService: Pick ) => { issueCredentials: TRotationFactoryIssueCredentials; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts index 4d51a23c3..f6fdafe1d 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { Auth0ClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret"; +import { AzureClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; import { LdapPasswordRotationSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; @@ -11,6 +12,7 @@ export const SecretRotationV2Schema = z.discriminatedUnion("type", [ PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, Auth0ClientSecretRotationSchema, + AzureClientSecretRotationSchema, LdapPasswordRotationSchema, AwsIamUserSecretRotationSchema ]); diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts new file mode 100644 index 000000000..08242d4cb --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts @@ -0,0 +1,225 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { groupBy, unique } from "@app/lib/fn"; +import { ormify } from "@app/lib/knex"; + +import { EHostGroupMembershipFilter } from "./ssh-host-group-types"; + +export type TSshHostGroupDALFactory = ReturnType; + +export const sshHostGroupDALFactory = (db: TDbClient) => { + const sshHostGroupOrm = ormify(db, TableName.SshHostGroup); + + const findSshHostGroupsWithLoginMappings = async (projectId: string, tx?: Knex) => { + try { + // First, get all the SSH host groups with their login mappings + const rows = await (tx || db.replicaNode())(TableName.SshHostGroup) + .leftJoin( + TableName.SshHostLoginUser, + `${TableName.SshHostGroup}.id`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .where(`${TableName.SshHostGroup}.projectId`, projectId) + .select( + db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"), + db.ref("projectId").withSchema(TableName.SshHostGroup), + db.ref("name").withSchema(TableName.SshHostGroup), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping) + ) + .orderBy(`${TableName.SshHostGroup}.updatedAt`, "desc"); + + const hostsGrouped = groupBy(rows, (r) => r.sshHostGroupId); + + const hostGroupIds = Object.keys(hostsGrouped); + + type HostCountRow = { + sshHostGroupId: string; + host_count: string; + }; + + const hostCountsQuery = (await (tx || + db + .replicaNode()(TableName.SshHostGroupMembership) + .select(`${TableName.SshHostGroupMembership}.sshHostGroupId`, db.raw(`count(*) as host_count`)) + .whereIn(`${TableName.SshHostGroupMembership}.sshHostGroupId`, hostGroupIds) + .groupBy(`${TableName.SshHostGroupMembership}.sshHostGroupId`))) as HostCountRow[]; + + const hostCountsMap = hostCountsQuery.reduce>((acc, { sshHostGroupId, host_count }) => { + acc[sshHostGroupId] = Number(host_count); + return acc; + }, {}); + + return Object.values(hostsGrouped).map((hostRows) => { + const { sshHostGroupId, name } = hostRows[0]; + const loginMappingGrouped = groupBy( + hostRows.filter((r) => r.loginUser), + (r) => r.loginUser + ); + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + } + })); + return { + id: sshHostGroupId, + projectId, + name, + loginMappings, + hostCount: hostCountsMap[sshHostGroupId] ?? 0 + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SshHostGroup}: FindSshHostGroupsWithLoginMappings` }); + } + }; + + const findSshHostGroupByIdWithLoginMappings = async (sshHostGroupId: string, tx?: Knex) => { + try { + const rows = await (tx || db.replicaNode())(TableName.SshHostGroup) + .leftJoin( + TableName.SshHostLoginUser, + `${TableName.SshHostGroup}.id`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .where(`${TableName.SshHostGroup}.id`, sshHostGroupId) + .select( + db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"), + db.ref("projectId").withSchema(TableName.SshHostGroup), + db.ref("name").withSchema(TableName.SshHostGroup), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users), + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping) + ); + + if (rows.length === 0) return null; + + const { sshHostGroupId: id, projectId, name } = rows[0]; + + const loginMappingGrouped = groupBy( + rows.filter((r) => r.loginUser), + (r) => r.loginUser + ); + + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + } + })); + + return { + id, + projectId, + name, + loginMappings + }; + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SshHostGroup}: FindSshHostGroupByIdWithLoginMappings` }); + } + }; + + const findAllSshHostsInGroup = async ({ + sshHostGroupId, + offset = 0, + limit, + filter + }: { + sshHostGroupId: string; + offset?: number; + limit?: number; + filter?: EHostGroupMembershipFilter; + }) => { + try { + const sshHostGroup = await db + .replicaNode()(TableName.SshHostGroup) + .where(`${TableName.SshHostGroup}.id`, sshHostGroupId) + .select("projectId") + .first(); + + if (!sshHostGroup) { + throw new BadRequestError({ + message: `SSH host group with ID ${sshHostGroupId} not found` + }); + } + + const query = db + .replicaNode()(TableName.SshHost) + .where(`${TableName.SshHost}.projectId`, sshHostGroup.projectId) + .leftJoin(TableName.SshHostGroupMembership, (bd) => { + bd.on(`${TableName.SshHostGroupMembership}.sshHostId`, "=", `${TableName.SshHost}.id`).andOn( + `${TableName.SshHostGroupMembership}.sshHostGroupId`, + "=", + db.raw("?", [sshHostGroupId]) + ); + }) + .select( + db.ref("id").withSchema(TableName.SshHost), + db.ref("hostname").withSchema(TableName.SshHost), + db.ref("alias").withSchema(TableName.SshHost), + db.ref("sshHostGroupId").withSchema(TableName.SshHostGroupMembership), + db.ref("createdAt").withSchema(TableName.SshHostGroupMembership).as("joinedGroupAt"), + db.raw(`count(*) OVER() as total_count`) + ) + .offset(offset) + .orderBy(`${TableName.SshHost}.hostname`, "asc"); + + if (limit) { + void query.limit(limit); + } + + if (filter) { + switch (filter) { + case EHostGroupMembershipFilter.GROUP_MEMBERS: + void query.andWhere(`${TableName.SshHostGroupMembership}.createdAt`, "is not", null); + break; + case EHostGroupMembershipFilter.NON_GROUP_MEMBERS: + void query.andWhere(`${TableName.SshHostGroupMembership}.createdAt`, "is", null); + break; + default: + break; + } + } + + const hosts = await query; + + return { + hosts: hosts.map(({ id, hostname, alias, sshHostGroupId: memberGroupId, joinedGroupAt }) => ({ + id, + hostname, + alias, + isPartOfGroup: !!memberGroupId, + joinedGroupAt + })), + // @ts-expect-error col select is raw and not strongly typed + totalCount: Number(hosts?.[0]?.total_count ?? 0) + }; + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.SshHostGroupMembership}: FindAllSshHostsInGroup` }); + } + }; + + return { + findSshHostGroupsWithLoginMappings, + findSshHostGroupByIdWithLoginMappings, + findAllSshHostsInGroup, + ...sshHostGroupOrm + }; +}; diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-membership-dal.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-membership-dal.ts new file mode 100644 index 000000000..54179c2d9 --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-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 TSshHostGroupMembershipDALFactory = ReturnType; + +export const sshHostGroupMembershipDALFactory = (db: TDbClient) => { + const sshHostGroupMembershipOrm = ormify(db, TableName.SshHostGroupMembership); + + return { + ...sshHostGroupMembershipOrm + }; +}; diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-schema.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-schema.ts new file mode 100644 index 000000000..4ebf3000d --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-schema.ts @@ -0,0 +1,7 @@ +import { SshHostGroupsSchema } from "@app/db/schemas"; + +export const sanitizedSshHostGroup = SshHostGroupsSchema.pick({ + id: true, + projectId: true, + name: true +}); diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts new file mode 100644 index 000000000..751116895 --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts @@ -0,0 +1,397 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; +import { TSshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal"; +import { TSshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal"; +import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; +import { TSshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { createSshLoginMappings } from "../ssh-host/ssh-host-fns"; +import { + TAddHostToSshHostGroupDTO, + TCreateSshHostGroupDTO, + TDeleteSshHostGroupDTO, + TGetSshHostGroupDTO, + TListSshHostGroupHostsDTO, + TRemoveHostFromSshHostGroupDTO, + TUpdateSshHostGroupDTO +} from "./ssh-host-group-types"; + +type TSshHostGroupServiceFactoryDep = { + projectDAL: Pick; + sshHostDAL: Pick; + sshHostGroupDAL: Pick< + TSshHostGroupDALFactory, + | "create" + | "updateById" + | "findById" + | "deleteById" + | "transaction" + | "findSshHostGroupByIdWithLoginMappings" + | "findAllSshHostsInGroup" + | "findOne" + | "find" + >; + sshHostGroupMembershipDAL: Pick; + sshHostLoginUserDAL: Pick; + sshHostLoginUserMappingDAL: Pick; + userDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TSshHostGroupServiceFactory = ReturnType; + +export const sshHostGroupServiceFactory = ({ + projectDAL, + sshHostDAL, + sshHostGroupDAL, + sshHostGroupMembershipDAL, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + licenseService +}: TSshHostGroupServiceFactoryDep) => { + const createSshHostGroup = async ({ + projectId, + name, + loginMappings, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateSshHostGroupDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.SshHostGroups); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.sshHostGroups) + throw new BadRequestError({ + message: "Failed to create SSH host group due to plan restriction. Upgrade plan to create group." + }); + + const newSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => { + // (dangtony98): room to optimize check to ensure that + // the SSH host group name is unique across the whole org + const project = await projectDAL.findById(projectId, tx); + if (!project) throw new NotFoundError({ message: `Project with ID '${projectId}' not found` }); + const projects = await projectDAL.find( + { + orgId: project.orgId + }, + { tx } + ); + + const existingSshHostGroup = await sshHostGroupDAL.find( + { + name, + $in: { + projectId: projects.map((p) => p.id) + } + }, + { tx } + ); + + if (existingSshHostGroup.length) { + throw new BadRequestError({ + message: `SSH host group with name '${name}' already exists in the organization` + }); + } + + const sshHostGroup = await sshHostGroupDAL.create( + { + projectId, + name + }, + tx + ); + + await createSshLoginMappings({ + sshHostGroupId: sshHostGroup.id, + loginMappings, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + projectId, + actorAuthMethod, + actorOrgId, + tx + }); + + const newSshHostGroupWithLoginMappings = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings( + sshHostGroup.id, + tx + ); + if (!newSshHostGroupWithLoginMappings) { + throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroup.id}' not found` }); + } + + return newSshHostGroupWithLoginMappings; + }); + + return newSshHostGroup; + }; + + const updateSshHostGroup = async ({ + sshHostGroupId, + name, + loginMappings, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateSshHostGroupDTO) => { + const sshHostGroup = await sshHostGroupDAL.findById(sshHostGroupId); + if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.sshHostGroups) + throw new BadRequestError({ + message: "Failed to update SSH host group due to plan restriction. Upgrade plan to update group." + }); + + const updatedSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => { + await sshHostGroupDAL.updateById( + sshHostGroupId, + { + name + }, + tx + ); + if (loginMappings) { + await sshHostLoginUserDAL.delete({ sshHostGroupId: sshHostGroup.id }, tx); + if (loginMappings.length) { + await createSshLoginMappings({ + sshHostGroupId: sshHostGroup.id, + loginMappings, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + tx + }); + } + } + + const updatedSshHostGroupWithLoginMappings = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings( + sshHostGroup.id, + tx + ); + if (!updatedSshHostGroupWithLoginMappings) { + throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroup.id}' not found` }); + } + + return updatedSshHostGroupWithLoginMappings; + }); + + return updatedSshHostGroup; + }; + + const getSshHostGroup = async ({ + sshHostGroupId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TGetSshHostGroupDTO) => { + const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); + if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups); + + return sshHostGroup; + }; + + const deleteSshHostGroup = async ({ + sshHostGroupId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TDeleteSshHostGroupDTO) => { + const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); + if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.SshHostGroups); + + await sshHostGroupDAL.deleteById(sshHostGroupId); + + return sshHostGroup; + }; + + const listSshHostGroupHosts = async ({ + sshHostGroupId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + filter + }: TListSshHostGroupHostsDTO) => { + const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); + if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups); + + const { hosts, totalCount } = await sshHostGroupDAL.findAllSshHostsInGroup({ sshHostGroupId, filter }); + return { sshHostGroup, hosts, totalCount }; + }; + + const addHostToSshHostGroup = async ({ + sshHostGroupId, + hostId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TAddHostToSshHostGroupDTO) => { + const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); + if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); + + const sshHost = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId); + if (!sshHost) { + throw new NotFoundError({ + message: `SSH host with ID ${hostId} not found` + }); + } + + if (sshHostGroup.projectId !== sshHost.projectId) { + throw new BadRequestError({ + message: `SSH host with ID ${hostId} not found in project ${sshHostGroup.projectId}` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups); + + await sshHostGroupMembershipDAL.create({ sshHostGroupId, sshHostId: hostId }); + + return { sshHostGroup, sshHost }; + }; + + const removeHostFromSshHostGroup = async ({ + sshHostGroupId, + hostId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TRemoveHostFromSshHostGroupDTO) => { + const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); + if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); + + const sshHost = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId); + if (!sshHost) { + throw new NotFoundError({ + message: `SSH host with ID ${hostId} not found` + }); + } + + if (sshHostGroup.projectId !== sshHost.projectId) { + throw new BadRequestError({ + message: `SSH host with ID ${hostId} not found in project ${sshHostGroup.projectId}` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: sshHostGroup.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups); + + const sshHostGroupMembership = await sshHostGroupMembershipDAL.findOne({ + sshHostGroupId, + sshHostId: hostId + }); + + if (!sshHostGroupMembership) { + throw new NotFoundError({ + message: `SSH host with ID ${hostId} not found in SSH host group with ID ${sshHostGroupId}` + }); + } + + await sshHostGroupMembershipDAL.deleteById(sshHostGroupMembership.id); + + return { sshHostGroup, sshHost }; + }; + + return { + createSshHostGroup, + getSshHostGroup, + deleteSshHostGroup, + updateSshHostGroup, + listSshHostGroupHosts, + addHostToSshHostGroup, + removeHostFromSshHostGroup + }; +}; diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts new file mode 100644 index 000000000..3485b5d26 --- /dev/null +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts @@ -0,0 +1,46 @@ +import { TLoginMapping } from "@app/ee/services/ssh-host/ssh-host-types"; +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateSshHostGroupDTO = { + name: string; + loginMappings: TLoginMapping[]; +} & TProjectPermission; + +export type TUpdateSshHostGroupDTO = { + sshHostGroupId: string; + name?: string; + loginMappings?: { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + }[]; +} & Omit; + +export type TGetSshHostGroupDTO = { + sshHostGroupId: string; +} & Omit; + +export type TDeleteSshHostGroupDTO = { + sshHostGroupId: string; +} & Omit; + +export type TListSshHostGroupHostsDTO = { + sshHostGroupId: string; + filter?: EHostGroupMembershipFilter; +} & Omit; + +export type TAddHostToSshHostGroupDTO = { + sshHostGroupId: string; + hostId: string; +} & Omit; + +export type TRemoveHostFromSshHostGroupDTO = { + sshHostGroupId: string; + hostId: string; +} & Omit; + +export enum EHostGroupMembershipFilter { + GROUP_MEMBERS = "group-members", + NON_GROUP_MEMBERS = "non-group-members" +} diff --git a/backend/src/ee/services/ssh-host/ssh-host-dal.ts b/backend/src/ee/services/ssh-host/ssh-host-dal.ts index 3c9755e65..e66f7da7a 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-dal.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-dal.ts @@ -6,6 +6,8 @@ import { DatabaseError } from "@app/lib/errors"; import { groupBy, unique } from "@app/lib/fn"; import { ormify } from "@app/lib/knex"; +import { LoginMappingSource } from "./ssh-host-types"; + export type TSshHostDALFactory = ReturnType; export const sshHostDALFactory = (db: TDbClient) => { @@ -13,20 +15,22 @@ export const sshHostDALFactory = (db: TDbClient) => { const findUserAccessibleSshHosts = async (projectIds: string[], userId: string, tx?: Knex) => { try { - const user = await (tx || db.replicaNode())(TableName.Users).where({ id: userId }).select("username").first(); + const knex = tx || db.replicaNode(); + + const user = await knex(TableName.Users).where({ id: userId }).select("username").first(); if (!user) { throw new DatabaseError({ name: `${TableName.Users}: UserNotFound`, error: new Error("User not found") }); } - const rows = await (tx || db.replicaNode())(TableName.SshHost) + // get hosts where user has direct login mappings + const directHostRows = await knex(TableName.SshHost) .leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`) .leftJoin( TableName.SshHostLoginUserMapping, `${TableName.SshHostLoginUser}.id`, `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) - .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SshHostLoginUserMapping}.userId`) .whereIn(`${TableName.SshHost}.projectId`, projectIds) .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) .select( @@ -37,26 +41,70 @@ export const sshHostDALFactory = (db: TDbClient) => { db.ref("userCertTtl").withSchema(TableName.SshHost), db.ref("hostCertTtl").withSchema(TableName.SshHost), db.ref("loginUser").withSchema(TableName.SshHostLoginUser), - db.ref("username").withSchema(TableName.Users), - db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), db.ref("userSshCaId").withSchema(TableName.SshHost), db.ref("hostSshCaId").withSchema(TableName.SshHost) - ) - .orderBy(`${TableName.SshHost}.updatedAt`, "desc"); + ); - const grouped = groupBy(rows, (r) => r.sshHostId); - return Object.values(grouped).map((hostRows) => { + // get hosts where user has login mappings via host groups + const groupHostRows = await knex(TableName.SshHostGroupMembership) + .join( + TableName.SshHostLoginUser, + `${TableName.SshHostGroupMembership}.sshHostGroupId`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .join(TableName.SshHost, `${TableName.SshHostGroupMembership}.sshHostId`, `${TableName.SshHost}.id`) + .whereIn(`${TableName.SshHost}.projectId`, projectIds) + .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) + .select( + db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), + db.ref("projectId").withSchema(TableName.SshHost), + db.ref("hostname").withSchema(TableName.SshHost), + db.ref("alias").withSchema(TableName.SshHost), + db.ref("userCertTtl").withSchema(TableName.SshHost), + db.ref("hostCertTtl").withSchema(TableName.SshHost), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("userSshCaId").withSchema(TableName.SshHost), + db.ref("hostSshCaId").withSchema(TableName.SshHost) + ); + + const directHostRowsWithSource = directHostRows.map((row) => ({ + ...row, + source: LoginMappingSource.HOST + })); + + const groupHostRowsWithSource = groupHostRows.map((row) => ({ + ...row, + source: LoginMappingSource.HOST_GROUP + })); + + const mergedRows = [...directHostRowsWithSource, ...groupHostRowsWithSource]; + + const hostsGrouped = groupBy(mergedRows, (r) => r.sshHostId); + + return Object.values(hostsGrouped).map((hostRows) => { const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } = hostRows[0]; const loginMappingGrouped = groupBy(hostRows, (r) => r.loginUser); + const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, mappings]) => { + // Prefer HOST source over HOST_GROUP + const preferredMapping = + mappings.find((m) => m.source === LoginMappingSource.HOST) || + mappings.find((m) => m.source === LoginMappingSource.HOST_GROUP); - const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser]) => ({ - loginUser, - allowedPrincipals: { - usernames: [user.username] - } - })); + return { + loginUser, + allowedPrincipals: { + usernames: [user.username] + }, + source: preferredMapping!.source + }; + }); return { id: sshHostId, @@ -101,20 +149,57 @@ export const sshHostDALFactory = (db: TDbClient) => { ) .orderBy(`${TableName.SshHost}.updatedAt`, "desc"); + // process login mappings inherited from groups that hosts are part of + const hostIds = unique(rows.map((r) => r.sshHostId)).filter(Boolean); + const groupRows = await (tx || db.replicaNode())(TableName.SshHostGroupMembership) + .join( + TableName.SshHostLoginUser, + `${TableName.SshHostGroupMembership}.sshHostGroupId`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .select( + db.ref("sshHostId").withSchema(TableName.SshHostGroupMembership), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users) + ) + .whereIn(`${TableName.SshHostGroupMembership}.sshHostId`, hostIds); + + const groupedGroupMappings = groupBy(groupRows, (r) => r.sshHostId); + const hostsGrouped = groupBy(rows, (r) => r.sshHostId); return Object.values(hostsGrouped).map((hostRows) => { const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = hostRows[0]; + // direct login mappings const loginMappingGrouped = groupBy( hostRows.filter((r) => r.loginUser), (r) => r.loginUser ); - const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + const directMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { usernames: unique(entries.map((e) => e.username)).filter(Boolean) - } + }, + source: LoginMappingSource.HOST + })); + + // group-inherited login mappings + const inheritedGroupRows = groupedGroupMappings[sshHostId] || []; + const inheritedGrouped = groupBy(inheritedGroupRows, (r) => r.loginUser); + + const groupMappings = Object.entries(inheritedGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + }, + source: LoginMappingSource.HOST_GROUP })); return { @@ -124,7 +209,7 @@ export const sshHostDALFactory = (db: TDbClient) => { projectId, userCertTtl, hostCertTtl, - loginMappings, + loginMappings: [...directMappings, ...groupMappings], userSshCaId, hostSshCaId }; @@ -163,16 +248,50 @@ export const sshHostDALFactory = (db: TDbClient) => { const { sshHostId: id, projectId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = rows[0]; - const loginMappingGrouped = groupBy( + // direct login mappings + const directGrouped = groupBy( rows.filter((r) => r.loginUser), (r) => r.loginUser ); - const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + const directMappings = Object.entries(directGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { usernames: unique(entries.map((e) => e.username)).filter(Boolean) - } + }, + source: LoginMappingSource.HOST + })); + + // group login mappings + const groupRows = await (tx || db.replicaNode())(TableName.SshHostGroupMembership) + .join( + TableName.SshHostLoginUser, + `${TableName.SshHostGroupMembership}.sshHostGroupId`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .where(`${TableName.SshHostGroupMembership}.sshHostId`, sshHostId) + .select( + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users) + ); + + const groupGrouped = groupBy( + groupRows.filter((r) => r.loginUser), + (r) => r.loginUser + ); + + const groupMappings = Object.entries(groupGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + }, + source: LoginMappingSource.HOST_GROUP })); return { @@ -182,7 +301,7 @@ export const sshHostDALFactory = (db: TDbClient) => { alias, userCertTtl, hostCertTtl, - loginMappings, + loginMappings: [...directMappings, ...groupMappings], userSshCaId, hostSshCaId }; diff --git a/backend/src/ee/services/ssh-host/ssh-host-fns.ts b/backend/src/ee/services/ssh-host/ssh-host-fns.ts new file mode 100644 index 000000000..9b9ce2642 --- /dev/null +++ b/backend/src/ee/services/ssh-host/ssh-host-fns.ts @@ -0,0 +1,85 @@ +import { Knex } from "knex"; + +import { ActionProjectType } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; + +import { TCreateSshLoginMappingsDTO } from "./ssh-host-types"; + +/** + * Create SSH login mappings for a given SSH host + * or SSH host group. + */ +export const createSshLoginMappings = async ({ + sshHostId, + sshHostGroupId, + loginMappings, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + projectId, + actorAuthMethod, + actorOrgId, + tx: outerTx +}: TCreateSshLoginMappingsDTO) => { + const processCreation = async (tx: Knex) => { + // (dangtony98): room to optimize + for await (const { loginUser, allowedPrincipals } of loginMappings) { + const sshHostLoginUser = await sshHostLoginUserDAL.create( + // (dangtony98): should either pass in sshHostId or sshHostGroupId but not both + { + sshHostId, + sshHostGroupId, + loginUser + }, + tx + ); + + if (allowedPrincipals.usernames.length > 0) { + const users = await userDAL.find( + { + $in: { + username: allowedPrincipals.usernames + } + }, + { tx } + ); + + const foundUsernames = new Set(users.map((u) => u.username)); + + for (const uname of allowedPrincipals.usernames) { + if (!foundUsernames.has(uname)) { + throw new BadRequestError({ + message: `Invalid username: ${uname}` + }); + } + } + + for await (const user of users) { + // check that each user has access to the SSH project + await permissionService.getUserProjectPermission({ + userId: user.id, + projectId, + authMethod: actorAuthMethod, + userOrgId: actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + } + + await sshHostLoginUserMappingDAL.insertMany( + users.map((user) => ({ + sshHostLoginUserId: sshHostLoginUser.id, + userId: user.id + })), + tx + ); + } + } + }; + + if (outerTx) { + return processCreation(outerTx); + } + + return sshHostLoginUserDAL.transaction(processCreation); +}; diff --git a/backend/src/ee/services/ssh-host/ssh-host-service.ts b/backend/src/ee/services/ssh-host/ssh-host-service.ts index 92f1f5236..87f4862bb 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-service.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts @@ -26,6 +26,7 @@ import { getSshPublicKey } from "../ssh/ssh-certificate-authority-fns"; import { SshCertType } from "../ssh/ssh-certificate-authority-types"; +import { createSshLoginMappings } from "./ssh-host-fns"; import { TCreateSshHostDTO, TDeleteSshHostDTO, @@ -202,56 +203,18 @@ export const sshHostServiceFactory = ({ tx ); - // (dangtony98): room to optimize - for await (const { loginUser, allowedPrincipals } of loginMappings) { - const sshHostLoginUser = await sshHostLoginUserDAL.create( - { - sshHostId: host.id, - loginUser - }, - tx - ); - - if (allowedPrincipals.usernames.length > 0) { - const users = await userDAL.find( - { - $in: { - username: allowedPrincipals.usernames - } - }, - { tx } - ); - - const foundUsernames = new Set(users.map((u) => u.username)); - - for (const uname of allowedPrincipals.usernames) { - if (!foundUsernames.has(uname)) { - throw new BadRequestError({ - message: `Invalid username: ${uname}` - }); - } - } - - for await (const user of users) { - // check that each user has access to the SSH project - await permissionService.getUserProjectPermission({ - userId: user.id, - projectId, - authMethod: actorAuthMethod, - userOrgId: actorOrgId, - actionProjectType: ActionProjectType.SSH - }); - } - - await sshHostLoginUserMappingDAL.insertMany( - users.map((user) => ({ - sshHostLoginUserId: sshHostLoginUser.id, - userId: user.id - })), - tx - ); - } - } + await createSshLoginMappings({ + sshHostId: host.id, + loginMappings, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + projectId, + actorAuthMethod, + actorOrgId, + tx + }); const newSshHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(host.id, tx); if (!newSshHostWithLoginMappings) { @@ -310,54 +273,18 @@ export const sshHostServiceFactory = ({ if (loginMappings) { await sshHostLoginUserDAL.delete({ sshHostId: host.id }, tx); if (loginMappings.length) { - for await (const { loginUser, allowedPrincipals } of loginMappings) { - const sshHostLoginUser = await sshHostLoginUserDAL.create( - { - sshHostId: host.id, - loginUser - }, - tx - ); - - if (allowedPrincipals.usernames.length > 0) { - const users = await userDAL.find( - { - $in: { - username: allowedPrincipals.usernames - } - }, - { tx } - ); - - const foundUsernames = new Set(users.map((u) => u.username)); - - for (const uname of allowedPrincipals.usernames) { - if (!foundUsernames.has(uname)) { - throw new BadRequestError({ - message: `Invalid username: ${uname}` - }); - } - } - - for await (const user of users) { - await permissionService.getUserProjectPermission({ - userId: user.id, - projectId: host.projectId, - authMethod: actorAuthMethod, - userOrgId: actorOrgId, - actionProjectType: ActionProjectType.SSH - }); - } - - await sshHostLoginUserMappingDAL.insertMany( - users.map((user) => ({ - sshHostLoginUserId: sshHostLoginUser.id, - userId: user.id - })), - tx - ); - } - } + await createSshLoginMappings({ + sshHostId: host.id, + loginMappings, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + projectId: host.projectId, + actorAuthMethod, + actorOrgId, + tx + }); } } diff --git a/backend/src/ee/services/ssh-host/ssh-host-types.ts b/backend/src/ee/services/ssh-host/ssh-host-types.ts index a4826cd72..9846920b7 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-types.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts @@ -1,18 +1,32 @@ +import { Knex } from "knex"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { TSshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal"; +import { TSshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal"; import { TProjectPermission } from "@app/lib/types"; +import { ActorAuthMethod } from "@app/services/auth/auth-type"; +import { TUserDALFactory } from "@app/services/user/user-dal"; export type TListSshHostsDTO = Omit; +export type TLoginMapping = { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; +}; + +export enum LoginMappingSource { + HOST = "host", + HOST_GROUP = "hostGroup" +} + export type TCreateSshHostDTO = { hostname: string; alias?: string; userCertTtl: string; hostCertTtl: string; - loginMappings: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings: TLoginMapping[]; userSshCaId?: string; hostSshCaId?: string; } & TProjectPermission; @@ -23,12 +37,7 @@ export type TUpdateSshHostDTO = { alias?: string; userCertTtl?: string; hostCertTtl?: string; - loginMappings?: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings?: TLoginMapping[]; } & Omit; export type TGetSshHostDTO = { @@ -48,3 +57,19 @@ export type TIssueSshHostHostCertDTO = { sshHostId: string; publicKey: string; } & Omit; + +type BaseCreateSshLoginMappingsDTO = { + loginMappings: TLoginMapping[]; + sshHostLoginUserDAL: Pick; + sshHostLoginUserMappingDAL: Pick; + userDAL: Pick; + permissionService: Pick; + projectId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + tx?: Knex; +}; + +export type TCreateSshLoginMappingsDTO = + | (BaseCreateSshLoginMappingsDTO & { sshHostId: string; sshHostGroupId?: undefined }) + | (BaseCreateSshLoginMappingsDTO & { sshHostGroupId: string; sshHostId?: undefined }); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts index 312b7966b..d58644d90 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -282,7 +282,7 @@ export const sshCertificateAuthorityServiceFactory = ({ // set [keyId] depending on if [allowCustomKeyIds] is true or false const keyId = sshCertificateTemplate.allowCustomKeyIds - ? requestedKeyId ?? `${actor}-${actorId}` + ? (requestedKeyId ?? `${actor}-${actorId}`) : `${actor}-${actorId}`; const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId }); @@ -404,7 +404,7 @@ export const sshCertificateAuthorityServiceFactory = ({ // set [keyId] depending on if [allowCustomKeyIds] is true or false const keyId = sshCertificateTemplate.allowCustomKeyIds - ? requestedKeyId ?? `${actor}-${actorId}` + ? (requestedKeyId ?? `${actor}-${actorId}`) : `${actor}-${actorId}`; const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId }); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index c8fb3820c..ae6bbbcab 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -48,6 +48,8 @@ export enum ApiDocsTags { SshCertificates = "SSH Certificates", SshCertificateAuthorities = "SSH Certificate Authorities", SshCertificateTemplates = "SSH Certificate Templates", + SshHosts = "SSH Hosts", + SshHostGroups = "SSH Host Groups", KmsKeys = "KMS Keys", KmsEncryption = "KMS Encryption", KmsSigning = "KMS Signing" @@ -568,6 +570,9 @@ export const PROJECTS = { LIST_SSH_HOSTS: { projectId: "The ID of the project to list SSH hosts for." }, + LIST_SSH_HOST_GROUPS: { + projectId: "The ID of the project to list SSH host groups for." + }, LIST_SSH_CERTIFICATES: { projectId: "The ID of the project to list SSH certificates for.", offset: "The offset to start from. If you enter 10, it will start from the 10th SSH certificate.", @@ -1382,6 +1387,40 @@ export const SSH_CERTIFICATE_TEMPLATES = { } }; +export const SSH_HOST_GROUPS = { + GET: { + sshHostGroupId: "The ID of the SSH host group to get.", + filter: "The filter to apply to the SSH hosts in the SSH host group." + }, + CREATE: { + projectId: "The ID of the project to create the SSH host group in.", + name: "The name of the SSH host group.", + loginMappings: + "A list of default login mappings to include on each host in the SSH host group. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project." + }, + UPDATE: { + sshHostGroupId: "The ID of the SSH host group to update.", + name: "The name of the SSH host group to update to.", + loginMappings: + "A list of default login mappings to include on each host in the SSH host group. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project." + }, + DELETE: { + sshHostGroupId: "The ID of the SSH host group to delete." + }, + LIST_HOSTS: { + offset: "The offset to start from. If you enter 10, it will start from the 10th host", + limit: "The number of hosts to return." + }, + ADD_HOST: { + sshHostGroupId: "The ID of the SSH host group to add the host to.", + hostId: "The ID of the SSH host to add to the SSH host group." + }, + DELETE_HOST: { + sshHostGroupId: "The ID of the SSH host group to delete the host from.", + hostId: "The ID of the SSH host to delete from the SSH host group." + } +}; + export const SSH_HOSTS = { GET: { sshHostId: "The ID of the SSH host to get." @@ -1863,6 +1902,13 @@ export const AppConnections = { instanceUrl: "The Windmill instance URL to connect with (defaults to https://app.windmill.dev).", accessToken: "The access token to use to connect with Windmill." }, + HC_VAULT: { + instanceUrl: "The Hashicrop Vault instance URL to connect with.", + namespace: "The Hashicrop Vault namespace to connect with.", + accessToken: "The access token used to connect with Hashicorp Vault.", + roleId: "The Role ID used to connect with Hashicorp Vault.", + secretId: "The Secret ID used to connect with Hashicorp Vault." + }, LDAP: { provider: "The type of LDAP provider. Determines provider-specific behaviors.", url: "The LDAP/LDAPS URL to connect to (e.g., 'ldap://domain-or-ip:389' or 'ldaps://domain-or-ip:636').", @@ -1876,6 +1922,10 @@ export const AppConnections = { TEAMCITY: { instanceUrl: "The TeamCity instance URL to connect with.", accessToken: "The access token to use to connect with TeamCity." + }, + AZURE_CLIENT_SECRETS: { + code: "The OAuth code to use to connect with Azure Client Secrets.", + tenantId: "The Tenant ID to use to connect with Azure Client Secrets." } } }; @@ -2016,6 +2066,10 @@ export const SecretSyncs = { workspace: "The Windmill workspace to sync secrets to.", path: "The Windmill workspace path to sync secrets to." }, + HC_VAULT: { + mount: "The Hashicorp Vault Secrets Engine Mount to sync secrets to.", + path: "The Hashicorp Vault path to sync secrets to." + }, TEAMCITY: { project: "The TeamCity project to sync secrets to.", buildConfig: "The TeamCity build configuration to sync secrets to." @@ -2084,6 +2138,11 @@ export const SecretRotations = { AUTH0_CLIENT_SECRET: { clientId: "The client ID of the Auth0 Application to rotate the client secret for." }, + AZURE_CLIENT_SECRET: { + objectId: "The ID of the Azure Application to rotate the client secret for.", + appName: "The name of the Azure Application to rotate the client secret for.", + clientId: "The client ID of the Azure Application to rotate the client secret for." + }, LDAP_PASSWORD: { dn: "The Distinguished Name (DN) of the principal to rotate the password for." }, @@ -2114,6 +2173,10 @@ export const SecretRotations = { clientId: "The name of the secret that the client ID will be mapped to.", clientSecret: "The name of the secret that the rotated client secret will be mapped to." }, + AZURE_CLIENT_SECRET: { + clientId: "The name of the secret that the client ID will be mapped to.", + clientSecret: "The name of the secret that the rotated client secret will be mapped to." + }, LDAP_PASSWORD: { dn: "The name of the secret that the Distinguished Name (DN) of the principal will be mapped to.", password: "The name of the secret that the rotated password will be mapped to." diff --git a/backend/src/lib/fn/index.ts b/backend/src/lib/fn/index.ts index 82a4c4914..ae704cf9f 100644 --- a/backend/src/lib/fn/index.ts +++ b/backend/src/lib/fn/index.ts @@ -6,4 +6,5 @@ export * from "./array"; export * from "./dates"; export * from "./object"; export * from "./string"; +export * from "./time"; export * from "./undefined"; diff --git a/backend/src/lib/fn/time.ts b/backend/src/lib/fn/time.ts new file mode 100644 index 000000000..27bd8f8a6 --- /dev/null +++ b/backend/src/lib/fn/time.ts @@ -0,0 +1,21 @@ +import ms, { StringValue } from "ms"; + +const convertToMilliseconds = (exp: string | number): number => { + if (typeof exp === "number") { + return exp * 1000; + } + + const result = ms(exp as StringValue); + if (typeof result !== "number") { + throw new Error(`Invalid expiration format: ${exp}`); + } + + return result; +}; + +export const getMinExpiresIn = (exp1: string | number, exp2: string | number): string | number => { + const ms1 = convertToMilliseconds(exp1); + const ms2 = convertToMilliseconds(exp2); + + return ms1 <= ms2 ? exp1 : exp2; +}; diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index 55d4bf399..b1e011709 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -47,21 +47,21 @@ export const buildFindFilter = if ($in) { Object.entries($in).forEach(([key, val]) => { if (val) { - void bd.whereIn([`${tableName ? `${tableName}.` : ""}${key}`] as never, val as never); + void bd.whereIn(`${tableName ? `${tableName}.` : ""}${key}`, val as never); } }); } if ($notNull?.length) { $notNull.forEach((key) => { - void bd.whereNotNull([`${tableName ? `${tableName}.` : ""}${key as string}`] as never); + void bd.whereNotNull(`${tableName ? `${tableName}.` : ""}${key as string}`); }); } if ($search) { Object.entries($search).forEach(([key, val]) => { if (val) { - void bd.whereILike([`${tableName ? `${tableName}.` : ""}${key}`] as never, val as never); + void bd.whereILike(`${tableName ? `${tableName}.` : ""}${key}`, val as never); } }); } diff --git a/backend/src/lib/workflow-integrations/trigger-notification.ts b/backend/src/lib/workflow-integrations/trigger-notification.ts new file mode 100644 index 000000000..58411bdb0 --- /dev/null +++ b/backend/src/lib/workflow-integrations/trigger-notification.ts @@ -0,0 +1,98 @@ +import { validateMicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns"; +import { sendSlackNotification } from "@app/services/slack/slack-fns"; + +import { logger } from "../logger"; +import { TriggerFeature, TTriggerWorkflowNotificationDTO } from "./types"; + +export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkflowNotificationDTO) => { + try { + const { projectId, notification } = dto.input; + const { projectDAL, projectSlackConfigDAL, kmsService, projectMicrosoftTeamsConfigDAL, microsoftTeamsService } = + dto.dependencies; + + const project = await projectDAL.findById(projectId); + + if (!project) { + return; + } + + const microsoftTeamsConfig = await projectMicrosoftTeamsConfigDAL.getIntegrationDetailsByProject(projectId); + const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId); + + if (slackConfig) { + if (notification.type === TriggerFeature.ACCESS_REQUEST) { + const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || []; + if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) { + await sendSlackNotification({ + orgId: project.orgId, + notification, + kmsService, + targetChannelIds, + slackIntegration: slackConfig + }).catch((error) => { + logger.error(error, "Error sending Slack notification"); + }); + } + } else if (notification.type === TriggerFeature.SECRET_APPROVAL) { + const targetChannelIds = slackConfig.secretRequestChannels?.split(", ") || []; + if (targetChannelIds.length && slackConfig.isSecretRequestNotificationEnabled) { + await sendSlackNotification({ + orgId: project.orgId, + notification, + kmsService, + targetChannelIds, + slackIntegration: slackConfig + }).catch((error) => { + logger.error(error, "Error sending Slack notification"); + }); + } + } + } + + if (microsoftTeamsConfig) { + if (notification.type === TriggerFeature.ACCESS_REQUEST) { + if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) { + const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse( + microsoftTeamsConfig.accessRequestChannels + ); + + if (success && data) { + await microsoftTeamsService + .sendNotification({ + notification, + target: data, + tenantId: microsoftTeamsConfig.tenantId, + microsoftTeamsIntegrationId: microsoftTeamsConfig.id, + orgId: project.orgId + }) + .catch((error) => { + logger.error(error, "Error sending Microsoft Teams notification"); + }); + } + } + } else if (notification.type === TriggerFeature.SECRET_APPROVAL) { + if (microsoftTeamsConfig.isSecretRequestNotificationEnabled && microsoftTeamsConfig.secretRequestChannels) { + const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse( + microsoftTeamsConfig.secretRequestChannels + ); + + if (success && data) { + await microsoftTeamsService + .sendNotification({ + notification, + target: data, + tenantId: microsoftTeamsConfig.tenantId, + microsoftTeamsIntegrationId: microsoftTeamsConfig.id, + orgId: project.orgId + }) + .catch((error) => { + logger.error(error, "Error sending Microsoft Teams notification"); + }); + } + } + } + } + } catch (error) { + logger.error(error, "Error triggering workflow integration notification"); + } +}; diff --git a/backend/src/lib/workflow-integrations/types.ts b/backend/src/lib/workflow-integrations/types.ts new file mode 100644 index 000000000..c18ecb496 --- /dev/null +++ b/backend/src/lib/workflow-integrations/types.ts @@ -0,0 +1,51 @@ +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { TProjectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; + +export enum TriggerFeature { + SECRET_APPROVAL = "secret-approval", + ACCESS_REQUEST = "access-request" +} + +export type TNotification = + | { + type: TriggerFeature.SECRET_APPROVAL; + payload: { + userEmail: string; + environment: string; + secretPath: string; + requestId: string; + projectId: string; + secretKeys: string[]; + }; + } + | { + type: TriggerFeature.ACCESS_REQUEST; + payload: { + requesterFullName: string; + requesterEmail: string; + isTemporary: boolean; + secretPath: string; + environment: string; + projectName: string; + permissions: string[]; + approvalUrl: string; + note?: string; + }; + }; + +export type TTriggerWorkflowNotificationDTO = { + input: { + projectId: string; + notification: TNotification; + }; + dependencies: { + projectDAL: Pick; + projectSlackConfigDAL: Pick; + projectMicrosoftTeamsConfigDAL: Pick; + kmsService: Pick; + microsoftTeamsService: Pick; + }; +}; diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index ad5291a13..57a1313c6 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -111,6 +111,11 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { return; } + // Authentication is handled on a route-level here. + if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) { + return; + } + const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET); if (!authMode) return; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f3d3b1a29..03e23a69d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -103,6 +103,9 @@ import { sshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; import { sshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal"; import { sshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service"; import { sshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal"; +import { sshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; +import { sshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal"; +import { sshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; @@ -175,6 +178,9 @@ import { internalKmsDALFactory } from "@app/services/kms/internal-kms-dal"; import { kmskeyDALFactory } from "@app/services/kms/kms-key-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal"; +import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; @@ -400,6 +406,8 @@ export const registerRoutes = async ( const sshHostDAL = sshHostDALFactory(db); const sshHostLoginUserDAL = sshHostLoginUserDALFactory(db); const sshHostLoginUserMappingDAL = sshHostLoginUserMappingDALFactory(db); + const sshHostGroupDAL = sshHostGroupDALFactory(db); + const sshHostGroupMembershipDAL = sshHostGroupMembershipDALFactory(db); const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); @@ -427,6 +435,8 @@ export const registerRoutes = async ( const githubOrgSyncDAL = githubOrgSyncDALFactory(db); const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL); + const microsoftTeamsIntegrationDAL = microsoftTeamsIntegrationDALFactory(db); + const projectMicrosoftTeamsConfigDAL = projectMicrosoftTeamsConfigDALFactory(db); const permissionService = permissionServiceFactory({ permissionDAL, @@ -624,6 +634,7 @@ export const registerRoutes = async ( tokenService, orgDAL, totpService, + orgMembershipDAL, auditLogService }); const passwordService = authPaswordServiceFactory({ @@ -688,6 +699,15 @@ export const registerRoutes = async ( orgDAL, externalGroupOrgRoleMappingDAL }); + + const microsoftTeamsService = microsoftTeamsServiceFactory({ + microsoftTeamsIntegrationDAL, + permissionService, + workflowIntegrationDAL, + kmsService, + serverCfgDAL: superAdminDAL + }); + const superAdminService = superAdminServiceFactory({ userDAL, identityDAL, @@ -701,7 +721,8 @@ export const registerRoutes = async ( orgService, keyStore, licenseService, - kmsService + kmsService, + microsoftTeamsService }); const orgAdminService = orgAdminServiceFactory({ @@ -852,6 +873,18 @@ export const registerRoutes = async ( kmsService }); + const sshHostGroupService = sshHostGroupServiceFactory({ + projectDAL, + sshHostDAL, + sshHostGroupDAL, + sshHostGroupMembershipDAL, + sshHostLoginUserDAL, + sshHostLoginUserMappingDAL, + userDAL, + permissionService, + licenseService + }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ certificateAuthorityDAL, certificateAuthorityCertDAL, @@ -1022,6 +1055,7 @@ export const registerRoutes = async ( sshCertificateDAL, sshCertificateTemplateDAL, sshHostDAL, + sshHostGroupDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore, @@ -1030,6 +1064,8 @@ export const registerRoutes = async ( certificateTemplateDAL, projectSlackConfigDAL, slackIntegrationDAL, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsIntegrationDAL, projectTemplateService, groupProjectDAL, smtpService @@ -1154,7 +1190,9 @@ export const registerRoutes = async ( userDAL, licenseService, projectSlackConfigDAL, - resourceMetadataDAL + resourceMetadataDAL, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsService }); const secretService = secretServiceFactory({ @@ -1216,7 +1254,9 @@ export const registerRoutes = async ( accessApprovalPolicyApproverDAL, projectSlackConfigDAL, kmsService, - groupDAL + groupDAL, + microsoftTeamsService, + projectMicrosoftTeamsConfigDAL }); const secretReplicationService = secretReplicationServiceFactory({ @@ -1614,6 +1654,7 @@ export const registerRoutes = async ( await dailyResourceCleanUp.startCleanUp(); await dailyExpiringPkiItemAlert.startSendingAlerts(); await kmsService.startService(); + await microsoftTeamsService.start(); // inject all services server.decorate("services", { @@ -1673,6 +1714,7 @@ export const registerRoutes = async ( sshCertificateAuthority: sshCertificateAuthorityService, sshCertificateTemplate: sshCertificateTemplateService, sshHost: sshHostService, + sshHostGroup: sshHostGroupService, certificateAuthority: certificateAuthorityService, certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, @@ -1706,6 +1748,7 @@ export const registerRoutes = async ( kmipOperation: kmipOperationService, gateway: gatewayService, secretRotationV2: secretRotationV2Service, + microsoftTeams: microsoftTeamsService, assumePrivileges: assumePrivilegeService, githubOrgSync: githubOrgSyncConfigService }); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 6eb1804f1..a55aa2ba4 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -27,7 +27,10 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { createdAt: true, updatedAt: true, encryptedSlackClientId: true, - encryptedSlackClientSecret: true + encryptedSlackClientSecret: true, + encryptedMicrosoftTeamsAppId: true, + encryptedMicrosoftTeamsClientSecret: true, + encryptedMicrosoftTeamsBotId: true }).extend({ isMigrationModeOn: z.boolean(), defaultAuthOrgSlug: z.string().nullable(), @@ -74,6 +77,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }), slackClientId: z.string().optional(), slackClientSecret: z.string().optional(), + microsoftTeamsAppId: z.string().optional(), + microsoftTeamsClientSecret: z.string().optional(), + microsoftTeamsBotId: z.string().optional(), authConsentContent: z .string() .trim() @@ -197,15 +203,22 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/integrations/slack/config", + url: "/integrations", config: { rateLimit: readLimit }, schema: { response: { 200: z.object({ - clientId: z.string(), - clientSecret: z.string() + slack: z.object({ + clientId: z.string(), + clientSecret: z.string() + }), + microsoftTeams: z.object({ + appId: z.string(), + clientSecret: z.string(), + botId: z.string() + }) }) } }, @@ -215,9 +228,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }); }, handler: async () => { - const adminSlackConfig = await server.services.superAdmin.getAdminSlackConfig(); + const adminIntegrationsConfig = await server.services.superAdmin.getAdminIntegrationsConfig(); - return adminSlackConfig; + return adminIntegrationsConfig; } }); diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 0b565f7d8..f6c260ea5 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -10,6 +10,10 @@ import { AzureAppConfigurationConnectionListItemSchema, SanitizedAzureAppConfigurationConnectionSchema } from "@app/services/app-connection/azure-app-configuration"; +import { + AzureClientSecretsConnectionListItemSchema, + SanitizedAzureClientSecretsConnectionSchema +} from "@app/services/app-connection/azure-client-secrets"; import { AzureKeyVaultConnectionListItemSchema, SanitizedAzureKeyVaultConnectionSchema @@ -24,6 +28,10 @@ import { } from "@app/services/app-connection/databricks"; import { GcpConnectionListItemSchema, SanitizedGcpConnectionSchema } from "@app/services/app-connection/gcp"; import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github"; +import { + HCVaultConnectionListItemSchema, + SanitizedHCVaultConnectionSchema +} from "@app/services/app-connection/hc-vault"; import { HumanitecConnectionListItemSchema, SanitizedHumanitecConnectionSchema @@ -63,8 +71,10 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedPostgresConnectionSchema.options, ...SanitizedMsSqlConnectionSchema.options, ...SanitizedCamundaConnectionSchema.options, - ...SanitizedWindmillConnectionSchema.options, ...SanitizedAuth0ConnectionSchema.options, + ...SanitizedHCVaultConnectionSchema.options, + ...SanitizedAzureClientSecretsConnectionSchema.options, + ...SanitizedWindmillConnectionSchema.options, ...SanitizedLdapConnectionSchema.options, ...SanitizedTeamCityConnectionSchema.options ]); @@ -82,8 +92,10 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ PostgresConnectionListItemSchema, MsSqlConnectionListItemSchema, CamundaConnectionListItemSchema, - WindmillConnectionListItemSchema, Auth0ConnectionListItemSchema, + HCVaultConnectionListItemSchema, + AzureClientSecretsConnectionListItemSchema, + WindmillConnectionListItemSchema, LdapConnectionListItemSchema, TeamCityConnectionListItemSchema ]); diff --git a/backend/src/server/routes/v1/app-connection-routers/azure-client-secrets-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/azure-client-secrets-connection-router.ts new file mode 100644 index 000000000..f699e60f1 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/azure-client-secrets-connection-router.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateAzureClientSecretsConnectionSchema, + SanitizedAzureClientSecretsConnectionSchema, + UpdateAzureClientSecretsConnectionSchema +} from "@app/services/app-connection/azure-client-secrets"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerAzureClientSecretsConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.AzureClientSecrets, + server, + sanitizedResponseSchema: SanitizedAzureClientSecretsConnectionSchema, + createSchema: CreateAzureClientSecretsConnectionSchema, + updateSchema: UpdateAzureClientSecretsConnectionSchema + }); + + server.route({ + method: "GET", + url: `/:connectionId/clients`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + clients: z.object({ name: z.string(), id: z.string(), appId: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const clients = await server.services.appConnection.azureClientSecrets.listApps(connectionId, req.permission); + + return { clients }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/hc-vault-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/hc-vault-connection-router.ts new file mode 100644 index 000000000..061c02777 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/hc-vault-connection-router.ts @@ -0,0 +1,47 @@ +import z from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateHCVaultConnectionSchema, + SanitizedHCVaultConnectionSchema, + UpdateHCVaultConnectionSchema +} from "@app/services/app-connection/hc-vault"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerHCVaultConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.HCVault, + server, + sanitizedResponseSchema: SanitizedHCVaultConnectionSchema, + createSchema: CreateHCVaultConnectionSchema, + updateSchema: UpdateHCVaultConnectionSchema + }); + + // The following endpoints are for internal Infisical App use only and not part of the public API + server.route({ + method: "GET", + url: `/:connectionId/mounts`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.string().array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const mounts = await server.services.appConnection.hcvault.listMounts(connectionId, req.permission); + return mounts; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index c2398fd78..eeae5e5e3 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -3,11 +3,13 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums import { registerAuth0ConnectionRouter } from "./auth0-connection-router"; import { registerAwsConnectionRouter } from "./aws-connection-router"; import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-configuration-connection-router"; +import { registerAzureClientSecretsConnectionRouter } from "./azure-client-secrets-connection-router"; import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connection-router"; import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; +import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; @@ -26,6 +28,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { handler: async (req) => { const { decodedToken, tokenVersion } = await server.services.authToken.validateRefreshToken(req.cookies.jid); const appCfg = getConfig(); + let expiresIn: string | number = appCfg.JWT_AUTH_LIFETIME; + if (decodedToken.organizationId) { + const org = await server.services.org.findOrganizationById( + decodedToken.userId, + decodedToken.organizationId, + decodedToken.authMethod, + decodedToken.organizationId + ); + if (org && org.userTokenExpiration) { + expiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); + } + } const token = jwt.sign( { @@ -92,7 +105,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { mfaMethod: decodedToken.mfaMethod }, appCfg.AUTH_SECRET, - { expiresIn: appCfg.JWT_AUTH_LIFETIME } + { expiresIn } ); return { token, organizationId: decodedToken.organizationId }; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 50fd33840..a50299555 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -26,6 +26,7 @@ import { registerIdentityUaRouter } from "./identity-universal-auth-router"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; +import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router"; import { registerOrgAdminRouter } from "./org-admin-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; @@ -79,6 +80,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { async (workflowIntegrationRouter) => { await workflowIntegrationRouter.register(registerWorkflowIntegrationRouter); await workflowIntegrationRouter.register(registerSlackRouter, { prefix: "/slack" }); + await workflowIntegrationRouter.register(registerMicrosoftTeamsRouter, { prefix: "/microsoft-teams" }); }, { prefix: "/workflow-integrations" } ); diff --git a/backend/src/server/routes/v1/microsoft-teams-router.ts b/backend/src/server/routes/v1/microsoft-teams-router.ts new file mode 100644 index 000000000..bf24717d5 --- /dev/null +++ b/backend/src/server/routes/v1/microsoft-teams-router.ts @@ -0,0 +1,381 @@ +import { z } from "zod"; + +import { MicrosoftTeamsIntegrationsSchema, WorkflowIntegrationsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { WorkflowIntegrationStatus } from "@app/services/workflow-integration/workflow-integration-types"; + +const sanitizedMicrosoftTeamsIntegrationSchema = WorkflowIntegrationsSchema.pick({ + id: true, + description: true, + slug: true, + integration: true +}).merge( + MicrosoftTeamsIntegrationsSchema.pick({ + tenantId: true + }).extend({ + status: z.nativeEnum(WorkflowIntegrationStatus) + }) +); + +export const registerMicrosoftTeamsRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/client-id", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + clientId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const clientId = await server.services.microsoftTeams.getClientId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { + clientId + }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + body: z.object({ + redirectUri: z.string(), + tenantId: z.string().uuid(), + slug: z.string(), + description: z.string().optional(), + code: z.string().trim() + }) + }, + + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.microsoftTeams.completeMicrosoftTeamsIntegration({ + tenantId: req.body.tenantId, + slug: req.body.slug, + description: req.body.description, + redirectUri: req.body.redirectUri, + code: req.body.code, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CREATE, + metadata: { + tenantId: req.body.tenantId, + slug: req.body.slug, + description: req.body.description + } + } + }); + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + response: { + 200: sanitizedMicrosoftTeamsIntegrationSchema.array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const microsoftTeamsIntegrations = await server.services.microsoftTeams.getMicrosoftTeamsIntegrationsByOrg({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST, + metadata: {} + } + }); + + return microsoftTeamsIntegrations; + } + }); + + server.route({ + method: "POST", + url: "/:id/installation-status", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + id: z.string() + }) + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const microsoftTeamsIntegration = await server.services.microsoftTeams.checkInstallationStatus({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + workflowIntegrationId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CHECK_INSTALLATION_STATUS, + metadata: { + tenantId: microsoftTeamsIntegration.tenantId, + slug: microsoftTeamsIntegration.slug + } + } + }); + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string() + }), + response: { + 200: sanitizedMicrosoftTeamsIntegrationSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const deletedMicrosoftTeamsIntegration = await server.services.microsoftTeams.deleteMicrosoftTeamsIntegration({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_DELETE, + metadata: { + tenantId: deletedMicrosoftTeamsIntegration.tenantId, + slug: deletedMicrosoftTeamsIntegration.slug, + id: deletedMicrosoftTeamsIntegration.id + } + } + }); + + return deletedMicrosoftTeamsIntegration; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string() + }), + response: { + 200: sanitizedMicrosoftTeamsIntegrationSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const microsoftTeamsIntegration = await server.services.microsoftTeams.getMicrosoftTeamsIntegrationById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET, + metadata: { + slug: microsoftTeamsIntegration.slug, + id: microsoftTeamsIntegration.id, + tenantId: microsoftTeamsIntegration.tenantId + } + } + }); + + return microsoftTeamsIntegration; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string() + }), + body: z.object({ + slug: slugSchema({ max: 64 }).optional(), + description: z.string().optional() + }), + response: { + 200: sanitizedMicrosoftTeamsIntegrationSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const microsoftTeamsIntegration = await server.services.microsoftTeams.updateMicrosoftTeamsIntegration({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.id, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_UPDATE, + metadata: { + slug: microsoftTeamsIntegration.slug, + id: microsoftTeamsIntegration.id, + tenantId: microsoftTeamsIntegration.tenantId, + newSlug: req.body.slug, + newDescription: req.body.description + } + } + }); + + return microsoftTeamsIntegration; + } + }); + + server.route({ + method: "GET", + url: "/:workflowIntegrationId/teams", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workflowIntegrationId: z.string() + }), + response: { + 200: z + .object({ + teamId: z.string(), + teamName: z.string(), + channels: z + .object({ + channelName: z.string(), + channelId: z.string() + }) + .array() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const microsoftTeamsIntegration = await server.services.microsoftTeams.getTeams({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + workflowIntegrationId: req.params.workflowIntegrationId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET_TEAMS, + metadata: { + tenantId: microsoftTeamsIntegration.tenantId, + slug: microsoftTeamsIntegration.slug, + id: microsoftTeamsIntegration.id + } + } + }); + + return microsoftTeamsIntegration.teams; + } + }); + + server.route({ + method: "POST", + url: "/message-endpoint", + schema: { + body: z.any(), + response: { + 200: z.any() + } + }, + handler: async (req, res) => { + await server.services.microsoftTeams.handleMessageEndpoint(req, res); + } + }); +}; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 22314b54b..da1a251ff 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -1,3 +1,4 @@ +import RE2 from "re2"; import { z } from "zod"; import { @@ -263,7 +264,18 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { enforceMfa: z.boolean().optional(), selectedMfaMethod: z.nativeEnum(MfaMethod).optional(), allowSecretSharingOutsideOrganization: z.boolean().optional(), - bypassOrgAuthEnabled: z.boolean().optional() + bypassOrgAuthEnabled: z.boolean().optional(), + userTokenExpiration: z + .string() + .refine((val) => new RE2(/^\d+[mhdw]$/).test(val), "Must be a number followed by m, h, d, or w") + .refine( + (val) => { + const numericPart = val.slice(0, -1); + return parseInt(numericPart, 10) >= 1; + }, + { message: "Duration value must be at least 1" } + ) + .optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 7df68f39a..fdc729548 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -14,6 +14,7 @@ import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { ProjectMicrosoftTeamsConfigsSchema } from "@app/db/schemas/project-microsoft-teams-configs"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs"; import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; @@ -21,8 +22,10 @@ import { re2Validator } from "@app/lib/zod"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; +import { validateMicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns"; import { ProjectFilterType, SearchProjectSortBy } from "@app/services/project/project-types"; import { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators"; +import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types"; import { integrationAuthPubSchema, SanitizedProjectSchema } from "../sanitizedSchemas"; import { sanitizedServiceTokenSchema } from "../v2/service-token-router"; @@ -740,55 +743,112 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/slack-config", + url: "/:workspaceId/workflow-integration-config/:integration", config: { rateLimit: readLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + workspaceId: z.string().trim(), + integration: z.nativeEnum(WorkflowIntegration) }), response: { - 200: ProjectSlackConfigsSchema.pick({ - id: true, - slackIntegrationId: true, - isAccessRequestNotificationEnabled: true, - accessRequestChannels: true, - isSecretRequestNotificationEnabled: true, - secretRequestChannels: true + 200: z.discriminatedUnion("integration", [ + ProjectSlackConfigsSchema.pick({ + id: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }).merge( + z.object({ + integration: z.literal(WorkflowIntegration.SLACK), + integrationId: z.string() + }) + ), + ProjectMicrosoftTeamsConfigsSchema.pick({ + id: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }).merge( + z.object({ + integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), + integrationId: z.string() + }) + ) + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const config = await server.services.project.getProjectWorkflowIntegrationConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + integration: req.params.integration + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.workspaceId, + event: { + type: EventType.GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG, + metadata: { + id: config.id, + integration: config.integration + } + } + }); + + return config; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/workflow-integration/:integration/:integrationId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim(), + integration: z.nativeEnum(WorkflowIntegration), + integrationId: z.string() + }), + response: { + 200: z.object({ + integrationConfig: z.object({ + id: z.string() + }) }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const slackConfig = await server.services.project.getProjectSlackConfig({ + const deletedIntegration = await server.services.project.deleteProjectWorkflowIntegration({ actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actor: req.permission.type, actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId + projectId: req.params.projectId, + integration: req.params.integration, + integrationId: req.params.integrationId }); - if (slackConfig) { - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: req.params.workspaceId, - event: { - type: EventType.GET_PROJECT_SLACK_CONFIG, - metadata: { - id: slackConfig.id - } - } - }); - } - - return slackConfig; + return { + integrationConfig: deletedIntegration + }; } }); server.route({ method: "PUT", - url: "/:workspaceId/slack-config", + url: "/:workspaceId/workflow-integration", config: { rateLimit: readLimit }, @@ -796,27 +856,57 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { params: z.object({ workspaceId: z.string().trim() }), - body: z.object({ - slackIntegrationId: z.string(), - isAccessRequestNotificationEnabled: z.boolean(), - accessRequestChannels: validateSlackChannelsField, - isSecretRequestNotificationEnabled: z.boolean(), - secretRequestChannels: validateSlackChannelsField - }), - response: { - 200: ProjectSlackConfigsSchema.pick({ - id: true, - slackIntegrationId: true, - isAccessRequestNotificationEnabled: true, - accessRequestChannels: true, - isSecretRequestNotificationEnabled: true, - secretRequestChannels: true + + body: z.discriminatedUnion("integration", [ + z.object({ + integration: z.literal(WorkflowIntegration.SLACK), + integrationId: z.string(), + accessRequestChannels: validateSlackChannelsField, + secretRequestChannels: validateSlackChannelsField, + isAccessRequestNotificationEnabled: z.boolean(), + isSecretRequestNotificationEnabled: z.boolean() + }), + z.object({ + integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), + integrationId: z.string(), + accessRequestChannels: validateMicrosoftTeamsChannelsSchema, + secretRequestChannels: validateMicrosoftTeamsChannelsSchema, + isAccessRequestNotificationEnabled: z.boolean(), + isSecretRequestNotificationEnabled: z.boolean() }) + ]), + response: { + 200: z.discriminatedUnion("integration", [ + ProjectSlackConfigsSchema.pick({ + id: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }).merge( + z.object({ + integration: z.literal(WorkflowIntegration.SLACK), + integrationId: z.string() + }) + ), + ProjectMicrosoftTeamsConfigsSchema.pick({ + id: true, + isAccessRequestNotificationEnabled: true, + isSecretRequestNotificationEnabled: true + }).merge( + z.object({ + integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), + integrationId: z.string(), + accessRequestChannels: validateMicrosoftTeamsChannelsSchema, + secretRequestChannels: validateMicrosoftTeamsChannelsSchema + }) + ) + ]) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const slackConfig = await server.services.project.updateProjectSlackConfig({ + const workflowIntegrationConfig = await server.services.project.updateProjectWorkflowIntegration({ actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actor: req.permission.type, @@ -829,19 +919,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { ...req.auditLogInfo, projectId: req.params.workspaceId, event: { - type: EventType.UPDATE_PROJECT_SLACK_CONFIG, + type: EventType.UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG, metadata: { - id: slackConfig.id, - slackIntegrationId: slackConfig.slackIntegrationId, - isAccessRequestNotificationEnabled: slackConfig.isAccessRequestNotificationEnabled, - accessRequestChannels: slackConfig.accessRequestChannels, - isSecretRequestNotificationEnabled: slackConfig.isSecretRequestNotificationEnabled, - secretRequestChannels: slackConfig.secretRequestChannels + id: workflowIntegrationConfig.id, + integrationId: workflowIntegrationConfig.integrationId, + integration: workflowIntegrationConfig.integration, + isAccessRequestNotificationEnabled: workflowIntegrationConfig.isAccessRequestNotificationEnabled, + accessRequestChannels: workflowIntegrationConfig.accessRequestChannels, + isSecretRequestNotificationEnabled: workflowIntegrationConfig.isSecretRequestNotificationEnabled, + secretRequestChannels: workflowIntegrationConfig.secretRequestChannels } } }); - return slackConfig; + return workflowIntegrationConfig; } }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/hc-vault-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/hc-vault-sync-router.ts new file mode 100644 index 000000000..97ee6963b --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/hc-vault-sync-router.ts @@ -0,0 +1,17 @@ +import { + CreateHCVaultSyncSchema, + HCVaultSyncSchema, + UpdateHCVaultSyncSchema +} from "@app/services/secret-sync/hc-vault"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerHCVaultSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.HCVault, + server, + responseSchema: HCVaultSyncSchema, + createSchema: CreateHCVaultSyncSchema, + updateSchema: UpdateHCVaultSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index a54777727..75b3ac68e 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -8,6 +8,7 @@ import { registerCamundaSyncRouter } from "./camunda-sync-router"; import { registerDatabricksSyncRouter } from "./databricks-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; +import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; import { registerTeamCitySyncRouter } from "./teamcity-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; @@ -29,5 +30,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { const appCfg = getConfig(); @@ -342,8 +343,12 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { }` ); } + + const serverCfg = await getServerCfg(); return res.redirect( - `${appCfg.SITE_URL}/signup/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + `${appCfg.SITE_URL}/signup/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}${ + serverCfg.defaultAuthOrgId && !appCfg.isCloud ? `&defaultOrgAllowed=true` : "" + }` ); } }); diff --git a/backend/src/server/routes/v1/workflow-integration-router.ts b/backend/src/server/routes/v1/workflow-integration-router.ts index 839d7b056..937a43843 100644 --- a/backend/src/server/routes/v1/workflow-integration-router.ts +++ b/backend/src/server/routes/v1/workflow-integration-router.ts @@ -7,7 +7,8 @@ const sanitizedWorkflowIntegrationSchema = WorkflowIntegrationsSchema.pick({ id: true, description: true, slug: true, - integration: true + integration: true, + status: true }); export const registerWorkflowIntegrationRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index f7540591e..a223004a9 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -14,6 +14,8 @@ import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-s import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema"; import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { LoginMappingSource } from "@app/ee/services/ssh-host/ssh-host-types"; +import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema"; import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; @@ -631,7 +633,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 200: z.object({ hosts: z.array( sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) ) }) @@ -650,4 +656,39 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { hosts }; } }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-host-groups", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_HOST_GROUPS.projectId) + }), + response: { + 200: z.object({ + groups: z.array( + sanitizedSshHostGroup.extend({ + loginMappings: loginMappingSchema.array(), + hostCount: z.number() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const groups = await server.services.project.listProjectSshHostGroups({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { groups }; + } + }); }; diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index d9196dc88..552253cde 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -88,24 +88,41 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { rateLimit: authRateLimit }, schema: { - body: z.object({ - email: z.string().trim(), - firstName: z.string().trim(), - lastName: z.string().trim().optional(), - protectedKey: z.string().trim(), - protectedKeyIV: z.string().trim(), - protectedKeyTag: z.string().trim(), - publicKey: z.string().trim(), - encryptedPrivateKey: z.string().trim(), - encryptedPrivateKeyIV: z.string().trim(), - encryptedPrivateKeyTag: z.string().trim(), - salt: z.string().trim(), - verifier: z.string().trim(), - organizationName: GenericResourceNameSchema, - providerAuthToken: z.string().trim().optional().nullish(), - attributionSource: z.string().trim().optional(), - password: z.string() - }), + body: z + .object({ + email: z.string().trim(), + firstName: z.string().trim(), + lastName: z.string().trim().optional(), + protectedKey: z.string().trim(), + protectedKeyIV: z.string().trim(), + protectedKeyTag: z.string().trim(), + publicKey: z.string().trim(), + encryptedPrivateKey: z.string().trim(), + encryptedPrivateKeyIV: z.string().trim(), + encryptedPrivateKeyTag: z.string().trim(), + salt: z.string().trim(), + verifier: z.string().trim(), + providerAuthToken: z.string().trim().optional().nullish(), + attributionSource: z.string().trim().optional(), + password: z.string() + }) + .and( + z.preprocess( + (data) => { + if (typeof data === "object" && data && "useDefaultOrg" in data === false) { + return { ...data, useDefaultOrg: false }; + } + return data; + }, + z.discriminatedUnion("useDefaultOrg", [ + z.object({ useDefaultOrg: z.literal(true) }), + z.object({ + useDefaultOrg: z.literal(false), + organizationName: GenericResourceNameSchema + }) + ]) + ) + ), response: { 200: z.object({ message: z.string(), diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index de20f3f64..c2912c2b6 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -5,6 +5,7 @@ export enum AppConnection { GCP = "gcp", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", + AzureClientSecrets = "azure-client-secrets", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", Vercel = "vercel", @@ -13,6 +14,7 @@ export enum AppConnection { Camunda = "camunda", Windmill = "windmill", Auth0 = "auth0", + HCVault = "hashicorp-vault", LDAP = "ldap", TeamCity = "teamcity" } diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 92595619c..95afdcbd2 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -23,6 +23,11 @@ import { getAzureAppConfigurationConnectionListItem, validateAzureAppConfigurationConnectionCredentials } from "./azure-app-configuration"; +import { + AzureClientSecretsConnectionMethod, + getAzureClientSecretsConnectionListItem, + validateAzureClientSecretsConnectionCredentials +} from "./azure-client-secrets"; import { AzureKeyVaultConnectionMethod, getAzureKeyVaultConnectionListItem, @@ -36,6 +41,11 @@ import { } from "./databricks"; import { GcpConnectionMethod, getGcpConnectionListItem, validateGcpConnectionCredentials } from "./gcp"; import { getGitHubConnectionListItem, GitHubConnectionMethod, validateGitHubConnectionCredentials } from "./github"; +import { + getHCVaultConnectionListItem, + HCVaultConnectionMethod, + validateHCVaultConnectionCredentials +} from "./hc-vault"; import { getHumanitecConnectionListItem, HumanitecConnectionMethod, @@ -76,8 +86,10 @@ export const listAppConnectionOptions = () => { getPostgresConnectionListItem(), getMsSqlConnectionListItem(), getCamundaConnectionListItem(), + getAzureClientSecretsConnectionListItem(), getWindmillConnectionListItem(), getAuth0ConnectionListItem(), + getHCVaultConnectionListItem(), getLdapConnectionListItem(), getTeamCityConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); @@ -136,6 +148,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureAppConfiguration]: validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.AzureClientSecrets]: + validateAzureClientSecretsConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, @@ -144,6 +158,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Auth0]: validateAuth0ConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.HCVault]: validateHCVaultConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.LDAP]: validateLdapConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.TeamCity]: validateTeamCityConnectionCredentials as TAppConnectionCredentialsValidator }; @@ -157,6 +172,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "GitHub App"; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: + case AzureClientSecretsConnectionMethod.OAuth: case GitHubConnectionMethod.OAuth: return "OAuth"; case AwsConnectionMethod.AccessKey: @@ -177,10 +193,13 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case MsSqlConnectionMethod.UsernameAndPassword: return "Username & Password"; case WindmillConnectionMethod.AccessToken: + case HCVaultConnectionMethod.AccessToken: case TeamCityConnectionMethod.AccessToken: return "Access Token"; case Auth0ConnectionMethod.ClientCredentials: return "Client Credentials"; + case HCVaultConnectionMethod.AppRole: + return "App Role"; case LdapConnectionMethod.SimpleBind: return "Simple Bind"; default: @@ -226,8 +245,10 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.TerraformCloud]: platformManagedCredentialsNotSupported, [AppConnection.Camunda]: platformManagedCredentialsNotSupported, [AppConnection.Vercel]: platformManagedCredentialsNotSupported, + [AppConnection.AzureClientSecrets]: platformManagedCredentialsNotSupported, [AppConnection.Windmill]: platformManagedCredentialsNotSupported, [AppConnection.Auth0]: platformManagedCredentialsNotSupported, + [AppConnection.HCVault]: platformManagedCredentialsNotSupported, [AppConnection.LDAP]: platformManagedCredentialsNotSupported, // we could support this in the future [AppConnection.TeamCity]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 524993b23..05e00446c 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -6,6 +6,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.GCP]: "GCP", [AppConnection.AzureKeyVault]: "Azure Key Vault", [AppConnection.AzureAppConfiguration]: "Azure App Configuration", + [AppConnection.AzureClientSecrets]: "Azure Client Secrets", [AppConnection.Databricks]: "Databricks", [AppConnection.Humanitec]: "Humanitec", [AppConnection.TerraformCloud]: "Terraform Cloud", @@ -15,6 +16,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Camunda]: "Camunda", [AppConnection.Windmill]: "Windmill", [AppConnection.Auth0]: "Auth0", + [AppConnection.HCVault]: "Hashicorp Vault", [AppConnection.LDAP]: "LDAP", [AppConnection.TeamCity]: "TeamCity" }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index e872c07e4..7a8b1a09c 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -32,6 +32,8 @@ import { ValidateAuth0ConnectionCredentialsSchema } from "./auth0"; import { ValidateAwsConnectionCredentialsSchema } from "./aws"; import { awsConnectionService } from "./aws/aws-connection-service"; import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration"; +import { ValidateAzureClientSecretsConnectionCredentialsSchema } from "./azure-client-secrets"; +import { azureClientSecretsConnectionService } from "./azure-client-secrets/azure-client-secrets-service"; import { ValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault"; import { ValidateCamundaConnectionCredentialsSchema } from "./camunda"; import { camundaConnectionService } from "./camunda/camunda-connection-service"; @@ -41,6 +43,8 @@ import { ValidateGcpConnectionCredentialsSchema } from "./gcp"; import { gcpConnectionService } from "./gcp/gcp-connection-service"; import { ValidateGitHubConnectionCredentialsSchema } from "./github"; import { githubConnectionService } from "./github/github-connection-service"; +import { ValidateHCVaultConnectionCredentialsSchema } from "./hc-vault"; +import { hcVaultConnectionService } from "./hc-vault/hc-vault-connection-service"; import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; import { ValidateLdapConnectionCredentialsSchema } from "./ldap"; @@ -76,8 +80,10 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { + const { INF_APP_CONNECTION_AZURE_CLIENT_ID } = getConfig(); + + return { + name: "Azure Client Secrets" as const, + app: AppConnection.AzureClientSecrets as const, + methods: Object.values(AzureClientSecretsConnectionMethod) as [AzureClientSecretsConnectionMethod.OAuth], + oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_ID + }; +}; + +export const getAzureConnectionAccessToken = async ( + connectionId: string, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const appCfg = getConfig(); + if (!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + throw new BadRequestError({ + message: `Azure environment variables have not been configured` + }); + } + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureClientSecrets) { + throw new BadRequestError({ + message: `Connection with ID '${connectionId}' is not an Azure Client Secrets connection` + }); + } + + const credentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureClientSecretsConnectionCredentials; + + const { refreshToken } = credentials; + const currentTime = Date.now(); + + const { data } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", credentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "refresh_token", + scope: `openid offline_access https://graph.microsoft.com/.default`, + client_id: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID, + client_secret: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + refresh_token: refreshToken + }) + ); + + const updatedCredentials = { + ...credentials, + accessToken: data.access_token, + expiresAt: currentTime + data.expires_in * 1000, + refreshToken: data.refresh_token + }; + + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: updatedCredentials, + orgId: appConnection.orgId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); + + return data.access_token; +}; + +export const validateAzureClientSecretsConnectionCredentials = async (config: TAzureClientSecretsConnectionConfig) => { + const { credentials: inputCredentials, method } = config; + + const { INF_APP_CONNECTION_AZURE_CLIENT_ID, INF_APP_CONNECTION_AZURE_CLIENT_SECRET, SITE_URL } = getConfig(); + + if (!SITE_URL) { + throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" }); + } + + if (!INF_APP_CONNECTION_AZURE_CLIENT_ID || !INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + throw new InternalServerError({ + message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse | null = null; + let tokenError: AxiosError | null = null; + + try { + tokenResp = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "authorization_code", + code: inputCredentials.code, + scope: `openid offline_access https://graph.microsoft.com/.default`, + client_id: INF_APP_CONNECTION_AZURE_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` + }) + ); + } catch (e: unknown) { + if (e instanceof AxiosError) { + tokenError = e; + } else { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } + } + + if (tokenError) { + if (tokenError instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + + if (!tokenResp) { + throw new InternalServerError({ + message: `Failed to get access token: Token was empty with no error` + }); + } + + switch (method) { + case AzureClientSecretsConnectionMethod.OAuth: + return { + tenantId: inputCredentials.tenantId, + accessToken: tokenResp.data.access_token, + refreshToken: tokenResp.data.refresh_token, + expiresAt: Date.now() + tokenResp.data.expires_in * 1000 + }; + default: + throw new InternalServerError({ + message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}` + }); + } +}; diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts new file mode 100644 index 000000000..2b4e65a13 --- /dev/null +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums"; + +export const AzureClientSecretsConnectionOAuthInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "OAuth code required").describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.code), + tenantId: z + .string() + .trim() + .min(1, "Tenant ID required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId) +}); + +export const AzureClientSecretsConnectionOAuthOutputCredentialsSchema = z.object({ + tenantId: z.string(), + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.number() +}); + +export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(AzureClientSecretsConnectionMethod.OAuth) + .describe(AppConnections.CREATE(AppConnection.AzureClientSecrets).method), + credentials: AzureClientSecretsConnectionOAuthInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials + ) + }) +]); + +export const CreateAzureClientSecretsConnectionSchema = ValidateAzureClientSecretsConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.AzureClientSecrets) +); + +export const UpdateAzureClientSecretsConnectionSchema = z + .object({ + credentials: AzureClientSecretsConnectionOAuthInputCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureClientSecrets)); + +const BaseAzureClientSecretsConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.AzureClientSecrets) +}); + +export const AzureClientSecretsConnectionSchema = z.intersection( + BaseAzureClientSecretsConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AzureClientSecretsConnectionMethod.OAuth), + credentials: AzureClientSecretsConnectionOAuthOutputCredentialsSchema + }) + ]) +); + +export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion("method", [ + BaseAzureClientSecretsConnectionSchema.extend({ + method: z.literal(AzureClientSecretsConnectionMethod.OAuth), + credentials: AzureClientSecretsConnectionOAuthOutputCredentialsSchema.pick({ + tenantId: true + }) + }) +]); + +export const AzureClientSecretsConnectionListItemSchema = z.object({ + name: z.literal("Azure Client Secrets"), + app: z.literal(AppConnection.AzureClientSecrets), + methods: z.nativeEnum(AzureClientSecretsConnectionMethod).array(), + oauthClientId: z.string().optional() +}); diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts new file mode 100644 index 000000000..fb20fbadd --- /dev/null +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + AzureClientSecretsConnectionOAuthOutputCredentialsSchema, + AzureClientSecretsConnectionSchema, + CreateAzureClientSecretsConnectionSchema, + ValidateAzureClientSecretsConnectionCredentialsSchema +} from "./azure-client-secrets-connection-schemas"; + +export type TAzureClientSecretsConnection = z.infer; + +export type TAzureClientSecretsConnectionInput = z.infer & { + app: AppConnection.AzureClientSecrets; +}; + +export type TValidateAzureClientSecretsConnectionCredentialsSchema = + typeof ValidateAzureClientSecretsConnectionCredentialsSchema; + +export type TAzureClientSecretsConnectionConfig = DiscriminativePick< + TAzureClientSecretsConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TAzureClientSecretsConnectionCredentials = z.infer< + typeof AzureClientSecretsConnectionOAuthOutputCredentialsSchema +>; + +export interface ExchangeCodeAzureResponse { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: number; + access_token: string; + refresh_token: string; + id_token: string; +} + +export interface TAzureRegisteredApp { + id: string; + appId: string; + displayName: string; + description?: string; + createdDateTime: string; + identifierUris?: string[]; + signInAudience?: string; +} + +export interface TAzureListRegisteredAppsResponse { + "@odata.context": string; + "@odata.nextLink"?: string; + value: TAzureRegisteredApp[]; +} + +export interface TAzureClientSecret { + keyId: string; + displayName?: string; + startDateTime: string; + endDateTime: string; + secretText?: string; +} diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-service.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-service.ts new file mode 100644 index 000000000..336c48d58 --- /dev/null +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-service.ts @@ -0,0 +1,68 @@ +import { request } from "@app/lib/config/request"; +import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { + TAzureClientSecretsConnection, + TAzureListRegisteredAppsResponse, + TAzureRegisteredApp +} from "./azure-client-secrets-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +const listAzureRegisteredApps = async ( + appConnection: TAzureClientSecretsConnection, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const accessToken = await getAzureConnectionAccessToken(appConnection.id, appConnectionDAL, kmsService); + + const graphEndpoint = `https://graph.microsoft.com/v1.0/applications`; + + const apps: TAzureRegisteredApp[] = []; + let nextLink = graphEndpoint; + + while (nextLink) { + // eslint-disable-next-line no-await-in-loop + const { data: appsPage } = await request.get(nextLink, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + }); + + apps.push(...appsPage.value); + nextLink = appsPage["@odata.nextLink"] || ""; + } + + return apps; +}; + +export const azureClientSecretsConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const listApps = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.AzureClientSecrets, connectionId, actor); + + const apps = await listAzureRegisteredApps(appConnection, appConnectionDAL, kmsService); + + return apps.map((app) => ({ + id: app.id, + name: app.displayName, + appId: app.appId + })); + }; + + return { + listApps + }; +}; diff --git a/backend/src/services/app-connection/azure-client-secrets/index.ts b/backend/src/services/app-connection/azure-client-secrets/index.ts new file mode 100644 index 000000000..60177973e --- /dev/null +++ b/backend/src/services/app-connection/azure-client-secrets/index.ts @@ -0,0 +1,4 @@ +export * from "./azure-client-secrets-connection-enums"; +export * from "./azure-client-secrets-connection-fns"; +export * from "./azure-client-secrets-connection-schemas"; +export * from "./azure-client-secrets-connection-types"; diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts index 8e8a6b2a7..116597ec4 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts @@ -38,8 +38,12 @@ export const getAzureConnectionAccessToken = async ( throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); } - if (appConnection.app !== AppConnection.AzureKeyVault && appConnection.app !== AppConnection.AzureAppConfiguration) { - throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not an Azure Key Vault connection` }); + if ( + appConnection.app !== AppConnection.AzureKeyVault && + appConnection.app !== AppConnection.AzureAppConfiguration && + appConnection.app !== AppConnection.AzureClientSecrets + ) { + throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not a valid Azure connection` }); } const credentials = (await decryptAppConnectionCredentials({ diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts new file mode 100644 index 000000000..a1e2c8f09 --- /dev/null +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts @@ -0,0 +1,4 @@ +export enum HCVaultConnectionMethod { + AccessToken = "access-token", + AppRole = "app-role" +} diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts new file mode 100644 index 000000000..48695a2a5 --- /dev/null +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -0,0 +1,119 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { HCVaultConnectionMethod } from "./hc-vault-connection-enums"; +import { + THCVaultConnection, + THCVaultConnectionConfig, + THCVaultMountResponse, + TValidateHCVaultConnectionCredentials +} from "./hc-vault-connection-types"; + +export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => { + const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); + + await blockLocalAndPrivateIpAddresses(instanceUrl); + + return instanceUrl; +}; + +export const getHCVaultConnectionListItem = () => ({ + name: "HCVault" as const, + app: AppConnection.HCVault as const, + methods: Object.values(HCVaultConnectionMethod) as [ + HCVaultConnectionMethod.AccessToken, + HCVaultConnectionMethod.AppRole + ] +}); + +type TokenRespData = { + auth: { + client_token: string; + }; +}; + +export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnectionCredentials) => { + // Return access token directly if not using AppRole method + if (connection.method !== HCVaultConnectionMethod.AppRole) { + return connection.credentials.accessToken; + } + + // Generate temporary token for AppRole method + try { + const { instanceUrl, roleId, secretId } = connection.credentials; + const tokenResp = await request.post( + `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`, + { role_id: roleId, secret_id: secretId }, + { + headers: { + "Content-Type": "application/json", + ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) + } + } + ); + + if (tokenResp.status !== 200) { + throw new BadRequestError({ + message: `Unable to validate credentials: Hashicorp Vault responded with a status code of ${tokenResp.status} (${tokenResp.statusText}). Verify credentials and try again.` + }); + } + + return tokenResp.data.auth.client_token; + } catch (e: unknown) { + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } +}; + +export const validateHCVaultConnectionCredentials = async (config: THCVaultConnectionConfig) => { + const instanceUrl = await getHCVaultInstanceUrl(config); + + try { + const accessToken = await getHCVaultAccessToken(config); + + // Verify token + await request.get(`${instanceUrl}/v1/auth/token/lookup-self`, { + headers: { "X-Vault-Token": accessToken } + }); + + return config.credentials; + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } +}; + +export const listHCVaultMounts = async (appConnection: THCVaultConnection) => { + const instanceUrl = await getHCVaultInstanceUrl(appConnection); + const accessToken = await getHCVaultAccessToken(appConnection); + + const { data } = await request.get(`${instanceUrl}/v1/sys/mounts`, { + headers: { + "X-Vault-Token": accessToken, + ...(appConnection.credentials.namespace ? { "X-Vault-Namespace": appConnection.credentials.namespace } : {}) + } + }); + + const mounts: string[] = []; + + // Filter for "kv" version 2 type only + Object.entries(data.data).forEach(([path, mount]) => { + if (mount.type === "kv" && mount.options?.version === "2") { + mounts.push(path); + } + }); + + return mounts; +}; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts new file mode 100644 index 000000000..a6db4d0eb --- /dev/null +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts @@ -0,0 +1,100 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { HCVaultConnectionMethod } from "./hc-vault-connection-enums"; + +const InstanceUrlSchema = z + .string() + .trim() + .min(1, "Instance URL required") + .url("Invalid Instance URL") + .describe(AppConnections.CREDENTIALS.HC_VAULT.instanceUrl); + +const NamespaceSchema = z.string().trim().optional().describe(AppConnections.CREDENTIALS.HC_VAULT.namespace); + +export const HCVaultConnectionAccessTokenCredentialsSchema = z.object({ + instanceUrl: InstanceUrlSchema, + namespace: NamespaceSchema, + accessToken: z + .string() + .trim() + .min(1, "Access Token required") + .describe(AppConnections.CREDENTIALS.HC_VAULT.accessToken) +}); + +export const HCVaultConnectionAppRoleCredentialsSchema = z.object({ + instanceUrl: InstanceUrlSchema, + namespace: NamespaceSchema, + roleId: z.string().trim().min(1, "Role ID required").describe(AppConnections.CREDENTIALS.HC_VAULT.roleId), + secretId: z.string().trim().min(1, "Secret ID required").describe(AppConnections.CREDENTIALS.HC_VAULT.secretId) +}); + +const BaseHCVaultConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.HCVault) }); + +export const HCVaultConnectionSchema = z.intersection( + BaseHCVaultConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(HCVaultConnectionMethod.AccessToken), + credentials: HCVaultConnectionAccessTokenCredentialsSchema + }), + z.object({ + method: z.literal(HCVaultConnectionMethod.AppRole), + credentials: HCVaultConnectionAppRoleCredentialsSchema + }) + ]) +); + +export const SanitizedHCVaultConnectionSchema = z.discriminatedUnion("method", [ + BaseHCVaultConnectionSchema.extend({ + method: z.literal(HCVaultConnectionMethod.AccessToken), + credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({}) + }), + BaseHCVaultConnectionSchema.extend({ + method: z.literal(HCVaultConnectionMethod.AppRole), + credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({}) + }) +]); + +export const ValidateHCVaultConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(HCVaultConnectionMethod.AccessToken) + .describe(AppConnections.CREATE(AppConnection.HCVault).method), + credentials: HCVaultConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.HCVault).credentials + ) + }), + z.object({ + method: z.literal(HCVaultConnectionMethod.AppRole).describe(AppConnections.CREATE(AppConnection.HCVault).method), + credentials: HCVaultConnectionAppRoleCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.HCVault).credentials + ) + }) +]); + +export const CreateHCVaultConnectionSchema = ValidateHCVaultConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault) +); + +export const UpdateHCVaultConnectionSchema = z + .object({ + credentials: z + .union([HCVaultConnectionAccessTokenCredentialsSchema, HCVaultConnectionAppRoleCredentialsSchema]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.HCVault).credentials) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault)); + +export const HCVaultConnectionListItemSchema = z.object({ + name: z.literal("HCVault"), + app: z.literal(AppConnection.HCVault), + methods: z.nativeEnum(HCVaultConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts new file mode 100644 index 000000000..b5cee6fdd --- /dev/null +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts @@ -0,0 +1,30 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listHCVaultMounts } from "./hc-vault-connection-fns"; +import { THCVaultConnection } from "./hc-vault-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const hcVaultConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listMounts = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.HCVault, connectionId, actor); + + try { + const mounts = await listHCVaultMounts(appConnection); + return mounts; + } catch (error) { + logger.error(error, "Failed to establish connection with Hashicorp Vault"); + return []; + } + }; + + return { + listMounts + }; +}; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts new file mode 100644 index 000000000..6f254eda0 --- /dev/null +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts @@ -0,0 +1,35 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateHCVaultConnectionSchema, + HCVaultConnectionSchema, + ValidateHCVaultConnectionCredentialsSchema +} from "./hc-vault-connection-schemas"; + +export type THCVaultConnection = z.infer; + +export type THCVaultConnectionInput = z.infer & { + app: AppConnection.HCVault; +}; + +export type TValidateHCVaultConnectionCredentialsSchema = typeof ValidateHCVaultConnectionCredentialsSchema; + +export type TValidateHCVaultConnectionCredentials = z.infer; + +export type THCVaultConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type THCVaultMountResponse = { + data: { + [key: string]: { + options: { + version?: string | null; + } | null; + type: string; // We're only interested in "kv" types + }; + }; +}; diff --git a/backend/src/services/app-connection/hc-vault/index.ts b/backend/src/services/app-connection/hc-vault/index.ts new file mode 100644 index 000000000..161c2b51c --- /dev/null +++ b/backend/src/services/app-connection/hc-vault/index.ts @@ -0,0 +1,4 @@ +export * from "./hc-vault-connection-enums"; +export * from "./hc-vault-connection-fns"; +export * from "./hc-vault-connection-schemas"; +export * from "./hc-vault-connection-types"; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 0f8ba5176..fdbd5ccd8 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -2,7 +2,7 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import { Knex } from "knex"; -import { OrgMembershipRole, TUsers, UserDeviceSchema } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName, TUsers, UserDeviceSchema } from "@app/db/schemas"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; @@ -12,7 +12,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; -import { removeTrailingSlash } from "@app/lib/fn"; +import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -20,6 +20,8 @@ import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; import { TOrgDALFactory } from "../org/org-dal"; +import { getDefaultOrgMembershipRole } from "../org/org-role-fns"; +import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { LoginMethod } from "../super-admin/super-admin-types"; import { TTotpServiceFactory } from "../totp/totp-service"; @@ -48,6 +50,7 @@ type TAuthLoginServiceFactoryDep = { smtpService: TSmtpService; totpService: Pick; auditLogService: Pick; + orgMembershipDAL: TOrgMembershipDALFactory; }; export type TAuthLoginFactory = ReturnType; @@ -56,6 +59,7 @@ export const authLoginServiceFactory = ({ tokenService, smtpService, orgDAL, + orgMembershipDAL, totpService, auditLogService }: TAuthLoginServiceFactoryDep) => { @@ -143,6 +147,17 @@ export const authLoginServiceFactory = ({ ); if (!tokenSession) throw new Error("Failed to create token"); + let tokenSessionExpiresIn: string | number = cfg.JWT_AUTH_LIFETIME; + let refreshTokenExpiresIn: string | number = cfg.JWT_REFRESH_LIFETIME; + + if (organizationId) { + const org = await orgDAL.findById(organizationId); + if (org && org.userTokenExpiration) { + tokenSessionExpiresIn = getMinExpiresIn(cfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); + refreshTokenExpiresIn = org.userTokenExpiration; + } + } + const accessToken = jwt.sign( { authMethod, @@ -155,7 +170,7 @@ export const authLoginServiceFactory = ({ mfaMethod }, cfg.AUTH_SECRET, - { expiresIn: cfg.JWT_AUTH_LIFETIME } + { expiresIn: tokenSessionExpiresIn } ); const refreshToken = jwt.sign( @@ -170,7 +185,7 @@ export const authLoginServiceFactory = ({ mfaMethod }, cfg.AUTH_SECRET, - { expiresIn: cfg.JWT_REFRESH_LIFETIME } + { expiresIn: refreshTokenExpiresIn } ); return { access: accessToken, refresh: refreshToken }; @@ -386,8 +401,8 @@ export const authLoginServiceFactory = ({ } const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled; - const orgMfaMethod = selectedOrg.enforceMfa ? selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL : undefined; - const userMfaMethod = user.isMfaEnabled ? user.selectedMfaMethod ?? MfaMethod.EMAIL : undefined; + const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; + const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; const mfaMethod = orgMfaMethod ?? userMfaMethod; if (shouldCheckMfa && (!decodedToken.isMfaVerified || decodedToken.mfaMethod !== mfaMethod)) { @@ -558,9 +573,9 @@ export const authLoginServiceFactory = ({ }: TVerifyMfaTokenDTO) => { const appCfg = getConfig(); const user = await userDAL.findById(userId); - enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); try { + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); if (mfaMethod === MfaMethod.EMAIL) { await tokenService.validateTokenForUser({ type: TokenType.TOKEN_EMAIL_MFA, @@ -708,6 +723,35 @@ export const authLoginServiceFactory = ({ authMethods: [authMethod], isGhost: false }); + + if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) { + let orgId = ""; + const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId); + if (!defaultOrg) { + throw new BadRequestError({ + message: `Failed to find default organization with ID ${serverCfg.defaultAuthOrgId}` + }); + } + orgId = defaultOrg.id; + const [orgMembership] = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }); + + if (!orgMembership) { + const { role, roleId } = await getDefaultOrgMembershipRole(defaultOrg.defaultMembershipRole); + + await orgMembershipDAL.create({ + userId: user.id, + inviteEmail: email, + orgId, + role, + roleId, + status: OrgMembershipStatus.Accepted, + isActive: true + }); + } + } } else { const isLinkingRequired = !user?.authMethods?.includes(authMethod); if (isLinkingRequired) { diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index a652c2a5b..4d8c98205 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -9,7 +9,8 @@ import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; -import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { getMinExpiresIn } from "@app/lib/fn"; import { isDisposableEmail } from "@app/lib/validator"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -46,7 +47,7 @@ type TAuthSignupDep = { projectDAL: Pick; projectBotDAL: Pick; groupProjectDAL: Pick; - orgService: Pick; + orgService: Pick; orgDAL: TOrgDALFactory; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; @@ -149,7 +150,8 @@ export const authSignupServiceFactory = ({ encryptedPrivateKeyTag, ip, userAgent, - authorization + authorization, + useDefaultOrg }: TCompleteAccountSignupDTO) => { const appCfg = getConfig(); const serverCfg = await getServerCfg(); @@ -292,15 +294,24 @@ export const authSignupServiceFactory = ({ }); if (!organizationId) { - const newOrganization = await orgService.createOrganization({ - userId: user.id, - userEmail: user.email ?? user.username, - orgName: organizationName - }); + let orgId = ""; + if (useDefaultOrg && serverCfg.defaultAuthOrgId && !appCfg.isCloud) { + const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId); + if (!defaultOrg) throw new BadRequestError({ message: "Failed to find default organization" }); + orgId = defaultOrg.id; + } else { + if (!organizationName) throw new BadRequestError({ message: "Organization name is required" }); + const newOrganization = await orgService.createOrganization({ + userId: user.id, + userEmail: user.email ?? user.username, + orgName: organizationName + }); - if (!newOrganization) throw new Error("Failed to create organization"); + if (!newOrganization) throw new Error("Failed to create organization"); + orgId = newOrganization.id; + } - organizationId = newOrganization.id; + organizationId = orgId; } const updatedMembersips = await orgDAL.updateMembership( @@ -320,6 +331,17 @@ export const authSignupServiceFactory = ({ projectBotDAL }); + let tokenSessionExpiresIn: string | number = appCfg.JWT_AUTH_LIFETIME; + let refreshTokenExpiresIn: string | number = appCfg.JWT_REFRESH_LIFETIME; + + if (organizationId) { + const org = await orgService.findOrganizationById(user.id, organizationId, authMethod, organizationId); + if (org && org.userTokenExpiration) { + tokenSessionExpiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); + refreshTokenExpiresIn = org.userTokenExpiration; + } + } + const tokenSession = await tokenService.getUserTokenSession({ userAgent, ip, @@ -337,7 +359,7 @@ export const authSignupServiceFactory = ({ organizationId }, appCfg.AUTH_SECRET, - { expiresIn: appCfg.JWT_AUTH_LIFETIME } + { expiresIn: tokenSessionExpiresIn } ); const refreshToken = jwt.sign( @@ -350,7 +372,7 @@ export const authSignupServiceFactory = ({ organizationId }, appCfg.AUTH_SECRET, - { expiresIn: appCfg.JWT_REFRESH_LIFETIME } + { expiresIn: refreshTokenExpiresIn } ); return { user: updateduser.info, accessToken, refreshToken, organizationId }; diff --git a/backend/src/services/auth/auth-signup-type.ts b/backend/src/services/auth/auth-signup-type.ts index 3308b9d12..8bbf302c5 100644 --- a/backend/src/services/auth/auth-signup-type.ts +++ b/backend/src/services/auth/auth-signup-type.ts @@ -12,12 +12,13 @@ export type TCompleteAccountSignupDTO = { encryptedPrivateKeyTag: string; salt: string; verifier: string; - organizationName: string; + organizationName?: string; providerAuthToken?: string | null; attributionSource?: string | undefined; ip: string; userAgent: string; authorization: string; + useDefaultOrg?: boolean; }; export type TCompleteAccountInviteDTO = { diff --git a/backend/src/services/microsoft-teams/microsoft-teams-fns.ts b/backend/src/services/microsoft-teams/microsoft-teams-fns.ts new file mode 100644 index 000000000..4111115bf --- /dev/null +++ b/backend/src/services/microsoft-teams/microsoft-teams-fns.ts @@ -0,0 +1,706 @@ +/* eslint-disable class-methods-use-this */ +import axios from "axios"; +import { TeamsActivityHandler, TurnContext } from "botbuilder"; +import jwt from "jsonwebtoken"; +import { Knex } from "knex"; +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { TNotification, TriggerFeature } from "@app/lib/workflow-integrations/types"; + +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { TWorkflowIntegrationDALFactory } from "../workflow-integration/workflow-integration-dal"; +import { WorkflowIntegrationStatus } from "../workflow-integration/workflow-integration-types"; +import { TMicrosoftTeamsIntegrationDALFactory } from "./microsoft-teams-integration-dal"; + +const ConsentError = "AADSTS65001"; + +export const verifyTenantFromCode = async ( + tenantId: string, + code: string, + redirectUri: string, + clientId: string, + clientSecret: string +) => { + const getAccessToken = async (params: URLSearchParams) => { + const response = await axios + .post<{ access_token: string }>(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, params, { + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + }) + .catch((err) => { + if (axios.isAxiosError(err)) { + if ((err.response?.data as { error_description?: string })?.error_description?.includes(ConsentError)) { + throw new BadRequestError({ + message: "Unable to verify tenant, please ensure that you have granted admin consent." + }); + } + logger.error(err.response?.data, "Error fetching Microsoft Teams access token"); + } + throw err; + }); + + return response.data.access_token; + }; + + // Azure App-based auth + const applicationAccessToken = await getAccessToken( + new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + scope: "https://graph.microsoft.com/.default", + redirect_uri: redirectUri, + grant_type: "client_credentials" + }) + ); + + // User-based auth + const authorizationAccessToken = await getAccessToken( + new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + scope: "https://graph.microsoft.com/.default", + redirect_uri: redirectUri, + grant_type: "authorization_code", + code + }) + ); + + // Verify application token + const { tid: tenantIdFromApplicationAccessToken } = jwt.decode(applicationAccessToken) as { tid: string }; + + if (tenantIdFromApplicationAccessToken !== tenantId) { + throw new BadRequestError({ + message: `Invalid application token tenant ID. Expected ${tenantId}, got ${tenantIdFromApplicationAccessToken}` + }); + } + + // Verify user authorization token + const { tid: tenantIdFromAuthorizationAccessToken } = jwt.decode(authorizationAccessToken) as { tid: string }; + + if (tenantIdFromAuthorizationAccessToken !== tenantId) { + throw new BadRequestError({ + message: `Invalid authorization token tenant ID. Expected ${tenantId}, got ${tenantIdFromAuthorizationAccessToken}` + }); + } +}; + +export const getMicrosoftTeamsAccessToken = async ( + { + orgId, + microsoftTeamsIntegrationId, + tenantId, + clientId, + clientSecret, + kmsService, + microsoftTeamsIntegrationDAL, + getBotFrameworkToken + }: { + microsoftTeamsIntegrationId: string; + orgId: string; + tenantId: string; + clientId: string; + clientSecret: string; + kmsService: Pick; + microsoftTeamsIntegrationDAL: Pick; + getBotFrameworkToken?: boolean; + }, + tx?: Knex +) => { + try { + const details = getBotFrameworkToken + ? { + uri: "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token", + scope: "https://api.botframework.com/.default" + } + : { + uri: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, + scope: "https://graph.microsoft.com/.default" + }; + + const integration = await microsoftTeamsIntegrationDAL.findOne( + { + id: microsoftTeamsIntegrationId + }, + tx + ); + + if (!integration) { + throw new BadRequestError({ message: "Microsoft Teams integration not found" }); + } + + if (getBotFrameworkToken) { + // If the token expires within the next 5 minutes, we'll get a new token instead of using the stored one. + const currentTime = new Date(new Date().getTime() + 5 * 60 * 1000); + + if ( + integration.encryptedBotAccessToken && + integration.botAccessTokenExpiresAt && + integration.botAccessTokenExpiresAt > currentTime + ) { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + orgId, + type: KmsDataKey.Organization + }); + + const botAccessToken = decryptor({ + cipherTextBlob: integration.encryptedBotAccessToken + }); + + return botAccessToken.toString(); + } + } else { + // If the token expires within the next 5 minutes, we'll get a new token instead of using the stored one. + const currentTime = new Date(new Date().getTime() + 5 * 60 * 1000); + + if ( + integration.encryptedAccessToken && + integration.accessTokenExpiresAt && + integration.accessTokenExpiresAt > currentTime + ) { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + orgId, + type: KmsDataKey.Organization + }); + + const accessToken = decryptor({ + cipherTextBlob: integration.encryptedAccessToken + }); + + return accessToken.toString(); + } + } + + const tokenResponse = await axios.post<{ access_token: string; expires_in: number }>( + details.uri, + new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + scope: details.scope, + grant_type: "client_credentials" + }) + ); + + if (getBotFrameworkToken) { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + orgId, + type: KmsDataKey.Organization + }); + + const { cipherTextBlob: encryptedBotAccessToken } = encryptor({ + plainText: Buffer.from(tokenResponse.data.access_token) + }); + + const expiresAt = new Date(new Date().getTime() + tokenResponse.data.expires_in * 1000); + + await microsoftTeamsIntegrationDAL.update( + { + id: microsoftTeamsIntegrationId + }, + { + botAccessTokenExpiresAt: expiresAt, + encryptedBotAccessToken + }, + tx + ); + } else { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + orgId, + type: KmsDataKey.Organization + }); + + const { cipherTextBlob: encryptedAccessToken } = encryptor({ + plainText: Buffer.from(tokenResponse.data.access_token) + }); + + const expiresAt = new Date(new Date().getTime() + tokenResponse.data.expires_in * 1000); + + await microsoftTeamsIntegrationDAL.update( + { + id: microsoftTeamsIntegrationId + }, + { + accessTokenExpiresAt: expiresAt, + encryptedAccessToken + }, + tx + ); + } + + return tokenResponse.data.access_token; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error( + error.response?.data, + `getMicrosoftTeamsAccessToken: Error fetching Microsoft Teams access token [status-code=${error.response?.status}]` + ); + } else { + logger.error(error, "getMicrosoftTeamsAccessToken: Error fetching Microsoft Teams access token"); + } + throw error; + } +}; + +export const isBotInstalledInTenant = async ( + { + tenantId, + botAppId, + botAppPassword, + botId, + orgId, + kmsService, + microsoftTeamsIntegrationDAL, + microsoftTeamsIntegrationId + }: { + tenantId: string; + botAppId: string; + botAppPassword: string; + botId: string; + orgId: string; + kmsService: Pick; + microsoftTeamsIntegrationDAL: Pick; + microsoftTeamsIntegrationId: string; + }, + tx?: Knex +) => { + try { + const botAccessToken = await getMicrosoftTeamsAccessToken( + { + tenantId, + clientId: botAppId.toString(), + clientSecret: botAppPassword.toString(), + getBotFrameworkToken: true, + orgId, + kmsService, + microsoftTeamsIntegrationDAL, + microsoftTeamsIntegrationId + }, + tx + ).catch(() => null); + + const accessToken = await getMicrosoftTeamsAccessToken( + { + orgId, + tenantId, + clientId: botAppId.toString(), + clientSecret: botAppPassword.toString(), + kmsService, + microsoftTeamsIntegrationDAL, + microsoftTeamsIntegrationId + }, + tx + ).catch(() => null); + + if (!botAccessToken || !accessToken) { + return { + accessToken: null, + botAccessToken: null, + installed: false, + internalId: null + } as const; + } + + const appsResponse = await axios + .get<{ value: { id: string; displayName: string; distributionMethod: string; externalId: string }[] }>( + "https://graph.microsoft.com/v1.0/appCatalogs/teamsApps", + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ) + .catch((error) => { + logger.error(error, "Error fetching installed apps"); + return null; + }); + + if (!appsResponse) { + return { + installed: false, + internalId: null, + accessToken, + botAccessToken + } as const; + } + + const botInstalledInTenant = appsResponse.data.value.find((a) => a.externalId === botId); + + if (!botInstalledInTenant) { + return { + installed: false, + internalId: null, + accessToken, + botAccessToken + } as const; + } + + return { + installed: true, + internalId: botInstalledInTenant.id, + accessToken, + botAccessToken + } as const; + } catch (error) { + logger.error(error, "Error fetching installed apps"); + return { + installed: false, + internalId: null, + accessToken: null, + botAccessToken: null + } as const; + } +}; + +export const buildTeamsPayload = (notification: TNotification) => { + const appCfg = getConfig(); + + switch (notification.type) { + case TriggerFeature.SECRET_APPROVAL: { + const { payload } = notification; + + const adaptiveCard = { + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.5", + body: [ + { + type: "TextBlock", + text: "Secret approval request", + weight: "Bolder", + size: "Large" + }, + { + type: "TextBlock", + text: `A secret approval request has been opened by ${payload.userEmail}.`, + wrap: true + }, + { + type: "FactSet", + facts: [ + { + title: "Environment", + value: payload.environment + }, + { + title: "Secret path", + value: payload.secretPath || "/" + }, + { + title: `Secret Key${payload.secretKeys.length > 1 ? "s" : ""}`, + value: payload.secretKeys.join(", ") + } + ] + } + ], + actions: [ + { + type: "Action.OpenUrl", + title: "View request in Infisical", + url: `${appCfg.SITE_URL}/secret-manager/${payload.projectId}/approval?requestId=${payload.requestId}` + } + ] + }; + + return { + adaptiveCard + }; + } + + case TriggerFeature.ACCESS_REQUEST: { + const { payload } = notification; + + const adaptiveCard = { + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.5", + body: [ + { + type: "TextBlock", + text: "New access approval request pending for review", + weight: "Bolder", + size: "Large" + }, + { + type: "TextBlock", + text: `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${ + payload.isTemporary ? "temporary" : "permanent" + } access to path '${payload.secretPath}' in the ${payload.environment} environment of ${ + payload.projectName + } project.`, + wrap: true + }, + { + type: "TextBlock", + text: `The following permissions are requested: ${payload.permissions.join(", ")}`, + wrap: true + }, + payload.note + ? { + type: "TextBlock", + text: `**User Note**: ${payload.note}`, + wrap: true + } + : null + ].filter(Boolean), + actions: [ + { + type: "Action.OpenUrl", + title: "View request in Infisical", + url: payload.approvalUrl + } + ] + }; + + return { + adaptiveCard + }; + } + + default: { + throw new BadRequestError({ + message: "Teams notification type not supported." + }); + } + } +}; + +export class TeamsBot extends TeamsActivityHandler { + private botAppId: string; + + private botAppPassword: string; + + private workflowIntegrationDAL: Pick; + + private microsoftTeamsIntegrationDAL: Pick; + + constructor({ + botAppId, + botAppPassword, + workflowIntegrationDAL, + microsoftTeamsIntegrationDAL + }: { + botAppId: string; + botAppPassword: string; + workflowIntegrationDAL: Pick; + microsoftTeamsIntegrationDAL: Pick; + }) { + super(); + + this.botAppId = botAppId; + this.botAppPassword = botAppPassword; + this.workflowIntegrationDAL = workflowIntegrationDAL; + this.microsoftTeamsIntegrationDAL = microsoftTeamsIntegrationDAL; + + // We know when a bot is added, but we can't know when it's fully removed from the tenant. + this.onTeamsMembersAddedEvent(async (membersAdded, _, context) => { + const botWasAdded = membersAdded.some((member) => member.id === context.activity.recipient.id); + + if (botWasAdded && context.activity.conversation.tenantId) { + const microsoftTeamIntegration = await this.microsoftTeamsIntegrationDAL + .findOne({ + tenantId: context.activity.conversation.tenantId + }) + .catch(() => null); + + if (microsoftTeamIntegration) { + await this.workflowIntegrationDAL + .update( + { + id: microsoftTeamIntegration.id, + status: WorkflowIntegrationStatus.PENDING + }, + { + status: WorkflowIntegrationStatus.INSTALLED + } + ) + .catch((error) => { + logger.error(error, "Microsoft Teams Workflow Integration: Failed to update workflow integration"); + }); + } + + // This is required in order for the bot to send proactive messages, which is required for the bot to pass the bot release validation step. + await context.sendActivity( + "👋 Thanks for installing the Infisical app! You can now use the bot to send notifications to your selected teams." + ); + } + }); + } + + async run(context: TurnContext) { + logger.info(context, "Processing Microsoft Teams context"); + await super.run(context); + } + + async sendMessageToChannel( + botAccessToken: string, + tenantId: string, + channelId: string, + teamId: string, + notification: TNotification + ) { + try { + const { adaptiveCard } = buildTeamsPayload(notification); + + const adaptiveCardActivity = { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + content: adaptiveCard + } + ], + conversation: { + id: channelId, + isGroup: true + }, + channelData: { + channel: { + id: channelId + }, + team: { + id: teamId + } + } + }; + + await axios.post( + `https://smba.trafficmanager.net/amer/v3/conversations/${channelId}/activities`, + adaptiveCardActivity, + { + headers: { + Authorization: `Bearer ${botAccessToken}`, + "Content-Type": "application/json" + } + } + ); + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error( + error.response?.data, + `sendMessageToChannel: Axios Error, Microsoft Teams Workflow Integration: Failed to send message to channel [channelId=${channelId}] [teamId=${teamId}] [tenantId=${tenantId}]` + ); + } else { + logger.error( + error, + `sendMessageToChannel: Microsoft Teams Workflow Integration: Failed to send message to channel [channelId=${channelId}] [teamId=${teamId}] [tenantId=${tenantId}]` + ); + } + throw error; + } + } + + async getTeamsAndChannels(accessToken: string, internalAppId: string) { + try { + let teamsNextLink: string = "https://graph.microsoft.com/v1.0/teams"; + + let allTeams: { displayName: string; id: string }[] = []; + while (teamsNextLink?.length) { + try { + // eslint-disable-next-line no-await-in-loop + const response = await axios.get<{ + value: { displayName: string; id: string }[]; + "@odata.nextLink"?: string; + }>(teamsNextLink, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + allTeams = allTeams.concat(response.data.value); + teamsNextLink = response.data["@odata.nextLink"] || ""; + } catch (error) { + logger.error(error, "Microsoft Teams Workflow Integration: Failed to fetch teams"); + throw error; + } + } + + const result = []; + + for await (const team of allTeams) { + try { + // Get installed apps for this team + const installedAppsResponse = await axios.get<{ value: { teamsAppDefinition: { teamsAppId: string } }[] }>( + `https://graph.microsoft.com/v1.0/teams/${team.id}/installedApps?$expand=teamsAppDefinition`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + if (!installedAppsResponse.data.value.some((app) => app.teamsAppDefinition.teamsAppId === internalAppId)) { + // eslint-disable-next-line no-continue + continue; + } + } catch (error) { + // eslint-disable-next-line no-continue + continue; // skip this team if we can't determine if the bot is installed + } + + let allChannels: { displayName: string; id: string }[] = []; + + let channelNextLink: string = `https://graph.microsoft.com/v1.0/teams/${team.id}/channels`; + + while (channelNextLink?.length) { + // eslint-disable-next-line no-await-in-loop + const resp = await axios + .get<{ + value: { displayName: string; id: string }[]; + "@odata.nextLink"?: string; + }>(channelNextLink, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }) + .catch((error) => { + if (axios.isAxiosError(error)) { + logger.error( + error.response?.data, + "getTeamsAndChannels: Axios error, Microsoft Teams Workflow Integration: Failed to fetch channels" + ); + } else { + logger.error( + error, + "getTeamsAndChannels: Microsoft Teams Workflow Integration: Failed to fetch channels" + ); + } + throw error; + }); + + allChannels = allChannels.concat(resp.data.value); + channelNextLink = resp.data["@odata.nextLink"] || ""; + } + + const channels = allChannels.map((channel) => ({ + channelName: channel.displayName, + channelId: channel.id + })); + + result.push({ + teamId: team.id, + teamName: team.displayName, + channels + }); + } + + return result; + } catch (error) { + logger.error(error, "Microsoft Teams Workflow Integration: Error fetching teams and channels"); + throw error; + } + } +} + +export const validateMicrosoftTeamsChannelsSchema = z + .object({ + teamId: z.string(), + channelIds: z.array(z.string()).min(1) + }) + .optional() + .refine((data) => data === undefined || data?.channelIds.length <= 20, { + message: "You can only select up to 20 Microsoft Teams channels" + }); diff --git a/backend/src/services/microsoft-teams/microsoft-teams-integration-dal.ts b/backend/src/services/microsoft-teams/microsoft-teams-integration-dal.ts new file mode 100644 index 000000000..7935d60cc --- /dev/null +++ b/backend/src/services/microsoft-teams/microsoft-teams-integration-dal.ts @@ -0,0 +1,62 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TMicrosoftTeamsIntegrations, TWorkflowIntegrations } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TMicrosoftTeamsIntegrationDALFactory = ReturnType; + +export const microsoftTeamsIntegrationDALFactory = (db: TDbClient) => { + const microsoftTeamsIntegrationOrm = ormify(db, TableName.MicrosoftTeamsIntegrations); + + const findByIdWithWorkflowIntegrationDetails = async (id: string, tx?: Knex) => { + try { + return await (tx || db.replicaNode())(TableName.MicrosoftTeamsIntegrations) + .join( + TableName.WorkflowIntegrations, + `${TableName.MicrosoftTeamsIntegrations}.id`, + `${TableName.WorkflowIntegrations}.id` + ) + .select(selectAllTableCols(TableName.MicrosoftTeamsIntegrations)) + .select(db.ref("orgId").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("description").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("integration").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("slug").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("status").withSchema(TableName.WorkflowIntegrations)) + .where(`${TableName.WorkflowIntegrations}.id`, id) + .first(); + } catch (error) { + throw new DatabaseError({ error, name: "Find by ID with Workflow integration details" }); + } + }; + + const findWithWorkflowIntegrationDetails = async ( + filter: Partial & Partial, + tx?: Knex + ) => { + try { + return await (tx || db.replicaNode())(TableName.MicrosoftTeamsIntegrations) + .join( + TableName.WorkflowIntegrations, + `${TableName.MicrosoftTeamsIntegrations}.id`, + `${TableName.WorkflowIntegrations}.id` + ) + .select(selectAllTableCols(TableName.MicrosoftTeamsIntegrations)) + .select(db.ref("orgId").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("description").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("integration").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("slug").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("status").withSchema(TableName.WorkflowIntegrations)) + .where(filter); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Workflow integration details" }); + } + }; + + return { + ...microsoftTeamsIntegrationOrm, + findByIdWithWorkflowIntegrationDetails, + findWithWorkflowIntegrationDetails + }; +}; diff --git a/backend/src/services/microsoft-teams/microsoft-teams-service.ts b/backend/src/services/microsoft-teams/microsoft-teams-service.ts new file mode 100644 index 000000000..3712a0793 --- /dev/null +++ b/backend/src/services/microsoft-teams/microsoft-teams-service.ts @@ -0,0 +1,710 @@ +import { ForbiddenError } from "@casl/ability"; +import { + CloudAdapter, + ConfigurationBotFrameworkAuthentication, + ConfigurationServiceClientCredentialFactory, + Request, + Response +} from "botbuilder"; +import { FastifyReply, FastifyRequest } from "fastify"; + +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; + +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { TSuperAdminDALFactory } from "../super-admin/super-admin-dal"; +import { TWorkflowIntegrationDALFactory } from "../workflow-integration/workflow-integration-dal"; +import { WorkflowIntegration, WorkflowIntegrationStatus } from "../workflow-integration/workflow-integration-types"; +import { + getMicrosoftTeamsAccessToken, + isBotInstalledInTenant, + TeamsBot, + verifyTenantFromCode +} from "./microsoft-teams-fns"; +import { TMicrosoftTeamsIntegrationDALFactory } from "./microsoft-teams-integration-dal"; +import { + TCheckInstallationStatusDTO, + TCreateMicrosoftTeamsIntegrationDTO, + TDeleteMicrosoftTeamsIntegrationDTO, + TGetClientIdDTO, + TGetMicrosoftTeamsIntegrationByIdDTO, + TGetMicrosoftTeamsIntegrationByOrgDTO, + TGetTeamsDTO, + TSendNotificationDTO, + TUpdateMicrosoftTeamsIntegrationDTO +} from "./microsoft-teams-types"; + +function requestBodyToRecord(body: unknown): Record { + // if body is null or undefined, return an empty object + if (body === null || body === undefined) { + return {}; + } + + // if body is not an object or is an array, return an empty object + if (typeof body !== "object" || Array.isArray(body)) { + return {}; + } + + // at this point, we know body is an object, so safe to cast + return body as Record; +} + +type TMicrosoftTeamsServiceFactoryDep = { + microsoftTeamsIntegrationDAL: Pick< + TMicrosoftTeamsIntegrationDALFactory, + | "deleteById" + | "updateById" + | "create" + | "findOne" + | "findById" + | "findByIdWithWorkflowIntegrationDetails" + | "findWithWorkflowIntegrationDetails" + | "update" + >; + permissionService: Pick; + kmsService: Pick; + workflowIntegrationDAL: Pick< + TWorkflowIntegrationDALFactory, + "transaction" | "create" | "updateById" | "deleteById" | "update" | "findOne" + >; + serverCfgDAL: Pick; +}; + +export type TMicrosoftTeamsServiceFactory = ReturnType; + +const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000"; + +export const microsoftTeamsServiceFactory = ({ + permissionService, + serverCfgDAL, + kmsService, + microsoftTeamsIntegrationDAL, + workflowIntegrationDAL +}: TMicrosoftTeamsServiceFactoryDep) => { + let teamsBot: TeamsBot | null = null; + let adapter: CloudAdapter | null = null; + + const initializeTeamsBot = async ({ botAppId, botAppPassword }: { botAppId: string; botAppPassword: string }) => { + logger.info("Initializing Microsoft Teams bot"); + teamsBot = new TeamsBot({ + botAppId, + botAppPassword, + workflowIntegrationDAL, + microsoftTeamsIntegrationDAL + }); + + adapter = new CloudAdapter( + new ConfigurationBotFrameworkAuthentication( + {}, + new ConfigurationServiceClientCredentialFactory({ + MicrosoftAppId: botAppId, + MicrosoftAppPassword: botAppPassword, + MicrosoftAppType: "MultiTenant" + }) + ) + ); + }; + + const start = async () => { + try { + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + + if ( + serverCfg?.encryptedMicrosoftTeamsAppId && + serverCfg?.encryptedMicrosoftTeamsClientSecret && + serverCfg?.encryptedMicrosoftTeamsBotId + ) { + const decryptWithRoot = kmsService.decryptWithRootKey(); + const decryptedAppId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsAppId); + const decryptedAppPassword = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsClientSecret); + + await initializeTeamsBot({ + botAppId: decryptedAppId.toString(), + botAppPassword: decryptedAppPassword.toString() + }); + } + } catch (err) { + logger.error(err, "Error initializing Microsoft Teams bot on startup"); + } + }; + + const checkInstallationStatus = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + workflowIntegrationId + }: TCheckInstallationStatusDTO) => { + const microsoftTeamsIntegration = + await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails(workflowIntegrationId); + + if (!microsoftTeamsIntegration) { + throw new NotFoundError({ + message: `Microsoft Teams integration with ID ${workflowIntegrationId} not found` + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + microsoftTeamsIntegration.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + if (!serverCfg) { + throw new BadRequestError({ + message: "Failed to get server configuration." + }); + } + + if ( + !serverCfg.encryptedMicrosoftTeamsAppId || + !serverCfg.encryptedMicrosoftTeamsClientSecret || + !serverCfg.encryptedMicrosoftTeamsBotId + ) { + throw new BadRequestError({ + message: "Microsoft Teams app ID, client secret, or bot ID is not set" + }); + } + const decryptWithRoot = kmsService.decryptWithRootKey(); + const decryptedAppId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsAppId); + const decryptedAppPassword = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsClientSecret); + const decryptedBotId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsBotId); + + const teamsBotInfo = await isBotInstalledInTenant({ + tenantId: microsoftTeamsIntegration.tenantId, + botAppId: decryptedAppId.toString(), + botAppPassword: decryptedAppPassword.toString(), + botId: decryptedBotId.toString(), + orgId: microsoftTeamsIntegration.orgId, + kmsService, + microsoftTeamsIntegrationDAL, + microsoftTeamsIntegrationId: microsoftTeamsIntegration.id + }); + + if (!teamsBotInfo.installed) { + if (microsoftTeamsIntegration.status === WorkflowIntegrationStatus.INSTALLED) { + await workflowIntegrationDAL.updateById(microsoftTeamsIntegration.id, { + status: WorkflowIntegrationStatus.PENDING + }); + } + + throw new BadRequestError({ + message: "Microsoft Teams bot is not installed in the configured Microsoft Teams Tenant" + }); + } + + if (microsoftTeamsIntegration.status !== WorkflowIntegrationStatus.INSTALLED) { + await workflowIntegrationDAL.updateById(microsoftTeamsIntegration.id, { + status: WorkflowIntegrationStatus.INSTALLED + }); + } + + return microsoftTeamsIntegration; + }; + + const completeMicrosoftTeamsIntegration = async ({ + code, + actor, + actorId, + actorOrgId, + actorAuthMethod, + tenantId, + slug, + description, + redirectUri + }: TCreateMicrosoftTeamsIntegrationDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + if (!serverCfg) { + throw new BadRequestError({ + message: "Failed to get server configuration." + }); + } + + const { encryptedMicrosoftTeamsAppId, encryptedMicrosoftTeamsClientSecret, encryptedMicrosoftTeamsBotId } = + serverCfg; + + if (!encryptedMicrosoftTeamsAppId || !encryptedMicrosoftTeamsClientSecret || !encryptedMicrosoftTeamsBotId) { + throw new BadRequestError({ + message: "Microsoft Teams app ID, client secret, or bot ID is not set" + }); + } + + const decryptWithRoot = kmsService.decryptWithRootKey(); + const botAppId = decryptWithRoot(encryptedMicrosoftTeamsAppId); + const botAppPassword = decryptWithRoot(encryptedMicrosoftTeamsClientSecret); + const botId = decryptWithRoot(encryptedMicrosoftTeamsBotId); + + await verifyTenantFromCode(tenantId, code, redirectUri, botAppId.toString(), botAppPassword.toString()); + + await workflowIntegrationDAL.transaction(async (tx) => { + const workflowIntegration = await workflowIntegrationDAL.create( + { + description, + orgId: actorOrgId, + slug, + integration: WorkflowIntegration.MICROSOFT_TEAMS, + status: WorkflowIntegrationStatus.PENDING + }, + tx + ); + + const microsoftTeamsIntegration = await microsoftTeamsIntegrationDAL + .create( + { + // @ts-expect-error id is kept as fixed because it is always equal to the workflow integration ID + id: workflowIntegration.id, + tenantId + }, + tx + ) + .catch((err) => { + if (err instanceof DatabaseError) { + if ((err.error as Error)?.stack?.includes("duplicate key value violates unique constraint")) + throw new BadRequestError({ + message: "Microsoft Teams integration with the same Tenant ID already exists." + }); + } + throw err; + }); + + const teamsBotInfo = await isBotInstalledInTenant( + { + tenantId: microsoftTeamsIntegration.tenantId, + botAppId: botAppId.toString(), + botAppPassword: botAppPassword.toString(), + botId: botId.toString(), + orgId: workflowIntegration.orgId, + kmsService, + microsoftTeamsIntegrationDAL, + microsoftTeamsIntegrationId: microsoftTeamsIntegration.id + }, + tx + ); + + if (teamsBotInfo.installed) { + const { encryptor: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({ + orgId: workflowIntegration.orgId, + type: KmsDataKey.Organization + }); + const { cipherTextBlob: encryptedAccessToken } = orgDataKeyEncryptor({ + plainText: Buffer.from(teamsBotInfo.accessToken, "utf8") + }); + + const { cipherTextBlob: encryptedBotAccessToken } = orgDataKeyEncryptor({ + plainText: Buffer.from(teamsBotInfo.botAccessToken, "utf8") + }); + await microsoftTeamsIntegrationDAL.updateById( + microsoftTeamsIntegration.id, + { + internalTeamsAppId: teamsBotInfo.internalId, + encryptedAccessToken, + encryptedBotAccessToken + }, + tx + ); + + await workflowIntegrationDAL.updateById( + workflowIntegration.id, + { + status: WorkflowIntegrationStatus.INSTALLED + }, + tx + ); + } + }); + }; + const getClientId = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGetClientIdDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + if (!serverCfg) { + throw new BadRequestError({ + message: "Failed to get server configuration." + }); + } + + if (!serverCfg.encryptedMicrosoftTeamsAppId) { + throw new BadRequestError({ + message: "Microsoft Teams app ID is not set" + }); + } + + const decryptWithRoot = kmsService.decryptWithRootKey(); + const clientId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsAppId); + + return clientId.toString(); + }; + const getMicrosoftTeamsIntegrationsByOrg = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod + }: TGetMicrosoftTeamsIntegrationByOrgDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + + const microsoftTeamsIntegrations = await microsoftTeamsIntegrationDAL.findWithWorkflowIntegrationDetails({ + orgId: actorOrgId, + status: WorkflowIntegrationStatus.INSTALLED + }); + + return microsoftTeamsIntegrations.map((integration) => ({ + ...integration, + status: integration.status as WorkflowIntegrationStatus, + tenantId: integration.tenantId + })); + }; + + const getMicrosoftTeamsIntegrationById = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TGetMicrosoftTeamsIntegrationByIdDTO) => { + const microsoftTeamsIntegration = await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails(id); + if (!microsoftTeamsIntegration) { + throw new NotFoundError({ + message: "Microsoft Teams integration not found." + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + microsoftTeamsIntegration.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + return { + ...microsoftTeamsIntegration, + status: microsoftTeamsIntegration.status as WorkflowIntegrationStatus + }; + }; + + const updateMicrosoftTeamsIntegration = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id, + slug, + description + }: TUpdateMicrosoftTeamsIntegrationDTO) => { + const microsoftTeamsIntegration = await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails(id); + if (!microsoftTeamsIntegration) { + throw new NotFoundError({ + message: `Microsoft Teams integration with ID ${id} not found` + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + microsoftTeamsIntegration.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + + const updatedIntegration = await workflowIntegrationDAL.transaction(async (tx) => { + await workflowIntegrationDAL.updateById( + microsoftTeamsIntegration.id, + { + slug, + description + }, + tx + ); + + const integration = await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails( + microsoftTeamsIntegration.id, + tx + ); + + if (!integration) { + throw new NotFoundError({ + message: `Microsoft Teams integration with ID ${microsoftTeamsIntegration.id} not found` + }); + } + + return { + ...integration, + status: integration.status as WorkflowIntegrationStatus + }; + }); + + return updatedIntegration; + }; + + const deleteMicrosoftTeamsIntegration = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TDeleteMicrosoftTeamsIntegrationDTO) => { + const microsoftTeamsIntegration = await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails(id); + if (!microsoftTeamsIntegration) { + throw new NotFoundError({ + message: `Microsoft Teams integration with ID ${id} not found` + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + microsoftTeamsIntegration.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); + + await workflowIntegrationDAL.deleteById(id); + + return { + ...microsoftTeamsIntegration, + status: microsoftTeamsIntegration.status as WorkflowIntegrationStatus + }; + }; + + const getTeams = async ({ actorId, actor, actorOrgId, actorAuthMethod, workflowIntegrationId }: TGetTeamsDTO) => { + const microsoftTeamsIntegration = + await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails(workflowIntegrationId); + + if (!microsoftTeamsIntegration) { + throw new NotFoundError({ + message: `Microsoft Teams integration with ID ${workflowIntegrationId} not found` + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + microsoftTeamsIntegration.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + if (!teamsBot || !adapter) { + throw new BadRequestError({ + message: "Unable to get teams and channels because the Microsoft Teams bot is uninitialized" + }); + } + + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + if (!serverCfg) { + throw new BadRequestError({ + message: "Failed to get server configuration." + }); + } + + if ( + !serverCfg.encryptedMicrosoftTeamsAppId || + !serverCfg.encryptedMicrosoftTeamsClientSecret || + !serverCfg.encryptedMicrosoftTeamsBotId + ) { + throw new BadRequestError({ + message: "Microsoft Teams app ID, client secret, or bot ID is not set" + }); + } + + const decryptWithRoot = kmsService.decryptWithRootKey(); + const decryptedAppId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsAppId); + const decryptedAppPassword = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsClientSecret); + const decryptedBotId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsBotId); + + const { installed, internalId, accessToken } = await isBotInstalledInTenant({ + tenantId: microsoftTeamsIntegration.tenantId, + botAppId: decryptedAppId.toString(), + botAppPassword: decryptedAppPassword.toString(), + botId: decryptedBotId.toString(), + orgId: microsoftTeamsIntegration.orgId, + kmsService, + microsoftTeamsIntegrationDAL, + microsoftTeamsIntegrationId: microsoftTeamsIntegration.id + }); + + if (!installed) { + throw new BadRequestError({ + message: "Microsoft Teams bot is not installed in the configured Microsoft Teams Tenant" + }); + } + + const teams = await teamsBot.getTeamsAndChannels(accessToken, internalId); + + return { + ...microsoftTeamsIntegration, + teams + }; + }; + + const handleMessageEndpoint = async (req: FastifyRequest, res: FastifyReply) => { + if (!teamsBot || !adapter) { + throw new BadRequestError({ + message: "Unable to handle message endpoint because the Microsoft Teams bot is uninitialized" + }); + } + + // We need to manually build a Response object because the BotFrameworkAdapter expects a Response object. We are using FastifyReply as the underlying socket. + const response: Response = { + socket: res.raw.socket, + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + end(...args: any[]): unknown { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + res.raw.end(...args); + return this; + }, + + header(name: string, value: unknown): unknown { + res.raw.setHeader(name, value as string); + return this; + }, + + send(...args: unknown[]): unknown { + // For the first argument, which is typically the body + if (args.length > 0) { + const body = args[0]; + + if (typeof body === "string" || Buffer.isBuffer(body)) { + res.raw.write(body); + } else if (body !== null && body !== undefined) { + const json = JSON.stringify(body); + if (!res.raw.headersSent && !res.raw.getHeader("content-type")) { + res.raw.setHeader("content-type", "application/json"); + } + res.raw.write(json); + } + } + + const lastArg = args[args.length - 1]; + if (typeof lastArg === "function") { + lastArg(); + } + + return this; + }, + + status(code: number): unknown { + res.raw.statusCode = code; + return this; + } + }; + + const request: Request = { + body: requestBodyToRecord(req.body), + headers: req.headers, + method: req.method + }; + + await adapter.process(request, response, async (context) => { + await teamsBot?.run(context); + }); + }; + + const sendNotification = async ({ + tenantId, + target, + notification, + orgId, + microsoftTeamsIntegrationId + }: TSendNotificationDTO) => { + if (!teamsBot || !adapter) { + throw new BadRequestError({ + message: "Unable to send notification because the Microsoft Teams bot is uninitialized" + }); + } + + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + if (!serverCfg) { + throw new BadRequestError({ + message: "Failed to get server configuration." + }); + } + + if ( + !serverCfg.encryptedMicrosoftTeamsAppId || + !serverCfg.encryptedMicrosoftTeamsClientSecret || + !serverCfg.encryptedMicrosoftTeamsBotId + ) { + throw new BadRequestError({ + message: "Microsoft Teams app ID, client secret, or bot ID is not set" + }); + } + + const decryptWithRoot = kmsService.decryptWithRootKey(); + const botAppId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsAppId); + const botAppPassword = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsClientSecret); + + const botAccessToken = await getMicrosoftTeamsAccessToken({ + tenantId, + clientId: botAppId.toString(), + clientSecret: botAppPassword.toString(), + getBotFrameworkToken: true, + orgId, + kmsService, + microsoftTeamsIntegrationDAL, + microsoftTeamsIntegrationId + }); + + for await (const channelId of target.channelIds) { + await teamsBot.sendMessageToChannel(botAccessToken, tenantId, channelId, target.teamId, notification); + } + }; + + return { + getMicrosoftTeamsIntegrationsByOrg, + getMicrosoftTeamsIntegrationById, + updateMicrosoftTeamsIntegration, + deleteMicrosoftTeamsIntegration, + completeMicrosoftTeamsIntegration, + initializeTeamsBot, + getTeams, + handleMessageEndpoint, + start, + sendNotification, + checkInstallationStatus, + getClientId + }; +}; diff --git a/backend/src/services/microsoft-teams/microsoft-teams-types.ts b/backend/src/services/microsoft-teams/microsoft-teams-types.ts new file mode 100644 index 000000000..7427cb0d5 --- /dev/null +++ b/backend/src/services/microsoft-teams/microsoft-teams-types.ts @@ -0,0 +1,42 @@ +import { TOrgPermission } from "@app/lib/types"; +import { TNotification } from "@app/lib/workflow-integrations/types"; + +export type TGetMicrosoftTeamsIntegrationByOrgDTO = Omit; + +export type TGetClientIdDTO = Omit; + +export type TCreateMicrosoftTeamsIntegrationDTO = Omit & { + tenantId: string; + slug: string; + redirectUri: string; + description?: string; + code: string; +}; + +export type TCheckInstallationStatusDTO = { workflowIntegrationId: string } & Omit; + +export type TGetMicrosoftTeamsIntegrationByIdDTO = { id: string } & Omit; + +export type TUpdateMicrosoftTeamsIntegrationDTO = { id: string; slug?: string; description?: string } & Omit< + TOrgPermission, + "orgId" +>; + +export type TGetTeamsDTO = Omit & { + workflowIntegrationId: string; +}; + +export type TDeleteMicrosoftTeamsIntegrationDTO = { + id: string; +} & Omit; + +export type TSendNotificationDTO = { + tenantId: string; + microsoftTeamsIntegrationId: string; + orgId: string; + target: { + teamId: string; + channelIds: string[]; + }; + notification: TNotification; +}; diff --git a/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts b/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts new file mode 100644 index 000000000..918b96e89 --- /dev/null +++ b/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TProjectMicrosoftTeamsConfigDALFactory = ReturnType; + +export const projectMicrosoftTeamsConfigDALFactory = (db: TDbClient) => { + const projectMicrosoftTeamsConfigOrm = ormify(db, TableName.ProjectMicrosoftTeamsConfigs); + + const getIntegrationDetailsByProject = (projectId: string, tx?: Knex) => { + return (tx || db.replicaNode())(TableName.ProjectMicrosoftTeamsConfigs) + .join( + TableName.MicrosoftTeamsIntegrations, + `${TableName.ProjectMicrosoftTeamsConfigs}.microsoftTeamsIntegrationId`, + `${TableName.MicrosoftTeamsIntegrations}.id` + ) + .where("projectId", "=", projectId) + .select( + selectAllTableCols(TableName.ProjectMicrosoftTeamsConfigs), + selectAllTableCols(TableName.MicrosoftTeamsIntegrations) + ) + .first(); + }; + + return { ...projectMicrosoftTeamsConfigOrm, getIntegrationDetailsByProject }; +}; diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index 2aa793c04..5a1a4c333 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -17,5 +17,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ shouldUseNewPrivilegeSystem: true, privilegeUpgradeInitiatedByUsername: true, privilegeUpgradeInitiatedAt: true, - bypassOrgAuthEnabled: true + bypassOrgAuthEnabled: true, + userTokenExpiration: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 3a6373575..d794391c1 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -170,8 +170,12 @@ export const orgServiceFactory = ({ actorOrgId: string | undefined ) => { await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + const appCfg = getConfig(); const org = await orgDAL.findOrgById(orgId); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); + if (!org.userTokenExpiration) { + return { ...org, userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME }; + } return org; }; /* @@ -350,7 +354,8 @@ export const orgServiceFactory = ({ enforceMfa, selectedMfaMethod, allowSecretSharingOutsideOrganization, - bypassOrgAuthEnabled + bypassOrgAuthEnabled, + userTokenExpiration } }: TUpdateOrgDTO) => { const appCfg = getConfig(); @@ -451,7 +456,8 @@ export const orgServiceFactory = ({ enforceMfa, selectedMfaMethod, allowSecretSharingOutsideOrganization, - bypassOrgAuthEnabled + bypassOrgAuthEnabled, + userTokenExpiration }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; @@ -692,6 +698,8 @@ export const orgServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + const invitingUser = await userDAL.findOne({ id: actorId }); + const org = await orgDAL.findOrgById(orgId); const [inviteeOrgMembership] = await orgDAL.findMembership({ @@ -725,8 +733,8 @@ export const orgServiceFactory = ({ subjectLine: "Infisical organization invitation", recipients: [inviteeOrgMembership.email as string], substitutions: { - inviterFirstName: inviteeOrgMembership.firstName, - inviterUsername: inviteeOrgMembership.email, + inviterFirstName: invitingUser.firstName, + inviterUsername: invitingUser.email, organizationName: org?.name, email: inviteeOrgMembership.email, organizationId: org?.id.toString(), @@ -755,6 +763,8 @@ export const orgServiceFactory = ({ const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const invitingUser = await userDAL.findOne({ id: actorId }); + const org = await orgDAL.findOrgById(orgId); const isEmailInvalid = await isDisposableEmail(inviteeEmails); @@ -1173,8 +1183,8 @@ export const orgServiceFactory = ({ subjectLine: "Infisical organization invitation", recipients: [el.email], substitutions: { - inviterFirstName: el.firstName, - inviterUsername: el.email, + inviterFirstName: invitingUser.firstName, + inviterUsername: invitingUser.email, organizationName: org?.name, email: el.email, organizationId: org?.id.toString(), diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 8a1698015..702cd25bf 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -74,6 +74,7 @@ export type TUpdateOrgDTO = { selectedMfaMethod: MfaMethod; allowSecretSharingOutsideOrganization: boolean; bypassOrgAuthEnabled: boolean; + userTokenExpiration: string; }>; } & TOrgPermission; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index db67f49d3..ecc8c5a36 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -26,6 +26,7 @@ import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/s import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; +import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; @@ -44,6 +45,9 @@ import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; +import { validateMicrosoftTeamsChannelsSchema } from "../microsoft-teams/microsoft-teams-fns"; +import { TMicrosoftTeamsIntegrationDALFactory } from "../microsoft-teams/microsoft-teams-integration-dal"; +import { TProjectMicrosoftTeamsConfigDALFactory } from "../microsoft-teams/project-microsoft-teams-config-dal"; import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TPkiAlertDALFactory } from "../pki-alert/pki-alert-dal"; @@ -61,9 +65,11 @@ import { fnDeleteProjectSecretReminders } from "../secret/secret-fns"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal"; +import { validateSlackChannelsField } from "../slack/slack-auth-validators"; import { TSlackIntegrationDALFactory } from "../slack/slack-integration-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; +import { WorkflowIntegration, WorkflowIntegrationStatus } from "../workflow-integration/workflow-integration-types"; import { TProjectDALFactory } from "./project-dal"; import { assignWorkspaceKeysToMembers, bootstrapSshProject, createProjectKey } from "./project-fns"; import { TProjectQueueFactory } from "./project-queue"; @@ -71,10 +77,11 @@ import { TProjectSshConfigDALFactory } from "./project-ssh-config-dal"; import { TCreateProjectDTO, TDeleteProjectDTO, + TDeleteProjectWorkflowIntegration, TGetProjectDTO, TGetProjectKmsKey, - TGetProjectSlackConfig, TGetProjectSshConfig, + TGetProjectWorkflowIntegrationConfig, TListProjectAlertsDTO, TListProjectCasDTO, TListProjectCertificateTemplatesDTO, @@ -93,9 +100,9 @@ import { TUpdateProjectDTO, TUpdateProjectKmsDTO, TUpdateProjectNameDTO, - TUpdateProjectSlackConfig, TUpdateProjectSshConfig, TUpdateProjectVersionLimitDTO, + TUpdateProjectWorkflowIntegration, TUpgradeProjectDTO } from "./project-types"; @@ -124,8 +131,19 @@ type TProjectServiceFactoryDep = { "create" | "findProjectGhostUser" | "findOne" | "delete" | "findAllProjectMembers" >; groupProjectDAL: Pick; - projectSlackConfigDAL: Pick; + projectSlackConfigDAL: Pick< + TProjectSlackConfigDALFactory, + "findOne" | "transaction" | "updateById" | "create" | "delete" + >; + projectMicrosoftTeamsConfigDAL: Pick< + TProjectMicrosoftTeamsConfigDALFactory, + "findOne" | "transaction" | "updateById" | "create" | "delete" + >; slackIntegrationDAL: Pick; + microsoftTeamsIntegrationDAL: Pick< + TMicrosoftTeamsIntegrationDALFactory, + "findById" | "findByIdWithWorkflowIntegrationDetails" + >; projectUserMembershipRoleDAL: Pick; certificateAuthorityDAL: Pick; certificateDAL: Pick; @@ -137,12 +155,12 @@ type TProjectServiceFactoryDep = { sshCertificateDAL: Pick; sshCertificateTemplateDAL: Pick; sshHostDAL: Pick; + sshHostGroupDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; queueService: Pick; smtpService: Pick; - orgDAL: Pick; keyStore: Pick; projectBotDAL: Pick; @@ -194,11 +212,14 @@ export const projectServiceFactory = ({ sshCertificateDAL, sshCertificateTemplateDAL, sshHostDAL, + sshHostGroupDAL, keyStore, kmsService, projectBotDAL, projectSlackConfigDAL, + projectMicrosoftTeamsConfigDAL, slackIntegrationDAL, + microsoftTeamsIntegrationDAL, projectTemplateService, groupProjectDAL, smtpService @@ -1147,6 +1168,32 @@ export const projectServiceFactory = ({ return allowedHosts; }; + /** + * Return list of SSH host groups for project + */ + const listProjectSshHostGroups = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshHostsDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups); + + const sshHostGroups = await sshHostGroupDAL.findSshHostGroupsWithLoginMappings(projectId); + + return sshHostGroups; + }; + /** * Return list of SSH certificates for project */ @@ -1456,13 +1503,14 @@ export const projectServiceFactory = ({ return projectSshConfig; }; - const getProjectSlackConfig = async ({ + const getProjectWorkflowIntegrationConfig = async ({ actorId, actor, actorOrgId, actorAuthMethod, - projectId - }: TGetProjectSlackConfig) => { + projectId, + integration + }: TGetProjectWorkflowIntegrationConfig) => { const project = await projectDAL.findById(projectId); if (!project) { throw new NotFoundError({ @@ -1481,23 +1529,60 @@ export const projectServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); - return projectSlackConfigDAL.findOne({ - projectId: project.id + if (integration === WorkflowIntegration.SLACK) { + const config = await projectSlackConfigDAL.findOne({ + projectId: project.id + }); + + if (!config) { + throw new NotFoundError({ + message: `Workflow integration config for project '${projectId}' and integration '${integration}' not found` + }); + } + + return { + ...config, + integration, + integrationId: config.slackIntegrationId + }; + } + + if (integration === WorkflowIntegration.MICROSOFT_TEAMS) { + const config = await projectMicrosoftTeamsConfigDAL.findOne({ + projectId: project.id + }); + + if (!config) { + throw new NotFoundError({ + message: `Workflow integration config for project '${projectId}' and integration '${integration}' not found` + }); + } + + return { + ...config, + integration, + integrationId: config.microsoftTeamsIntegrationId + }; + } + + throw new BadRequestError({ + message: `Integration type '${integration as string}' not supported` }); }; - const updateProjectSlackConfig = async ({ + const updateProjectWorkflowIntegration = async ({ actorId, actor, actorOrgId, actorAuthMethod, projectId, - slackIntegrationId, + integration, + integrationId, isAccessRequestNotificationEnabled, accessRequestChannels, isSecretRequestNotificationEnabled, secretRequestChannels - }: TUpdateProjectSlackConfig) => { + }: TUpdateProjectWorkflowIntegration) => { const project = await projectDAL.findById(projectId); if (!project) { throw new NotFoundError({ @@ -1505,17 +1590,206 @@ export const projectServiceFactory = ({ }); } - const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(slackIntegrationId); - - if (!slackIntegration) { - throw new NotFoundError({ - message: `Slack integration with ID '${slackIntegrationId}' not found` + if (integration === WorkflowIntegration.SLACK) { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.Any }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + const sanitizedAccessRequestChannels = validateSlackChannelsField.parse(accessRequestChannels); + const sanitizedSecretRequestChannels = validateSlackChannelsField.parse(secretRequestChannels); + + const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(integrationId); + + if (!slackIntegration) { + throw new NotFoundError({ + message: `Slack integration with ID '${integrationId}' not found` + }); + } + + if (slackIntegration.orgId !== actorOrgId) { + throw new ForbiddenRequestError({ + message: "Selected slack integration is not in the same organization" + }); + } + + if (slackIntegration.orgId !== project.orgId) { + throw new ForbiddenRequestError({ + message: "Selected slack integration is not in the same organization" + }); + } + + const updatedWorkflowIntegration = await projectSlackConfigDAL.transaction(async (tx) => { + const slackConfig = await projectSlackConfigDAL.findOne( + { + projectId + }, + tx + ); + + if (slackConfig) { + return projectSlackConfigDAL.updateById( + slackConfig.id, + { + slackIntegrationId: integrationId, + isAccessRequestNotificationEnabled, + accessRequestChannels: sanitizedAccessRequestChannels, + isSecretRequestNotificationEnabled, + secretRequestChannels: sanitizedSecretRequestChannels + }, + tx + ); + } + + return projectSlackConfigDAL.create( + { + projectId, + slackIntegrationId: integrationId, + isAccessRequestNotificationEnabled, + accessRequestChannels: sanitizedAccessRequestChannels, + isSecretRequestNotificationEnabled, + secretRequestChannels: sanitizedSecretRequestChannels + }, + tx + ); + }); + + return { + ...updatedWorkflowIntegration, + accessRequestChannels: sanitizedAccessRequestChannels, + secretRequestChannels: sanitizedSecretRequestChannels, + integrationId: slackIntegration.id, + integration: WorkflowIntegration.SLACK + } as const; + } + if (integration === WorkflowIntegration.MICROSOFT_TEAMS) { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.Any + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + if (isAccessRequestNotificationEnabled && !accessRequestChannels) { + throw new BadRequestError({ + message: "Access request channels are required when access request notifications are enabled" + }); + } + + if (isSecretRequestNotificationEnabled && !secretRequestChannels) { + throw new BadRequestError({ + message: "Secret request channels are required when secret request notifications are enabled" + }); + } + + if (!secretRequestChannels && !accessRequestChannels) { + throw new BadRequestError({ + message: "At least one of access request channels or secret request channels is required" + }); + } + + const microsoftTeamsIntegration = + await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails(integrationId); + + if (!microsoftTeamsIntegration) { + throw new NotFoundError({ + message: `Microsoft Teams integration with ID '${integrationId}' not found` + }); + } + + if (microsoftTeamsIntegration.status !== WorkflowIntegrationStatus.INSTALLED) { + throw new BadRequestError({ + message: "Microsoft Teams integration is not properly installed in your tenant." + }); + } + + if (microsoftTeamsIntegration.orgId !== actorOrgId) { + throw new ForbiddenRequestError({ + message: "Selected Microsoft Teams integration is not in the same organization" + }); + } + + if (microsoftTeamsIntegration.orgId !== project.orgId) { + throw new ForbiddenRequestError({ + message: "Selected Microsoft Teams integration is not in the same organization" + }); + } + + const sanitizedAccessRequestChannels = validateMicrosoftTeamsChannelsSchema.parse(accessRequestChannels); + const sanitizedSecretRequestChannels = validateMicrosoftTeamsChannelsSchema.parse(secretRequestChannels); + + const updatedWorkflowIntegration = await projectMicrosoftTeamsConfigDAL.transaction(async (tx) => { + const microsoftTeamsConfig = await projectMicrosoftTeamsConfigDAL.findOne( + { + projectId + }, + tx + ); + + if (microsoftTeamsConfig) { + return projectMicrosoftTeamsConfigDAL.updateById( + microsoftTeamsConfig.id, + { + microsoftTeamsIntegrationId: integrationId, + isAccessRequestNotificationEnabled, + accessRequestChannels: sanitizedAccessRequestChannels || {}, + isSecretRequestNotificationEnabled, + secretRequestChannels: sanitizedSecretRequestChannels || {} + }, + tx + ); + } + + return projectMicrosoftTeamsConfigDAL.create( + { + projectId, + microsoftTeamsIntegrationId: integrationId, + isAccessRequestNotificationEnabled, + accessRequestChannels: sanitizedAccessRequestChannels || {}, + isSecretRequestNotificationEnabled, + secretRequestChannels: sanitizedSecretRequestChannels || {} + }, + tx + ); + }); + + return { + ...updatedWorkflowIntegration, + accessRequestChannels: sanitizedAccessRequestChannels, + secretRequestChannels: sanitizedSecretRequestChannels, + integrationId: microsoftTeamsIntegration.id, + integration: WorkflowIntegration.MICROSOFT_TEAMS + } as const; } - if (slackIntegration.orgId !== actorOrgId) { - throw new ForbiddenRequestError({ - message: "Selected slack integration is not in the same organization" + throw new BadRequestError({ + message: `Integration type '${integration as string}' not supported` + }); + }; + + const deleteProjectWorkflowIntegration = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + integrationId, + integration + }: TDeleteProjectWorkflowIntegration) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: `Project with ID '${projectId}' not found` }); } @@ -1528,47 +1802,28 @@ export const projectServiceFactory = ({ actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Settings); - if (slackIntegration.orgId !== project.orgId) { - throw new ForbiddenRequestError({ - message: "Selected slack integration is not in the same organization" + if (integration === WorkflowIntegration.SLACK) { + const [deletedIntegration] = await projectSlackConfigDAL.delete({ + projectId, + slackIntegrationId: integrationId }); + + return deletedIntegration; } - return projectSlackConfigDAL.transaction(async (tx) => { - const slackConfig = await projectSlackConfigDAL.findOne( - { - projectId - }, - tx - ); + if (integration === WorkflowIntegration.MICROSOFT_TEAMS) { + const [deletedIntegration] = await projectMicrosoftTeamsConfigDAL.delete({ + projectId, + microsoftTeamsIntegrationId: integrationId + }); - if (slackConfig) { - return projectSlackConfigDAL.updateById( - slackConfig.id, - { - slackIntegrationId, - isAccessRequestNotificationEnabled, - accessRequestChannels, - isSecretRequestNotificationEnabled, - secretRequestChannels - }, - tx - ); - } + return deletedIntegration; + } - return projectSlackConfigDAL.create( - { - projectId, - slackIntegrationId, - isAccessRequestNotificationEnabled, - accessRequestChannels, - isSecretRequestNotificationEnabled, - secretRequestChannels - }, - tx - ); + throw new BadRequestError({ + message: `Integration with ID '${integrationId}' not found` }); }; @@ -1669,6 +1924,7 @@ export const projectServiceFactory = ({ listProjectCertificateTemplates, listProjectSshCas, listProjectSshHosts, + listProjectSshHostGroups, listProjectSshCertificates, listProjectSshCertificateTemplates, updateVersionLimit, @@ -1677,10 +1933,11 @@ export const projectServiceFactory = ({ getProjectKmsBackup, loadProjectKmsBackup, getProjectKmsKeys, + getProjectWorkflowIntegrationConfig, + updateProjectWorkflowIntegration, + deleteProjectWorkflowIntegration, getProjectSshConfig, updateProjectSshConfig, - getProjectSlackConfig, - updateProjectSlackConfig, requestProjectAccess, searchProjects }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 274189668..dc26d2357 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -8,6 +8,7 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { WorkflowIntegration } from "../workflow-integration/workflow-integration-types"; enum KmsType { External = "external", @@ -166,14 +167,33 @@ export type TUpdateProjectSshConfig = { export type TGetProjectSshConfig = TProjectPermission; -export type TGetProjectSlackConfig = TProjectPermission; +export type TGetProjectWorkflowIntegrationConfig = TProjectPermission & { + integration: WorkflowIntegration; +}; -export type TUpdateProjectSlackConfig = { - slackIntegrationId: string; - isAccessRequestNotificationEnabled: boolean; - accessRequestChannels: string; - isSecretRequestNotificationEnabled: boolean; - secretRequestChannels: string; +export type TUpdateProjectWorkflowIntegration = ( + | { + integrationId: string; + integration: WorkflowIntegration.SLACK; + isAccessRequestNotificationEnabled: boolean; + isSecretRequestNotificationEnabled: boolean; + accessRequestChannels?: string; + secretRequestChannels?: string; + } + | { + integrationId: string; + integration: WorkflowIntegration.MICROSOFT_TEAMS; + isAccessRequestNotificationEnabled: boolean; + isSecretRequestNotificationEnabled: boolean; + accessRequestChannels?: { teamId: string; channelIds: string[] }; + secretRequestChannels?: { teamId: string; channelIds: string[] }; + } +) & + TProjectPermission; + +export type TDeleteProjectWorkflowIntegration = { + integrationId: string; + integration: WorkflowIntegration; } & TProjectPermission; export type TBootstrapSshProjectDTO = { diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-constants.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-constants.ts new file mode 100644 index 000000000..4210307d2 --- /dev/null +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const HC_VAULT_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Hashicorp Vault", + destination: SecretSync.HCVault, + connection: AppConnection.HCVault, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts new file mode 100644 index 000000000..db35df292 --- /dev/null +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts @@ -0,0 +1,161 @@ +import { isAxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { getHCVaultAccessToken, getHCVaultInstanceUrl } from "@app/services/app-connection/hc-vault"; +import { + THCVaultListVariables, + THCVaultListVariablesResponse, + THCVaultSyncWithCredentials, + TPostHCVaultVariable +} from "@app/services/secret-sync/hc-vault/hc-vault-sync-types"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables) => { + await blockLocalAndPrivateIpAddresses(instanceUrl); + + try { + const { data } = await request.get( + `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, + { + headers: { + "X-Vault-Token": accessToken, + ...(namespace ? { "X-Vault-Namespace": namespace } : {}) + } + } + ); + + return data.data.data; + } catch (error: unknown) { + // Returning an empty set when a path isn't found allows that path to be created by a later POST request + if (isAxiosError(error) && error.response?.status === 404) { + return {}; + } + throw error; + } +}; + +// Hashicorp Vault updates all variables in one batch. This is to respect their versioning +const updateHCVaultVariables = async ({ + path, + instanceUrl, + namespace, + accessToken, + mount, + data +}: TPostHCVaultVariable) => { + await blockLocalAndPrivateIpAddresses(instanceUrl); + + return request.post( + `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, + { + data + }, + { + headers: { + "X-Vault-Token": accessToken, + ...(namespace ? { "X-Vault-Namespace": namespace } : {}), + "Content-Type": "application/json" + } + } + ); +}; + +export const HCVaultSyncFns = { + syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { mount, path }, + syncOptions: { disableSecretDeletion } + } = secretSync; + + const { namespace } = connection.credentials; + const accessToken = await getHCVaultAccessToken(connection); + const instanceUrl = await getHCVaultInstanceUrl(connection); + + const variables = await listHCVaultVariables({ + instanceUrl, + accessToken, + namespace, + mount, + path + }); + let tainted = false; + + for (const entry of Object.entries(secretMap)) { + const [key, { value }] = entry; + if (value !== variables[key]) { + variables[key] = value; + tainted = true; + } + } + + if (disableSecretDeletion) return; + + for await (const [key] of Object.entries(variables)) { + if (!(key in secretMap)) { + delete variables[key]; + tainted = true; + } + } + + // Only update variables if there was a change detected + if (!tainted) return; + + try { + await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables }); + } catch (error) { + throw new SecretSyncError({ + error + }); + } + }, + removeSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { mount, path } + } = secretSync; + + const { namespace } = connection.credentials; + const accessToken = await getHCVaultAccessToken(connection); + const instanceUrl = await getHCVaultInstanceUrl(connection); + + const variables = await listHCVaultVariables({ instanceUrl, namespace, accessToken, mount, path }); + + for await (const [key] of Object.entries(variables)) { + if (key in secretMap) { + delete variables[key]; + } + } + + try { + await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables }); + } catch (error) { + throw new SecretSyncError({ + error + }); + } + }, + getSecrets: async (secretSync: THCVaultSyncWithCredentials) => { + const { + connection, + destinationConfig: { mount, path } + } = secretSync; + + const { namespace } = connection.credentials; + const accessToken = await getHCVaultAccessToken(connection); + const instanceUrl = await getHCVaultInstanceUrl(connection); + + const variables = await listHCVaultVariables({ + instanceUrl, + namespace, + accessToken, + mount, + path + }); + + return Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, { value }])); + } +}; diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts new file mode 100644 index 000000000..d0f2a9f65 --- /dev/null +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts @@ -0,0 +1,58 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const HCVaultSyncDestinationConfigSchema = z.object({ + mount: z + .string() + .trim() + .min(1, "Secrets Engine Mount required") + .describe(SecretSyncs.DESTINATION_CONFIG.HC_VAULT.mount), + path: z + .string() + .trim() + .min(1, "Path required") + .transform((val) => val.replace(/^\/+|\/+$/g, "")) // removes leading/trailing slashes + .refine((val) => new RE2("^([a-zA-Z0-9._-]+/)*[a-zA-Z0-9._-]+$").test(val), { + message: + "Invalid Vault path format. Use alphanumerics, dots, dashes, underscores, and single slashes between segments." + }) + .describe(SecretSyncs.DESTINATION_CONFIG.HC_VAULT.path) +}); + +const HCVaultSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const HCVaultSyncSchema = BaseSecretSyncSchema(SecretSync.HCVault, HCVaultSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.HCVault), + destinationConfig: HCVaultSyncDestinationConfigSchema +}); + +export const CreateHCVaultSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.HCVault, + HCVaultSyncOptionsConfig +).extend({ + destinationConfig: HCVaultSyncDestinationConfigSchema +}); + +export const UpdateHCVaultSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.HCVault, + HCVaultSyncOptionsConfig +).extend({ + destinationConfig: HCVaultSyncDestinationConfigSchema.optional() +}); + +export const HCVaultSyncListItemSchema = z.object({ + name: z.literal("Hashicorp Vault"), + connection: z.literal(AppConnection.HCVault), + destination: z.literal(SecretSync.HCVault), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-types.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-types.ts new file mode 100644 index 000000000..4da823a76 --- /dev/null +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-types.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +import { THCVaultConnection } from "@app/services/app-connection/hc-vault"; + +import { CreateHCVaultSyncSchema, HCVaultSyncListItemSchema, HCVaultSyncSchema } from "./hc-vault-sync-schemas"; + +export type THCVaultSync = z.infer; + +export type THCVaultSyncInput = z.infer; + +export type THCVaultSyncListItem = z.infer; + +export type THCVaultSyncWithCredentials = THCVaultSync & { + connection: THCVaultConnection; +}; + +export type THCVaultListVariablesResponse = { + data: { + data: { + [key: string]: string; + }; + }; +}; + +export type THCVaultListVariables = { + accessToken: string; + instanceUrl: string; + namespace?: string; + mount: string; + path: string; +}; + +export type TPostHCVaultVariable = THCVaultListVariables & { + data: { + [key: string]: string; + }; +}; + +export type TDeleteHCVaultVariable = THCVaultListVariables; diff --git a/backend/src/services/secret-sync/hc-vault/index.ts b/backend/src/services/secret-sync/hc-vault/index.ts new file mode 100644 index 000000000..6fd9f4310 --- /dev/null +++ b/backend/src/services/secret-sync/hc-vault/index.ts @@ -0,0 +1,4 @@ +export * from "./hc-vault-sync-constants"; +export * from "./hc-vault-sync-fns"; +export * from "./hc-vault-sync-schemas"; +export * from "./hc-vault-sync-types"; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 687d76f33..9d59ebb76 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -11,6 +11,7 @@ export enum SecretSync { Camunda = "camunda", Vercel = "vercel", Windmill = "windmill", + HCVault = "hashicorp-vault", TeamCity = "teamcity" } diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 143fa4622..5749852d7 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -25,6 +25,7 @@ import { AZURE_KEY_VAULT_SYNC_LIST_OPTION, azureKeyVaultSyncFactory } from "./az import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda"; import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; +import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; import { TEAMCITY_SYNC_LIST_OPTION, TeamCitySyncFns } from "./teamcity"; @@ -45,6 +46,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Camunda]: CAMUNDA_SYNC_LIST_OPTION, [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION, [SecretSync.Windmill]: WINDMILL_SYNC_LIST_OPTION, + [SecretSync.HCVault]: HC_VAULT_SYNC_LIST_OPTION, [SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION }; @@ -142,6 +144,8 @@ export const SecretSyncFns = { return VercelSyncFns.syncSecrets(secretSync, secretMap); case SecretSync.Windmill: return WindmillSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.HCVault: + return HCVaultSyncFns.syncSecrets(secretSync, secretMap); case SecretSync.TeamCity: return TeamCitySyncFns.syncSecrets(secretSync, secretMap); default: @@ -203,6 +207,9 @@ export const SecretSyncFns = { case SecretSync.Windmill: secretMap = await WindmillSyncFns.getSecrets(secretSync); break; + case SecretSync.HCVault: + secretMap = await HCVaultSyncFns.getSecrets(secretSync); + break; case SecretSync.TeamCity: secretMap = await TeamCitySyncFns.getSecrets(secretSync); break; @@ -259,6 +266,8 @@ export const SecretSyncFns = { return VercelSyncFns.removeSecrets(secretSync, secretMap); case SecretSync.Windmill: return WindmillSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.HCVault: + return HCVaultSyncFns.removeSecrets(secretSync, secretMap); case SecretSync.TeamCity: return TeamCitySyncFns.removeSecrets(secretSync, secretMap); default: @@ -282,7 +291,7 @@ export const parseSyncErrorMessage = (err: unknown): string => { } else if (err instanceof AxiosError) { errorMessage = err?.response?.data ? JSON.stringify(err?.response?.data) - : err?.message ?? "An unknown error occurred."; + : (err?.message ?? "An unknown error occurred."); } else { errorMessage = (err as Error)?.message || "An unknown error occurred."; } diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index fdf4dfabb..c6d7adc8c 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -14,6 +14,7 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Camunda]: "Camunda", [SecretSync.Vercel]: "Vercel", [SecretSync.Windmill]: "Windmill", + [SecretSync.HCVault]: "Hashicorp Vault", [SecretSync.TeamCity]: "TeamCity" }; @@ -30,5 +31,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Camunda]: AppConnection.Camunda, [SecretSync.Vercel]: AppConnection.Vercel, [SecretSync.Windmill]: AppConnection.Windmill, + [SecretSync.HCVault]: AppConnection.HCVault, [SecretSync.TeamCity]: AppConnection.TeamCity }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 34ac84947..62b4ba3cc 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -834,7 +834,7 @@ export const secretSyncQueueFactory = ({ secretPath: folder?.path, environment: environment?.name, projectName: project.name, - syncUrl: `${appCfg.SITE_URL}/integrations/secret-syncs/${destination}/${secretSync.id}` + syncUrl: `${appCfg.SITE_URL}/secret-manager/${projectId}/integrations/secret-syncs/${destination}/${secretSync.id}` } }); }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index e99b31c20..e88174cc6 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -55,6 +55,12 @@ import { TAzureKeyVaultSyncWithCredentials } from "./azure-key-vault"; import { TGcpSync, TGcpSyncInput, TGcpSyncListItem, TGcpSyncWithCredentials } from "./gcp"; +import { + THCVaultSync, + THCVaultSyncInput, + THCVaultSyncListItem, + THCVaultSyncWithCredentials +} from "./hc-vault/hc-vault-sync-types"; import { THumanitecSync, THumanitecSyncInput, @@ -88,6 +94,7 @@ export type TSecretSync = | TCamundaSync | TVercelSync | TWindmillSync + | THCVaultSync | TTeamCitySync; export type TSecretSyncWithCredentials = @@ -103,6 +110,7 @@ export type TSecretSyncWithCredentials = | TCamundaSyncWithCredentials | TVercelSyncWithCredentials | TWindmillSyncWithCredentials + | THCVaultSyncWithCredentials | TTeamCitySyncWithCredentials; export type TSecretSyncInput = @@ -118,6 +126,7 @@ export type TSecretSyncInput = | TCamundaSyncInput | TVercelSyncInput | TWindmillSyncInput + | THCVaultSyncInput | TTeamCitySyncInput; export type TSecretSyncListItem = @@ -133,6 +142,7 @@ export type TSecretSyncListItem = | TCamundaSyncListItem | TVercelSyncListItem | TWindmillSyncListItem + | THCVaultSyncListItem | TTeamCitySyncListItem; export type TSyncOptionsConfig = { diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 6a0868741..714df0d3f 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -740,7 +740,7 @@ export const secretQueueFactory = ({ environment: jobPayload.environmentName, count: jobPayload.count, projectName: project.name, - integrationUrl: `${appCfg.SITE_URL}/integrations/${project.id}` + integrationUrl: `${appCfg.SITE_URL}/secret-manager/${project.id}/integrations?selectedTab=native-integrations` } }); } diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index 6c84c0e76..d4f5e29cc 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -3,12 +3,10 @@ import { WebClient } from "@slack/web-api"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { TNotification, TriggerFeature } from "@app/lib/workflow-integrations/types"; -import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; -import { TProjectDALFactory } from "../project/project-dal"; -import { TProjectSlackConfigDALFactory } from "./project-slack-config-dal"; -import { SlackTriggerFeature, TSlackNotification } from "./slack-types"; +import { TSendSlackNotificationDTO } from "./slack-types"; export const fetchSlackChannels = async (botKey: string) => { const slackChannels: { @@ -41,11 +39,11 @@ export const fetchSlackChannels = async (botKey: string) => { return slackChannels; }; -const buildSlackPayload = (notification: TSlackNotification) => { +const buildSlackPayload = (notification: TNotification) => { const appCfg = getConfig(); switch (notification.type) { - case SlackTriggerFeature.SECRET_APPROVAL: { + case TriggerFeature.SECRET_APPROVAL: { const { payload } = notification; const messageBody = `A secret approval request has been opened by ${payload.userEmail}. *Environment*: ${payload.environment} @@ -79,7 +77,7 @@ View the complete details <${appCfg.SITE_URL}/secret-manager/${payload.projectId payloadBlocks }; } - case SlackTriggerFeature.ACCESS_REQUEST: { + case TriggerFeature.ACCESS_REQUEST: { const { payload } = notification; const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${ payload.isTemporary ? "temporary" : "permanent" @@ -125,51 +123,24 @@ User Note: ${payload.note}` } }; -export const triggerSlackNotification = async ({ - projectId, +export const sendSlackNotification = async ({ + orgId, notification, - projectSlackConfigDAL, - projectDAL, - kmsService -}: { - projectId: string; - notification: TSlackNotification; - projectSlackConfigDAL: Pick; - projectDAL: Pick; - kmsService: Pick; -}) => { - const { payloadMessage, payloadBlocks } = buildSlackPayload(notification); - const project = await projectDAL.findById(projectId); - const slackIntegration = await projectSlackConfigDAL.getIntegrationDetailsByProject(project.id); - - if (!slackIntegration) { - return; - } - - let targetChannelIds: string[] = []; - if (notification.type === SlackTriggerFeature.ACCESS_REQUEST) { - targetChannelIds = slackIntegration.accessRequestChannels?.split(", ") || []; - if (!targetChannelIds.length || !slackIntegration.isAccessRequestNotificationEnabled) { - return; - } - } else if (notification.type === SlackTriggerFeature.SECRET_APPROVAL) { - targetChannelIds = slackIntegration.secretRequestChannels?.split(", ") || []; - if (!targetChannelIds.length || !slackIntegration.isSecretRequestNotificationEnabled) { - return; - } - } - + kmsService, + targetChannelIds, + slackIntegration +}: TSendSlackNotificationDTO) => { const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: project.orgId + orgId }); - const botKey = orgDataKeyDecryptor({ cipherTextBlob: slackIntegration.encryptedBotAccessToken }).toString("utf8"); - const slackWebClient = new WebClient(botKey); + const { payloadMessage, payloadBlocks } = buildSlackPayload(notification); + for await (const conversationId of targetChannelIds) { // we send both text and blocks for compatibility with barebone clients await slackWebClient.chat diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index 3e8354adf..20395b1cd 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -1,4 +1,8 @@ +import { TSlackIntegrations } from "@app/db/schemas"; import { TOrgPermission } from "@app/lib/types"; +import { TNotification } from "@app/lib/workflow-integrations/types"; + +import { TKmsServiceFactory } from "../kms/kms-service"; export type TGetSlackInstallUrlDTO = { slug: string; @@ -48,34 +52,10 @@ export type TReinstallSlackIntegrationDTO = { slackBotUserId: string; }; -export enum SlackTriggerFeature { - SECRET_APPROVAL = "secret-approval", - ACCESS_REQUEST = "access-request" -} - -export type TSlackNotification = - | { - type: SlackTriggerFeature.SECRET_APPROVAL; - payload: { - userEmail: string; - environment: string; - secretPath: string; - requestId: string; - projectId: string; - secretKeys: string[]; - }; - } - | { - type: SlackTriggerFeature.ACCESS_REQUEST; - payload: { - requesterFullName: string; - requesterEmail: string; - isTemporary: boolean; - secretPath: string; - environment: string; - projectName: string; - permissions: string[]; - approvalUrl: string; - note?: string; - }; - }; +export type TSendSlackNotificationDTO = { + orgId: string; + notification: TNotification; + kmsService: Pick; + targetChannelIds: string[]; + slackIntegration: TSlackIntegrations; +}; diff --git a/backend/src/services/smtp/emails/AccessApprovalRequestTemplate.tsx b/backend/src/services/smtp/emails/AccessApprovalRequestTemplate.tsx new file mode 100644 index 000000000..fef072546 --- /dev/null +++ b/backend/src/services/smtp/emails/AccessApprovalRequestTemplate.tsx @@ -0,0 +1,95 @@ +import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface AccessApprovalRequestTemplateProps extends Omit { + projectName: string; + requesterFullName: string; + requesterEmail: string; + isTemporary: boolean; + secretPath: string; + environment: string; + expiresIn: string; + permissions: string[]; + note?: string; + approvalUrl: string; +} + +export const AccessApprovalRequestTemplate = ({ + projectName, + siteUrl, + requesterFullName, + requesterEmail, + isTemporary, + secretPath, + environment, + expiresIn, + permissions, + note, + approvalUrl +}: AccessApprovalRequestTemplateProps) => { + return ( + + + You have a new access approval request pending review for the project {projectName} + +
+ + {requesterFullName} ( + + {requesterEmail} + + ) has requested {isTemporary ? "temporary" : "permanent"} access to {secretPath} in the{" "} + {environment} environment. + + + {isTemporary && ( + + This access will expire {expiresIn} after approval. + + )} + + The following permissions are requested: + + {permissions.map((permission) => ( + + - {permission} + + ))} + {note && ( + + User Note: "{note}" + + )} +
+
+ +
+
+ ); +}; + +export default AccessApprovalRequestTemplate; + +AccessApprovalRequestTemplate.PreviewProps = { + requesterFullName: "Abigail Williams", + requesterEmail: "abigail@infisical.com", + isTemporary: true, + secretPath: "/api/secrets", + environment: "Production", + siteUrl: "https://infisical.com", + projectName: "Example Project", + expiresIn: "1 day", + permissions: ["Read Secret", "Delete Project", "Create Dynamic Secret"], + note: "I need access to these permissions for the new initiative for HR." +} as AccessApprovalRequestTemplateProps; diff --git a/backend/src/services/smtp/emails/BaseEmailWrapper.tsx b/backend/src/services/smtp/emails/BaseEmailWrapper.tsx new file mode 100644 index 000000000..3d02fc793 --- /dev/null +++ b/backend/src/services/smtp/emails/BaseEmailWrapper.tsx @@ -0,0 +1,45 @@ +import { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Tailwind, Text } from "@react-email/components"; +import React, { ReactNode } from "react"; + +export interface BaseEmailWrapperProps { + title: string; + preview: string; + siteUrl: string; + children?: ReactNode; +} + +export const BaseEmailWrapper = ({ title, preview, children, siteUrl }: BaseEmailWrapperProps) => { + return ( + + + + + {preview} + +
+
+
+ Infisical Logo +
+
+
{children}
+
+
+ + Email sent via{" "} + + Infisical + + +
+ + + + + ); +}; diff --git a/backend/src/services/smtp/emails/EmailMfaTemplate.tsx b/backend/src/services/smtp/emails/EmailMfaTemplate.tsx new file mode 100644 index 000000000..b01e2f8af --- /dev/null +++ b/backend/src/services/smtp/emails/EmailMfaTemplate.tsx @@ -0,0 +1,50 @@ +import { Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface EmailMfaTemplateProps extends Omit { + code: string; + isCloud: boolean; +} + +export const EmailMfaTemplate = ({ code, siteUrl, isCloud }: EmailMfaTemplateProps) => { + return ( + + + MFA required + +
+ Enter the MFA code shown below in the browser where you started sign-in. + + {code} + +
+
+ + Not you?{" "} + {isCloud ? ( + <> + Contact us at{" "} + + support@infisical.com + {" "} + immediately + + ) : ( + "Contact your administrator immediately" + )} + . + +
+
+ ); +}; + +export default EmailMfaTemplate; + +EmailMfaTemplate.PreviewProps = { + code: "124356", + isCloud: true, + siteUrl: "https://infisical.com" +} as EmailMfaTemplateProps; diff --git a/backend/src/services/smtp/emails/EmailVerificationTemplate.tsx b/backend/src/services/smtp/emails/EmailVerificationTemplate.tsx new file mode 100644 index 000000000..fc32b01b0 --- /dev/null +++ b/backend/src/services/smtp/emails/EmailVerificationTemplate.tsx @@ -0,0 +1,53 @@ +import { Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface EmailVerificationTemplateProps extends Omit { + code: string; + isCloud: boolean; +} + +export const EmailVerificationTemplate = ({ code, siteUrl, isCloud }: EmailVerificationTemplateProps) => { + return ( + + + Confirm your email address + +
+ Enter the confirmation code shown below in the browser window requiring confirmation. + + {code} + +
+
+ + Questions about Infisical?{" "} + {isCloud ? ( + <> + Email us at{" "} + + support@infisical.com + + + ) : ( + "Contact your administrator" + )} + . + +
+
+ ); +}; + +export default EmailVerificationTemplate; + +EmailVerificationTemplate.PreviewProps = { + code: "124356", + isCloud: true, + siteUrl: "https://infisical.com" +} as EmailVerificationTemplateProps; diff --git a/backend/src/services/smtp/emails/ExternalImportFailedTemplate.tsx b/backend/src/services/smtp/emails/ExternalImportFailedTemplate.tsx new file mode 100644 index 000000000..3cca41721 --- /dev/null +++ b/backend/src/services/smtp/emails/ExternalImportFailedTemplate.tsx @@ -0,0 +1,43 @@ +import { Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface ExternalImportFailedTemplateProps extends Omit { + error: string; + provider: string; +} + +export const ExternalImportFailedTemplate = ({ error, siteUrl, provider }: ExternalImportFailedTemplateProps) => { + return ( + + + An import from {provider} to Infisical has failed + +
+ + An import from {provider} to Infisical has failed due to unforeseen circumstances. Please + re-try your import. + + + If your issue persists, you can contact the Infisical team at{" "} + + support@infisical.com + + . + + + Error: "{error}" + +
+
+ ); +}; + +export default ExternalImportFailedTemplate; + +ExternalImportFailedTemplate.PreviewProps = { + provider: "EnvKey", + error: "Something went wrong. Please try again.", + siteUrl: "https://infisical.com" +} as ExternalImportFailedTemplateProps; diff --git a/backend/src/services/smtp/emails/ExternalImportStartedTemplate.tsx b/backend/src/services/smtp/emails/ExternalImportStartedTemplate.tsx new file mode 100644 index 000000000..9b8ce6cf7 --- /dev/null +++ b/backend/src/services/smtp/emails/ExternalImportStartedTemplate.tsx @@ -0,0 +1,31 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface ExternalImportStartedTemplateProps extends Omit { + provider: string; +} + +export const ExternalImportStartedTemplate = ({ siteUrl, provider }: ExternalImportStartedTemplateProps) => { + return ( + + + An import from {provider} to Infisical has been started + +
+ + An import from {provider} to Infisical is in progress. The import process may take up to 30 + minutes. You will receive an email once the import has completed. + +
+
+ ); +}; + +export default ExternalImportStartedTemplate; + +ExternalImportStartedTemplate.PreviewProps = { + provider: "EnvKey", + siteUrl: "https://infisical.com" +} as ExternalImportStartedTemplateProps; diff --git a/backend/src/services/smtp/emails/ExternalImportSucceededTemplate.tsx b/backend/src/services/smtp/emails/ExternalImportSucceededTemplate.tsx new file mode 100644 index 000000000..390d50fbc --- /dev/null +++ b/backend/src/services/smtp/emails/ExternalImportSucceededTemplate.tsx @@ -0,0 +1,31 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface ExternalImportSucceededTemplateProps extends Omit { + provider: string; +} + +export const ExternalImportSucceededTemplate = ({ siteUrl, provider }: ExternalImportSucceededTemplateProps) => { + return ( + + + An import from {provider} to Infisical has completed + +
+ + An import from {provider} to Infisical was successful. Your data is now available in + Infisical. + +
+
+ ); +}; + +export default ExternalImportSucceededTemplate; + +ExternalImportSucceededTemplate.PreviewProps = { + provider: "EnvKey", + siteUrl: "https://infisical.com" +} as ExternalImportSucceededTemplateProps; diff --git a/backend/src/services/smtp/emails/IntegrationSyncFailedTemplate.tsx b/backend/src/services/smtp/emails/IntegrationSyncFailedTemplate.tsx new file mode 100644 index 000000000..c2fb78ae0 --- /dev/null +++ b/backend/src/services/smtp/emails/IntegrationSyncFailedTemplate.tsx @@ -0,0 +1,65 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface IntegrationSyncFailedTemplateProps extends Omit { + count: number; + projectName: string; + secretPath: string; + environment: string; + syncMessage: string; + integrationUrl: string; +} + +export const IntegrationSyncFailedTemplate = ({ + count, + siteUrl, + projectName, + secretPath, + environment, + syncMessage, + integrationUrl +}: IntegrationSyncFailedTemplateProps) => { + return ( + + + {count} integration(s) failed to sync + +
+ Project + {projectName} + Environment + {environment} + Secret Path + {secretPath} + Failure Reason: + "{syncMessage}" +
+
+ +
+
+ ); +}; + +export default IntegrationSyncFailedTemplate; + +IntegrationSyncFailedTemplate.PreviewProps = { + projectName: "Example Project", + secretPath: "/api/secrets", + environment: "Production", + siteUrl: "https://infisical.com", + integrationUrl: "https://infisical.com", + count: 2, + syncMessage: "Secret key cannot contain a colon (:)" +} as IntegrationSyncFailedTemplateProps; diff --git a/backend/src/services/smtp/emails/NewDeviceLoginTemplate.tsx b/backend/src/services/smtp/emails/NewDeviceLoginTemplate.tsx new file mode 100644 index 000000000..9692bc77d --- /dev/null +++ b/backend/src/services/smtp/emails/NewDeviceLoginTemplate.tsx @@ -0,0 +1,68 @@ +import { Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface NewDeviceLoginTemplateProps extends Omit { + email: string; + timestamp: string; + ip: string; + userAgent: string; + isCloud: boolean; +} + +export const NewDeviceLoginTemplate = ({ + email, + timestamp, + ip, + userAgent, + siteUrl, + isCloud +}: NewDeviceLoginTemplateProps) => { + return ( + + + We're verifying a recent login for +
+ {email} +
+
+ Timestamp + {timestamp} + IP Address + {ip} + User Agent + {userAgent} +
+
+ + If you believe that this login is suspicious, please contact{" "} + {isCloud ? ( + + support@infisical.com + + ) : ( + "your administrator" + )}{" "} + or reset your password immediately. + +
+
+ ); +}; + +export default NewDeviceLoginTemplate; + +NewDeviceLoginTemplate.PreviewProps = { + email: "john@infisical.com", + ip: "127.0.0.1", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15", + timestamp: "Tue Apr 29 2025 23:03:27 GMT+0000 (Coordinated Universal Time)", + isCloud: true, + siteUrl: "https://infisical.com" +} as NewDeviceLoginTemplateProps; diff --git a/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx b/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx new file mode 100644 index 000000000..1d2ecc1a3 --- /dev/null +++ b/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx @@ -0,0 +1,57 @@ +import { Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface OrgAdminBreakglassAccessTemplateProps extends Omit { + email: string; + timestamp: string; + ip: string; + userAgent: string; +} + +export const OrgAdminBreakglassAccessTemplate = ({ + email, + siteUrl, + timestamp, + ip, + userAgent +}: OrgAdminBreakglassAccessTemplateProps) => { + return ( + + + The organization admin {email} has bypassed enforced SSO login + +
+ Timestamp + {timestamp} + IP Address + {ip} + User Agent + {userAgent} + + If you'd like to disable Admin SSO Bypass, please visit{" "} + + Organization Security Settings + + . + +
+
+ ); +}; + +export default OrgAdminBreakglassAccessTemplate; + +OrgAdminBreakglassAccessTemplate.PreviewProps = { + ip: "127.0.0.1", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15", + timestamp: "Tue Apr 29 2025 23:03:27 GMT+0000 (Coordinated Universal Time)", + siteUrl: "https://infisical.com", + email: "august@infisical.com" +} as OrgAdminBreakglassAccessTemplateProps; diff --git a/backend/src/services/smtp/emails/OrgAdminProjectGrantAccessTemplate.tsx b/backend/src/services/smtp/emails/OrgAdminProjectGrantAccessTemplate.tsx new file mode 100644 index 000000000..bec22575c --- /dev/null +++ b/backend/src/services/smtp/emails/OrgAdminProjectGrantAccessTemplate.tsx @@ -0,0 +1,41 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface OrgAdminProjectGrantAccessTemplateProps extends Omit { + email: string; + projectName: string; +} + +export const OrgAdminProjectGrantAccessTemplate = ({ + email, + siteUrl, + projectName +}: OrgAdminProjectGrantAccessTemplateProps) => { + return ( + + + An organization admin has joined the project {projectName} + +
+ + The organization admin {email} has self-issued direct access to the project{" "} + {projectName}. + +
+
+ ); +}; + +export default OrgAdminProjectGrantAccessTemplate; + +OrgAdminProjectGrantAccessTemplate.PreviewProps = { + email: "kevin@infisical.com", + projectName: "Example Project", + siteUrl: "https://infisical.com" +} as OrgAdminProjectGrantAccessTemplateProps; diff --git a/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx b/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx new file mode 100644 index 000000000..b281e75d6 --- /dev/null +++ b/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx @@ -0,0 +1,77 @@ +import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface OrganizationInvitationTemplateProps extends Omit { + metadata?: string; + inviterFirstName: string; + inviterUsername: string; + organizationName: string; + email: string; + organizationId: string; + token: string; + callback_url: string; +} + +export const OrganizationInvitationTemplate = ({ + organizationName, + inviterFirstName, + inviterUsername, + token, + callback_url, + metadata, + email, + organizationId, + siteUrl +}: OrganizationInvitationTemplateProps) => { + return ( + + + You've been invited to join +
+ {organizationName} on Infisical +
+
+ + {inviterFirstName} ( + + {inviterUsername} + + ) has invited you to collaborate on {organizationName}. + +
+
+ +
+
+ + About Infisical: Infisical is an all-in-one platform to securely manage application secrets, + certificates, SSH keys, and configurations across your team and infrastructure. + +
+
+ ); +}; + +export default OrganizationInvitationTemplate; + +OrganizationInvitationTemplate.PreviewProps = { + organizationName: "Example Organization", + inviterFirstName: "Jane", + inviterUsername: "jane@infisical.com", + email: "john@infisical.com", + siteUrl: "https://infisical.com", + callback_url: "https://app.infisical.com", + token: "preview-token", + organizationId: "1ae1c2c7-8068-461c-b15e-421737868a6a" +} as OrganizationInvitationTemplateProps; diff --git a/backend/src/services/smtp/emails/PasswordResetTemplate.tsx b/backend/src/services/smtp/emails/PasswordResetTemplate.tsx new file mode 100644 index 000000000..7486b29d9 --- /dev/null +++ b/backend/src/services/smtp/emails/PasswordResetTemplate.tsx @@ -0,0 +1,60 @@ +import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface PasswordResetTemplateProps extends Omit { + email: string; + callback_url: string; + token: string; + isCloud: boolean; +} + +export const PasswordResetTemplate = ({ email, isCloud, siteUrl, callback_url, token }: PasswordResetTemplateProps) => { + return ( + + + Account Recovery + +
+ A password reset was requested for your Infisical account. + + If you did not initiate this request, please contact{" "} + {isCloud ? ( + <> + us immediately at{" "} + + support@infisical.com + + + ) : ( + "your administrator immediately" + )} + . + +
+
+ +
+
+ ); +}; + +export default PasswordResetTemplate; + +PasswordResetTemplate.PreviewProps = { + email: "kevin@infisical.com", + callback_url: "https://app.infisical.com", + isCloud: true, + token: "preview-token", + siteUrl: "https://infisical.com" +} as PasswordResetTemplateProps; diff --git a/backend/src/services/smtp/emails/PasswordSetupTemplate.tsx b/backend/src/services/smtp/emails/PasswordSetupTemplate.tsx new file mode 100644 index 000000000..c8a986c8c --- /dev/null +++ b/backend/src/services/smtp/emails/PasswordSetupTemplate.tsx @@ -0,0 +1,59 @@ +import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface PasswordSetupTemplateProps extends Omit { + email: string; + callback_url: string; + token: string; + isCloud: boolean; +} + +export const PasswordSetupTemplate = ({ email, isCloud, siteUrl, callback_url, token }: PasswordSetupTemplateProps) => { + return ( + + + Password Setup + +
+ Someone requested to set up a password for your Infisical account. + + Make sure you are already logged in to Infisical in the current browser before clicking the link below. + + + If you did not initiate this request, please contact{" "} + {isCloud ? ( + <> + us immediately at{" "} + + support@infisical.com + + + ) : ( + "your administrator immediately" + )} + . + +
+
+ +
+
+ ); +}; + +export default PasswordSetupTemplate; + +PasswordSetupTemplate.PreviewProps = { + email: "casey@infisical.com", + callback_url: "https://app.infisical.com", + isCloud: true, + siteUrl: "https://infisical.com", + token: "preview-token" +} as PasswordSetupTemplateProps; diff --git a/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx b/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx new file mode 100644 index 000000000..a09a125a2 --- /dev/null +++ b/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx @@ -0,0 +1,69 @@ +import { Heading, Hr, Section, Text } from "@react-email/components"; +import React, { Fragment } from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface PkiExpirationAlertTemplateProps extends Omit { + alertName: string; + alertBeforeDays: number; + items: { type: string; friendlyName: string; serialNumber: string; expiryDate: string }[]; +} + +export const PkiExpirationAlertTemplate = ({ + alertName, + siteUrl, + alertBeforeDays, + items +}: PkiExpirationAlertTemplateProps) => { + return ( + + + CA/Certificate Expiration Notice + +
+ Hello, + + This is an automated alert for {alertName} triggered for CAs/Certificates expiring in{" "} + {alertBeforeDays} days. + + + Expiring Items: + + {items.map((item) => ( + +
+ {item.type}: + {item.friendlyName} + Serial Number: + {item.serialNumber} + Expires On: + {item.expiryDate} +
+ ))} +
+ + Please take the necessary actions to renew these items before they expire. + + + For more details, please log in to your Infisical account and check your PKI management section. + +
+
+ ); +}; + +export default PkiExpirationAlertTemplate; + +PkiExpirationAlertTemplate.PreviewProps = { + alertBeforeDays: 5, + items: [ + { type: "CA", friendlyName: "Example CA", serialNumber: "1234567890", expiryDate: "2032-01-01" }, + { type: "Certificate", friendlyName: "Example Certificate", serialNumber: "2345678901", expiryDate: "2032-01-01" } + ], + alertName: "My PKI Alert", + siteUrl: "https://infisical.com" +} as PkiExpirationAlertTemplateProps; diff --git a/backend/src/services/smtp/emails/ProjectAccessRequestTemplate.tsx b/backend/src/services/smtp/emails/ProjectAccessRequestTemplate.tsx new file mode 100644 index 000000000..afc32a86b --- /dev/null +++ b/backend/src/services/smtp/emails/ProjectAccessRequestTemplate.tsx @@ -0,0 +1,68 @@ +import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface ProjectAccessRequestTemplateProps extends Omit { + projectName: string; + requesterName: string; + requesterEmail: string; + orgName: string; + note: string; + callback_url: string; +} + +export const ProjectAccessRequestTemplate = ({ + projectName, + siteUrl, + requesterName, + requesterEmail, + orgName, + note, + callback_url +}: ProjectAccessRequestTemplateProps) => { + return ( + + + A user has requested access to the project {projectName} + +
+ + {requesterName} ( + + {requesterEmail} + + ) has requested access to the project {projectName} in the organization{" "} + {orgName}. + + + User note: "{note}" + +
+
+ +
+
+ ); +}; + +export default ProjectAccessRequestTemplate; + +ProjectAccessRequestTemplate.PreviewProps = { + requesterName: "Abigail Williams", + requesterEmail: "abigail@infisical.com", + orgName: "Example Org", + siteUrl: "https://infisical.com", + projectName: "Example Project", + note: "I need access to the project for the new initiative for HR.", + callback_url: "https://infisical.com" +} as ProjectAccessRequestTemplateProps; diff --git a/backend/src/services/smtp/emails/ProjectInvitationTemplate.tsx b/backend/src/services/smtp/emails/ProjectInvitationTemplate.tsx new file mode 100644 index 000000000..1745dc8e5 --- /dev/null +++ b/backend/src/services/smtp/emails/ProjectInvitationTemplate.tsx @@ -0,0 +1,50 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface ProjectInvitationTemplateProps extends Omit { + callback_url: string; + workspaceName: string; +} + +export const ProjectInvitationTemplate = ({ callback_url, workspaceName, siteUrl }: ProjectInvitationTemplateProps) => { + return ( + + + You've been invited to join a project on Infisical + +
+ + You've been invited to join the project {workspaceName}. + +
+
+ +
+
+ + About Infisical: Infisical is an all-in-one platform to securely manage application secrets, + certificates, SSH keys, and configurations across your team and infrastructure. + +
+
+ ); +}; + +export default ProjectInvitationTemplate; + +ProjectInvitationTemplate.PreviewProps = { + workspaceName: "Example Project", + siteUrl: "https://infisical.com", + callback_url: "https://app.infisical.com" +} as ProjectInvitationTemplateProps; diff --git a/backend/src/services/smtp/emails/ScimUserProvisionedTemplate.tsx b/backend/src/services/smtp/emails/ScimUserProvisionedTemplate.tsx new file mode 100644 index 000000000..bbd41818e --- /dev/null +++ b/backend/src/services/smtp/emails/ScimUserProvisionedTemplate.tsx @@ -0,0 +1,56 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface ScimUserProvisionedTemplateProps extends Omit { + organizationName: string; + callback_url: string; +} + +export const ScimUserProvisionedTemplate = ({ + organizationName, + callback_url, + siteUrl +}: ScimUserProvisionedTemplateProps) => { + return ( + + + You've been invited to join +
+ {organizationName} on Infisical +
+
+ + You've been invited to collaborate on {organizationName}. + +
+
+ +
+
+ + About Infisical: Infisical is an all-in-one platform to securely manage application secrets, + certificates, SSH keys, and configurations across your team and infrastructure. + +
+
+ ); +}; + +export default ScimUserProvisionedTemplate; + +ScimUserProvisionedTemplate.PreviewProps = { + organizationName: "Example Organization", + callback_url: "https://app.infisical.com", + siteUrl: "https://app.infisical.com" +} as ScimUserProvisionedTemplateProps; diff --git a/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx b/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx new file mode 100644 index 000000000..bad823bd3 --- /dev/null +++ b/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx @@ -0,0 +1,72 @@ +import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretApprovalRequestBypassedTemplateProps + extends Omit { + projectName: string; + requesterFullName: string; + requesterEmail: string; + secretPath: string; + environment: string; + bypassReason: string; + approvalUrl: string; +} + +export const SecretApprovalRequestBypassedTemplate = ({ + projectName, + siteUrl, + requesterFullName, + requesterEmail, + secretPath, + environment, + bypassReason, + approvalUrl +}: SecretApprovalRequestBypassedTemplateProps) => { + return ( + + + A secret approval request has been bypassed in the project {projectName} + +
+ + {requesterFullName} ( + + {requesterEmail} + + ) has merged a secret to {secretPath} in the {environment} environment + without obtaining the required approval. + + + The following reason was provided for bypassing the policy: " + {bypassReason}" + +
+
+ +
+
+ ); +}; + +export default SecretApprovalRequestBypassedTemplate; + +SecretApprovalRequestBypassedTemplate.PreviewProps = { + requesterFullName: "Abigail Williams", + requesterEmail: "abigail@infisical.com", + secretPath: "/api/secrets", + environment: "Production", + siteUrl: "https://infisical.com", + projectName: "Example Project", + bypassReason: "I needed urgent access for a production misconfiguration." +} as SecretApprovalRequestBypassedTemplateProps; diff --git a/backend/src/services/smtp/emails/SecretApprovalRequestNeedsReviewTemplate.tsx b/backend/src/services/smtp/emails/SecretApprovalRequestNeedsReviewTemplate.tsx new file mode 100644 index 000000000..b4a7c306a --- /dev/null +++ b/backend/src/services/smtp/emails/SecretApprovalRequestNeedsReviewTemplate.tsx @@ -0,0 +1,57 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretApprovalRequestNeedsReviewTemplateProps + extends Omit { + projectName: string; + firstName: string; + organizationName: string; + approvalUrl: string; +} + +export const SecretApprovalRequestNeedsReviewTemplate = ({ + projectName, + siteUrl, + firstName, + organizationName, + approvalUrl +}: SecretApprovalRequestNeedsReviewTemplateProps) => { + return ( + + + A secret approval request for the project {projectName} requires review + +
+ Hello {firstName}, + + You have a new secret change request pending your review for the project {projectName} in the + organization {organizationName}. + +
+
+ +
+
+ ); +}; + +export default SecretApprovalRequestNeedsReviewTemplate; + +SecretApprovalRequestNeedsReviewTemplate.PreviewProps = { + firstName: "Gordon", + organizationName: "Example Org", + siteUrl: "https://infisical.com", + approvalUrl: "https://infisical.com", + projectName: "Example Project" +} as SecretApprovalRequestNeedsReviewTemplateProps; diff --git a/backend/src/services/smtp/emails/SecretLeakIncidentTemplate.tsx b/backend/src/services/smtp/emails/SecretLeakIncidentTemplate.tsx new file mode 100644 index 000000000..631013756 --- /dev/null +++ b/backend/src/services/smtp/emails/SecretLeakIncidentTemplate.tsx @@ -0,0 +1,82 @@ +import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretLeakIncidentTemplateProps extends Omit { + numberOfSecrets: number; + pusher_email: string; + pusher_name: string; +} + +export const SecretLeakIncidentTemplate = ({ + numberOfSecrets, + siteUrl, + pusher_name, + pusher_email +}: SecretLeakIncidentTemplateProps) => { + return ( + + + Infisical has uncovered {numberOfSecrets} secret(s) from a recent commit + +
+ + You are receiving this notification because one or more leaked secrets have been detected in a recent commit + {(pusher_email || pusher_name) && ( + <> + {" "} + pushed by {pusher_name ?? "Unknown Pusher"}{" "} + {pusher_email && ( + <> + ( + + {pusher_email} + + ) + + )} + + )} + . + + + If these are test secrets, please add `infisical-scan:ignore` at the end of the line containing the secret as + a comment in the given programming language. This will prevent future notifications from being sent out for + these secrets. + + + If these are production secrets, please rotate them immediately. + + + Once you have taken action, be sure to update the status of the risk in the{" "} + + Infisical Dashboard + + . + +
+
+ +
+
+ ); +}; + +export default SecretLeakIncidentTemplate; + +SecretLeakIncidentTemplate.PreviewProps = { + pusher_name: "Jim", + pusher_email: "jim@infisical.com", + numberOfSecrets: 3, + siteUrl: "https://infisical.com" +} as SecretLeakIncidentTemplateProps; diff --git a/backend/src/services/smtp/emails/SecretReminderTemplate.tsx b/backend/src/services/smtp/emails/SecretReminderTemplate.tsx new file mode 100644 index 000000000..01b2e9044 --- /dev/null +++ b/backend/src/services/smtp/emails/SecretReminderTemplate.tsx @@ -0,0 +1,45 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretReminderTemplateProps extends Omit { + projectName: string; + organizationName: string; + reminderNote?: string; +} + +export const SecretReminderTemplate = ({ + siteUrl, + reminderNote, + projectName, + organizationName +}: SecretReminderTemplateProps) => { + return ( + + + Secret Reminder + +
+ + You have a new secret reminder from the project {projectName} in the{" "} + {organizationName} organization. + + {reminderNote && ( + + Reminder Note: "{reminderNote}" + + )} +
+
+ ); +}; + +export default SecretReminderTemplate; + +SecretReminderTemplate.PreviewProps = { + reminderNote: "Remember to rotate secret.", + projectName: "Example Project", + organizationName: "Example Organization", + siteUrl: "https://infisical.com" +} as SecretReminderTemplateProps; diff --git a/backend/src/services/smtp/emails/SecretRequestCompletedTemplate.tsx b/backend/src/services/smtp/emails/SecretRequestCompletedTemplate.tsx new file mode 100644 index 000000000..4adeec636 --- /dev/null +++ b/backend/src/services/smtp/emails/SecretRequestCompletedTemplate.tsx @@ -0,0 +1,53 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretRequestCompletedTemplateProps extends Omit { + name?: string; + respondentUsername: string; + secretRequestUrl: string; +} + +export const SecretRequestCompletedTemplate = ({ + name, + siteUrl, + respondentUsername, + secretRequestUrl +}: SecretRequestCompletedTemplateProps) => { + return ( + + + A secret has been shared with you + +
+ + {respondentUsername ? {respondentUsername} : "Someone"} shared a secret{" "} + {name && ( + <> + {name}{" "} + + )}{" "} + with you. + +
+
+ +
+
+ ); +}; + +export default SecretRequestCompletedTemplate; + +SecretRequestCompletedTemplate.PreviewProps = { + respondentUsername: "Gracie", + siteUrl: "https://infisical.com", + secretRequestUrl: "https://infisical.com", + name: "API_TOKEN" +} as SecretRequestCompletedTemplateProps; diff --git a/backend/src/services/smtp/emails/SecretRotationFailedTemplate.tsx b/backend/src/services/smtp/emails/SecretRotationFailedTemplate.tsx new file mode 100644 index 000000000..52e58986d --- /dev/null +++ b/backend/src/services/smtp/emails/SecretRotationFailedTemplate.tsx @@ -0,0 +1,68 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretRotationFailedTemplateProps extends Omit { + rotationType: string; + rotationName: string; + rotationUrl: string; + projectName: string; + environment: string; + secretPath: string; + content: string; +} + +export const SecretRotationFailedTemplate = ({ + rotationType, + rotationName, + rotationUrl, + projectName, + siteUrl, + environment, + secretPath, + content +}: SecretRotationFailedTemplateProps) => { + return ( + + + Your {rotationType} rotation {rotationName} failed to rotate + +
+ Name + {rotationName} + Type + {rotationType} + Project + {projectName} + Environment + {environment} + Secret Path + {secretPath} + Reason: + {content} +
+
+ +
+
+ ); +}; + +export default SecretRotationFailedTemplate; + +SecretRotationFailedTemplate.PreviewProps = { + rotationType: "Auth0 Client Secret", + rotationUrl: "https://infisical.com", + content: "See Rotation status for details", + projectName: "Example Project", + secretPath: "/api/secrets", + environment: "Production", + rotationName: "my-auth0-rotation", + siteUrl: "https://infisical.com" +} as SecretRotationFailedTemplateProps; diff --git a/backend/src/services/smtp/emails/SecretSyncFailedTemplate.tsx b/backend/src/services/smtp/emails/SecretSyncFailedTemplate.tsx new file mode 100644 index 000000000..01d909219 --- /dev/null +++ b/backend/src/services/smtp/emails/SecretSyncFailedTemplate.tsx @@ -0,0 +1,80 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretSyncFailedTemplateProps extends Omit { + syncDestination: string; + syncName: string; + syncUrl: string; + projectName: string; + environment: string; + secretPath: string; + failureMessage: string; +} + +export const SecretSyncFailedTemplate = ({ + syncDestination, + syncName, + syncUrl, + projectName, + siteUrl, + environment, + secretPath, + failureMessage +}: SecretSyncFailedTemplateProps) => { + return ( + + + Your {syncDestination} sync {syncName} failed to complete + +
+ Name + {syncName} + Destination + {syncDestination} + Project + {projectName} + {environment && ( + <> + Environment + {environment} + + )} + {secretPath && ( + <> + Secret Path + {secretPath} + + )} + {failureMessage && ( + <> + Reason: + {failureMessage} + + )} +
+
+ +
+
+ ); +}; + +export default SecretSyncFailedTemplate; + +SecretSyncFailedTemplate.PreviewProps = { + syncDestination: "AWS Parameter Store", + syncUrl: "https://infisical.com", + failureMessage: "Key name cannot contain a colon (:) or a forward slash (/).", + projectName: "Example Project", + secretPath: "/api/secrets", + environment: "Production", + syncName: "my-aws-sync", + siteUrl: "https://infisical.com" +} as SecretSyncFailedTemplateProps; diff --git a/backend/src/services/smtp/emails/ServiceTokenExpiryNoticeTemplate.tsx b/backend/src/services/smtp/emails/ServiceTokenExpiryNoticeTemplate.tsx new file mode 100644 index 000000000..8f8deb63a --- /dev/null +++ b/backend/src/services/smtp/emails/ServiceTokenExpiryNoticeTemplate.tsx @@ -0,0 +1,53 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface ServiceTokenExpiryNoticeTemplateProps extends Omit { + tokenName: string; + projectName: string; + url: string; +} + +export const ServiceTokenExpiryNoticeTemplate = ({ + tokenName, + siteUrl, + projectName, + url +}: ServiceTokenExpiryNoticeTemplateProps) => { + return ( + + + Service token expiry notice + +
+ + Your service token {tokenName} for the project {projectName} will expire + within 24 hours. + + If this token is still needed for your workflow, please create a new one before it expires. +
+
+ +
+
+ ); +}; + +export default ServiceTokenExpiryNoticeTemplate; + +ServiceTokenExpiryNoticeTemplate.PreviewProps = { + projectName: "Example Project", + siteUrl: "https://infisical.com", + url: "https://infisical.com", + tokenName: "Example Token" +} as ServiceTokenExpiryNoticeTemplateProps; diff --git a/backend/src/services/smtp/emails/SignupEmailVerificationTemplate.tsx b/backend/src/services/smtp/emails/SignupEmailVerificationTemplate.tsx new file mode 100644 index 000000000..0a3da75f9 --- /dev/null +++ b/backend/src/services/smtp/emails/SignupEmailVerificationTemplate.tsx @@ -0,0 +1,53 @@ +import { Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SignupEmailVerificationTemplateProps extends Omit { + code: string; + isCloud: boolean; +} + +export const SignupEmailVerificationTemplate = ({ code, siteUrl, isCloud }: SignupEmailVerificationTemplateProps) => { + return ( + + + Confirm your email address + +
+ Enter the confirmation code shown below in the browser where you started sign-up. + + {code} + +
+
+ + Questions about setting up Infisical?{" "} + {isCloud ? ( + <> + Email us at{" "} + + support@infisical.com + + + ) : ( + "Contact your administrator" + )} + . + +
+
+ ); +}; + +export default SignupEmailVerificationTemplate; + +SignupEmailVerificationTemplate.PreviewProps = { + code: "124356", + isCloud: true, + siteUrl: "https://infisical.com" +} as SignupEmailVerificationTemplateProps; diff --git a/backend/src/services/smtp/emails/UnlockAccountTemplate.tsx b/backend/src/services/smtp/emails/UnlockAccountTemplate.tsx new file mode 100644 index 000000000..b0b4f2086 --- /dev/null +++ b/backend/src/services/smtp/emails/UnlockAccountTemplate.tsx @@ -0,0 +1,45 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface UnlockAccountTemplateProps extends Omit { + token: string; + callback_url: string; +} + +export const UnlockAccountTemplate = ({ token, siteUrl, callback_url }: UnlockAccountTemplateProps) => { + return ( + + + Unlock your Infisical account + +
+ + Your account has been temporarily locked due to multiple failed login attempts. + + If these attempts were not made by you, reset your password immediately. +
+
+ +
+
+ ); +}; + +export default UnlockAccountTemplate; + +UnlockAccountTemplate.PreviewProps = { + callback_url: "Example Project", + siteUrl: "https://infisical.com", + token: "preview-token" +} as UnlockAccountTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts new file mode 100644 index 000000000..29738dbb2 --- /dev/null +++ b/backend/src/services/smtp/emails/index.ts @@ -0,0 +1,27 @@ +export * from "./AccessApprovalRequestTemplate"; +export * from "./EmailMfaTemplate"; +export * from "./EmailVerificationTemplate"; +export * from "./ExternalImportFailedTemplate"; +export * from "./ExternalImportStartedTemplate"; +export * from "./ExternalImportSucceededTemplate"; +export * from "./IntegrationSyncFailedTemplate"; +export * from "./NewDeviceLoginTemplate"; +export * from "./OrgAdminBreakglassAccessTemplate"; +export * from "./OrgAdminProjectGrantAccessTemplate"; +export * from "./OrganizationInvitationTemplate"; +export * from "./PasswordResetTemplate"; +export * from "./PasswordSetupTemplate"; +export * from "./PkiExpirationAlertTemplate"; +export * from "./ProjectAccessRequestTemplate"; +export * from "./ProjectInvitationTemplate"; +export * from "./ScimUserProvisionedTemplate"; +export * from "./SecretApprovalRequestBypassedTemplate"; +export * from "./SecretApprovalRequestNeedsReviewTemplate"; +export * from "./SecretLeakIncidentTemplate"; +export * from "./SecretReminderTemplate"; +export * from "./SecretRequestCompletedTemplate"; +export * from "./SecretRotationFailedTemplate"; +export * from "./SecretSyncFailedTemplate"; +export * from "./ServiceTokenExpiryNoticeTemplate"; +export * from "./SignupEmailVerificationTemplate"; +export * from "./UnlockAccountTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 550e1bb07..12b38ebb2 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -1,13 +1,41 @@ -import fs from "node:fs/promises"; -import path from "node:path"; - -import handlebars from "handlebars"; +import { render } from "@react-email/components"; import { createTransport } from "nodemailer"; import SMTPTransport from "nodemailer/lib/smtp-transport"; +import React from "react"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; +import { + AccessApprovalRequestTemplate, + EmailMfaTemplate, + EmailVerificationTemplate, + ExternalImportFailedTemplate, + ExternalImportStartedTemplate, + ExternalImportSucceededTemplate, + IntegrationSyncFailedTemplate, + NewDeviceLoginTemplate, + OrgAdminBreakglassAccessTemplate, + OrgAdminProjectGrantAccessTemplate, + OrganizationInvitationTemplate, + PasswordResetTemplate, + PasswordSetupTemplate, + PkiExpirationAlertTemplate, + ProjectAccessRequestTemplate, + ProjectInvitationTemplate, + ScimUserProvisionedTemplate, + SecretApprovalRequestBypassedTemplate, + SecretApprovalRequestNeedsReviewTemplate, + SecretLeakIncidentTemplate, + SecretReminderTemplate, + SecretRequestCompletedTemplate, + SecretRotationFailedTemplate, + SecretSyncFailedTemplate, + ServiceTokenExpiryNoticeTemplate, + SignupEmailVerificationTemplate, + UnlockAccountTemplate +} from "./emails"; + export type TSmtpConfig = SMTPTransport.Options; export type TSmtpSendMail = { template: SmtpTemplates; @@ -18,61 +46,96 @@ export type TSmtpSendMail = { export type TSmtpService = ReturnType; export enum SmtpTemplates { - SignupEmailVerification = "signupEmailVerification.handlebars", - EmailVerification = "emailVerification.handlebars", - SecretReminder = "secretReminder.handlebars", - EmailMfa = "emailMfa.handlebars", - UnlockAccount = "unlockAccount.handlebars", - AccessApprovalRequest = "accessApprovalRequest.handlebars", - AccessSecretRequestBypassed = "accessSecretRequestBypassed.handlebars", - SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview.handlebars", - HistoricalSecretList = "historicalSecretLeakIncident.handlebars", - NewDeviceJoin = "newDevice.handlebars", - OrgInvite = "organizationInvitation.handlebars", - ResetPassword = "passwordReset.handlebars", - SetupPassword = "passwordSetup.handlebars", - SecretLeakIncident = "secretLeakIncident.handlebars", - WorkspaceInvite = "workspaceInvitation.handlebars", - ScimUserProvisioned = "scimUserProvisioned.handlebars", - PkiExpirationAlert = "pkiExpirationAlert.handlebars", - IntegrationSyncFailed = "integrationSyncFailed.handlebars", - SecretSyncFailed = "secretSyncFailed.handlebars", - ExternalImportSuccessful = "externalImportSuccessful.handlebars", - ExternalImportFailed = "externalImportFailed.handlebars", - ExternalImportStarted = "externalImportStarted.handlebars", - SecretRequestCompleted = "secretRequestCompleted.handlebars", - SecretRotationFailed = "secretRotationFailed.handlebars", - ProjectAccessRequest = "projectAccess.handlebars", - OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess.handlebars", - OrgAdminBreakglassAccess = "orgAdminBreakglassAccess.handlebars", - ServiceTokenExpired = "serviceTokenExpired.handlebars" + SignupEmailVerification = "signupEmailVerification", + EmailVerification = "emailVerification", + SecretReminder = "secretReminder", + EmailMfa = "emailMfa", + UnlockAccount = "unlockAccount", + AccessApprovalRequest = "accessApprovalRequest", + AccessSecretRequestBypassed = "accessSecretRequestBypassed", + SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview", + // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? + NewDeviceJoin = "newDevice", + OrgInvite = "organizationInvitation", + ResetPassword = "passwordReset", + SetupPassword = "passwordSetup", + SecretLeakIncident = "secretLeakIncident", + WorkspaceInvite = "workspaceInvitation", + ScimUserProvisioned = "scimUserProvisioned", + PkiExpirationAlert = "pkiExpirationAlert", + IntegrationSyncFailed = "integrationSyncFailed", + SecretSyncFailed = "secretSyncFailed", + ExternalImportSuccessful = "externalImportSuccessful", + ExternalImportFailed = "externalImportFailed", + ExternalImportStarted = "externalImportStarted", + SecretRequestCompleted = "secretRequestCompleted", + SecretRotationFailed = "secretRotationFailed", + ProjectAccessRequest = "projectAccess", + OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess", + OrgAdminBreakglassAccess = "orgAdminBreakglassAccess", + ServiceTokenExpired = "serviceTokenExpired" } export enum SmtpHost { Sendgrid = "smtp.sendgrid.net", Mailgun = "smtp.mailgun.org", - SocketLabs = "smtp.sockerlabs.com", + SocketLabs = "smtp.socketlabs.com", Zohomail = "smtp.zoho.com", Gmail = "smtp.gmail.com", Office365 = "smtp.office365.com" } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const EmailTemplateMap: Record> = { + [SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate, + [SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate, + [SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate, + [SmtpTemplates.EmailMfa]: EmailMfaTemplate, + [SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate, + [SmtpTemplates.EmailVerification]: EmailVerificationTemplate, + [SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate, + [SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate, + [SmtpTemplates.ExternalImportSuccessful]: ExternalImportSucceededTemplate, + [SmtpTemplates.AccessSecretRequestBypassed]: SecretApprovalRequestBypassedTemplate, + [SmtpTemplates.IntegrationSyncFailed]: IntegrationSyncFailedTemplate, + [SmtpTemplates.OrgAdminBreakglassAccess]: OrgAdminBreakglassAccessTemplate, + [SmtpTemplates.SecretLeakIncident]: SecretLeakIncidentTemplate, + [SmtpTemplates.WorkspaceInvite]: ProjectInvitationTemplate, + [SmtpTemplates.ScimUserProvisioned]: ScimUserProvisionedTemplate, + [SmtpTemplates.SecretRequestCompleted]: SecretRequestCompletedTemplate, + [SmtpTemplates.UnlockAccount]: UnlockAccountTemplate, + [SmtpTemplates.ServiceTokenExpired]: ServiceTokenExpiryNoticeTemplate, + [SmtpTemplates.SecretReminder]: SecretReminderTemplate, + [SmtpTemplates.SecretRotationFailed]: SecretRotationFailedTemplate, + [SmtpTemplates.SecretSyncFailed]: SecretSyncFailedTemplate, + [SmtpTemplates.OrgAdminProjectDirectAccess]: OrgAdminProjectGrantAccessTemplate, + [SmtpTemplates.ProjectAccessRequest]: ProjectAccessRequestTemplate, + [SmtpTemplates.SecretApprovalRequestNeedsReview]: SecretApprovalRequestNeedsReviewTemplate, + [SmtpTemplates.ResetPassword]: PasswordResetTemplate, + [SmtpTemplates.SetupPassword]: PasswordSetupTemplate, + [SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate +}; + export const smtpServiceFactory = (cfg: TSmtpConfig) => { const smtp = createTransport(cfg); const isSmtpOn = Boolean(cfg.host); - handlebars.registerHelper("emailFooter", () => { - const { SITE_URL } = getConfig(); - return new handlebars.SafeString( - `

Email sent via Infisical at ${SITE_URL}

` - ); - }); - const sendMail = async ({ substitutions, recipients, template, subjectLine }: TSmtpSendMail) => { const appCfg = getConfig(); - const html = await fs.readFile(path.resolve(__dirname, "./templates/", template), "utf8"); - const temp = handlebars.compile(html); - const htmlToSend = temp({ isCloud: appCfg.isCloud, siteUrl: appCfg.SITE_URL, ...substitutions }); + + const EmailTemplate = EmailTemplateMap[template]; + + if (!EmailTemplate) { + throw new Error(`Email template ${template} not found`); + } + + const htmlToSend = await render( + React.createElement(EmailTemplate, { + ...substitutions, + isCloud: appCfg.isCloud, + siteUrl: appCfg.SITE_URL + }) + ); if (isSmtpOn) { await smtp.sendMail({ diff --git a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars deleted file mode 100644 index 6813c1200..000000000 --- a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - 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}} -
-

- {{#if note}} -

User Note: "{{note}}"

- {{/if}} - -

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

- - {{emailFooter}} - - - diff --git a/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars b/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars deleted file mode 100644 index 8c82df289..000000000 --- a/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars +++ /dev/null @@ -1,33 +0,0 @@ - - - - - Secret Approval Request Policy Bypassed - - - -

Infisical

-

Secret Approval Request Bypassed

-

A secret approval request has been bypassed in the project "{{projectName}}".

- -

- {{requesterFullName}} - ({{requesterEmail}}) has merged a secret to environment - {{environment}} - at secret path - {{secretPath}} - without obtaining the required approvals. -

-

- The following reason was provided for bypassing the policy: - {{bypassReason}} -

- -

- To review this action, please visit the request panel - here. -

- - {{emailFooter}} - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/emailMfa.handlebars b/backend/src/services/smtp/templates/emailMfa.handlebars deleted file mode 100644 index 4c948b08c..000000000 --- a/backend/src/services/smtp/templates/emailMfa.handlebars +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - MFA Code - - - -

Infisical

-

Sign in attempt requires further verification

-

Your MFA code is below — enter it where you started signing in to Infisical.

-

{{code}}

-

The MFA code will be valid for 2 minutes.

-

Not you? Contact {{#if isCloud}}Infisical{{else}}your administrator{{/if}} immediately.

- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/emailVerification.handlebars b/backend/src/services/smtp/templates/emailVerification.handlebars deleted file mode 100644 index 4a989626e..000000000 --- a/backend/src/services/smtp/templates/emailVerification.handlebars +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - Code - - - -

Confirm your email address

-

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

-

{{code}}

- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/externalImportFailed.handlebars b/backend/src/services/smtp/templates/externalImportFailed.handlebars deleted file mode 100644 index 1755052c1..000000000 --- a/backend/src/services/smtp/templates/externalImportFailed.handlebars +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - Import failed - - - -

An import from {{provider}} to Infisical has failed

-

An import from - {{provider}} - to Infisical has failed due to unforeseen circumstances. Please re-try your import, and if the issue persists, you - can contact the Infisical team at team@infisical.com. -

- -

Error: {{error}}

- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/externalImportStarted.handlebars b/backend/src/services/smtp/templates/externalImportStarted.handlebars deleted file mode 100644 index 90026f762..000000000 --- a/backend/src/services/smtp/templates/externalImportStarted.handlebars +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - Import in progress - - - -

An import from {{provider}} to Infisical is in progress

-

An import from - {{provider}} - to Infisical is in progress. The import process may take up to 30 minutes, and you will receive once the import - has finished or if it fails.

- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/externalImportSuccessful.handlebars b/backend/src/services/smtp/templates/externalImportSuccessful.handlebars deleted file mode 100644 index a918e9ec7..000000000 --- a/backend/src/services/smtp/templates/externalImportSuccessful.handlebars +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - Import successful - - - -

An import from {{provider}} to Infisical was successful

-

An import from {{provider}} was successful. Your data is now available in Infisical.

- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars b/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars deleted file mode 100644 index 4a918ee0d..000000000 --- a/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - Incident alert: secrets potentially leaked - - - -

Infisical has uncovered {{numberOfSecrets}} secret(s) from historical commits to your repo

-

View leaked secrets

- -

If these are production secrets, please rotate them immediately.

- -

Once you have taken action, be sure to update the status of the risk in your - Infisical dashboard.

- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/integrationSyncFailed.handlebars b/backend/src/services/smtp/templates/integrationSyncFailed.handlebars deleted file mode 100644 index 2aff820fa..000000000 --- a/backend/src/services/smtp/templates/integrationSyncFailed.handlebars +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - Integration Sync Failed - - - -

Infisical

- -
-

{{count}} integration(s) failed to sync.

- - View your project integrations. - -
- -
-
-

Project: {{projectName}}

-

Environment: {{environment}}

-

Secret Path: {{secretPath}}

-
- - {{#if syncMessage}} -

Reason: {{syncMessage}}

- {{/if}} - - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/newDevice.handlebars b/backend/src/services/smtp/templates/newDevice.handlebars deleted file mode 100644 index 197e0b7a7..000000000 --- a/backend/src/services/smtp/templates/newDevice.handlebars +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - Successful login for {{email}} from new device - - - -

Infisical

-

We're verifying a recent login for {{email}}:

-

Timestamp: {{timestamp}}

-

IP address: {{ip}}

-

User agent: {{userAgent}}

-

If you believe that this login is suspicious, please contact - {{#if isCloud}}Infisical{{else}}your administrator{{/if}} - or reset your password immediately.

- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/orgAdminBreakglassAccess.handlebars b/backend/src/services/smtp/templates/orgAdminBreakglassAccess.handlebars deleted file mode 100644 index cc97ff201..000000000 --- a/backend/src/services/smtp/templates/orgAdminBreakglassAccess.handlebars +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - Organization admin has bypassed SSO - - - -

Infisical

-

The organization admin {{email}} has bypassed enforced SSO login.

-

Timestamp: {{timestamp}}

-

IP address: {{ip}}

-

User agent: {{userAgent}}

-

If you'd like to disable Admin SSO Bypass, please visit Organization Settings > Security.

- - {{emailFooter}} - - - diff --git a/backend/src/services/smtp/templates/orgAdminProjectGrantAccess.handlebars b/backend/src/services/smtp/templates/orgAdminProjectGrantAccess.handlebars deleted file mode 100644 index ef8c6e6b4..000000000 --- a/backend/src/services/smtp/templates/orgAdminProjectGrantAccess.handlebars +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - Organization admin issued direct access to project - - - -

Infisical

-

The organization admin {{email}} has granted direct access to the project "{{projectName}}".

- - {{emailFooter}} - - - diff --git a/backend/src/services/smtp/templates/organizationInvitation.handlebars b/backend/src/services/smtp/templates/organizationInvitation.handlebars deleted file mode 100644 index da429477b..000000000 --- a/backend/src/services/smtp/templates/organizationInvitation.handlebars +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - Organization Invitation - - -

Join your organization on Infisical

-

{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization named {{organizationName}}

- Click to join -

What is Infisical?

-

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

- - {{emailFooter}} - - diff --git a/backend/src/services/smtp/templates/passwordReset.handlebars b/backend/src/services/smtp/templates/passwordReset.handlebars deleted file mode 100644 index 1cb2ae8ce..000000000 --- a/backend/src/services/smtp/templates/passwordReset.handlebars +++ /dev/null @@ -1,16 +0,0 @@ - - - - - Account Recovery - - -

Reset your password

-

Someone requested a password reset.

- Reset password -

If you didn't initiate this request, please contact - {{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}

- - {{emailFooter}} - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/passwordSetup.handlebars b/backend/src/services/smtp/templates/passwordSetup.handlebars deleted file mode 100644 index 1059a7446..000000000 --- a/backend/src/services/smtp/templates/passwordSetup.handlebars +++ /dev/null @@ -1,17 +0,0 @@ - - - - - Password Setup - - -

Setup your password

-

Someone requested to set up a password for your account.

-

Make sure you are already logged in to Infisical in the current browser before clicking the link below.

- Setup password -

If you didn't initiate this request, please contact - {{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}

- - {{emailFooter}} - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars b/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars deleted file mode 100644 index f9013e24d..000000000 --- a/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars +++ /dev/null @@ -1,33 +0,0 @@ - - - - - Infisical CA/Certificate expiration notice - - -

Hello,

-

This is an automated alert for "{{alertName}}" triggered for CAs/Certificates expiring in - {{alertBeforeDays}} - days.

- -

Expiring Items:

-
    - {{#each items}} -
  • - {{type}}: - {{friendlyName}} -
    Serial Number: - {{serialNumber}} -
    Expires On: - {{expiryDate}} -
  • - {{/each}} -
- -

Please take necessary actions to renew these items before they expire.

- -

For more details, please log in to your Infisical account and check your PKI management section.

- - {{emailFooter}} - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/projectAccess.handlebars b/backend/src/services/smtp/templates/projectAccess.handlebars deleted file mode 100644 index 5ff1ca7ec..000000000 --- a/backend/src/services/smtp/templates/projectAccess.handlebars +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - Project Access Request - - - -

Infisical

-

You have a new project access request!

-
    -
  • Requester Name: "{{requesterName}}"
  • -
  • Requester Email: "{{requesterEmail}}"
  • -
  • Project Name: "{{projectName}}"
  • -
  • Organization Name: "{{orgName}}"
  • -
  • User Note: "{{note}}"
  • -
-

- Please click on the link below to grant access -

- Grant Access - {{emailFooter}} - - - diff --git a/backend/src/services/smtp/templates/scimUserProvisioned.handlebars b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars deleted file mode 100644 index ba04d7201..000000000 --- a/backend/src/services/smtp/templates/scimUserProvisioned.handlebars +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - Organization Invitation - - -

Join your organization on Infisical

-

You've been invited to join the Infisical organization — {{organizationName}}

- Join now -

What is Infisical?

-

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets - and configs.

- - {{emailFooter}} - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars b/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars deleted file mode 100644 index c12c08460..000000000 --- a/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - Secret Change Approval Request - - - -

Hi {{firstName}},

-

New secret change requests are pending review.

-
-

You have a secret change request pending your review in project "{{projectName}}", in the "{{organizationName}}" - organization.

- -

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

- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretLeakIncident.handlebars b/backend/src/services/smtp/templates/secretLeakIncident.handlebars deleted file mode 100644 index d0d9a617c..000000000 --- a/backend/src/services/smtp/templates/secretLeakIncident.handlebars +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - Incident alert: secret leaked - - - -

Infisical has uncovered {{numberOfSecrets}} secret(s) from your recent push

-

View leaked secrets

-

You are receiving this notification because one or more secret leaks have been detected in a recent commit pushed - by - {{pusher_name}} - ({{pusher_email}}). If these are test secrets, please add `infisical-scan:ignore` at the end of the line - containing the secret as comment in the given programming. This will prevent future notifications from being sent - out for those secret(s).

- -

If these are production secrets, please rotate them immediately.

- -

Once you have taken action, be sure to update the status of the risk in your - Infisical dashboard.

- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretReminder.handlebars b/backend/src/services/smtp/templates/secretReminder.handlebars deleted file mode 100644 index d64c4bf42..000000000 --- a/backend/src/services/smtp/templates/secretReminder.handlebars +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - Secret Reminder - - - -

Infisical

-

You have a new secret reminder!

-

You have a new secret reminder from project "{{projectName}}", in {{organizationName}}

- {{#if reminderNote}} -

Here's the note included with the reminder: {{reminderNote}}

- {{/if}} - - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretRequestCompleted.handlebars b/backend/src/services/smtp/templates/secretRequestCompleted.handlebars deleted file mode 100644 index d2cefdd54..000000000 --- a/backend/src/services/smtp/templates/secretRequestCompleted.handlebars +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - Secret Request Completed - - - -

Infisical

-

A secret has been shared with you

- - {{#if name}} -

Secret request name: {{name}}

- {{/if}} - {{#if respondentUsername}} -

Shared by: {{respondentUsername}}

- {{/if}} - -
-
- -

- You can access the secret by clicking the link below. -

-

- Access Secret -

- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretRotationFailed.handlebars b/backend/src/services/smtp/templates/secretRotationFailed.handlebars deleted file mode 100644 index 728798ce8..000000000 --- a/backend/src/services/smtp/templates/secretRotationFailed.handlebars +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - Your {{rotationType}} Rotation "{{rotationName}}" Failed to Rotate - - - -

Infisical

- - - -
-
-

Name: {{rotationName}}

-

Type: {{rotationType}}

-

Project: {{projectName}}

-

Environment: {{environment}}

-

Secret Path: {{secretPath}}

-
- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretSyncFailed.handlebars b/backend/src/services/smtp/templates/secretSyncFailed.handlebars deleted file mode 100644 index 3e7ad7831..000000000 --- a/backend/src/services/smtp/templates/secretSyncFailed.handlebars +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - {{syncDestination}} Sync "{{syncName}}" Failed - - - -

Infisical

- -
-

{{content}}

- - View in Infisical. - -
- -
-
-

Name: {{syncName}}

-

Destination: {{syncDestination}}

-

Project: {{projectName}}

- {{#if environment}} -

Environment: {{environment}}

- {{/if}} - {{#if secretPath}} -

Secret Path: {{secretPath}}

- {{/if}} -
- - {{#if failureMessage}} -

Reason: {{failureMessage}}

- {{/if}} - - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/serviceTokenExpired.handlebars b/backend/src/services/smtp/templates/serviceTokenExpired.handlebars deleted file mode 100644 index 199150c05..000000000 --- a/backend/src/services/smtp/templates/serviceTokenExpired.handlebars +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - Service Token Expiring Soon - - - -

Service Token Expiry Notice

-

Your service token "{{tokenName}}" will expire within 24 hours.

- -

This token is currently being used on project "{{projectName}}". If this token is still needed for your workflow, please create a new one before it expires.

- - Create New Token - - {{emailFooter}} - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/signupEmailVerification.handlebars b/backend/src/services/smtp/templates/signupEmailVerification.handlebars deleted file mode 100644 index 39f47ae48..000000000 --- a/backend/src/services/smtp/templates/signupEmailVerification.handlebars +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - 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? - {{#if isCloud}}Email us at support@infisical.com{{else}}Contact your administrator{{/if}}.

- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/unlockAccount.handlebars b/backend/src/services/smtp/templates/unlockAccount.handlebars deleted file mode 100644 index b65cb5625..000000000 --- a/backend/src/services/smtp/templates/unlockAccount.handlebars +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - Your Infisical account has been locked - - - -

Unlock your Infisical account

-

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

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

- - {{emailFooter}} - - - \ No newline at end of file diff --git a/backend/src/services/smtp/templates/workspaceInvitation.handlebars b/backend/src/services/smtp/templates/workspaceInvitation.handlebars deleted file mode 100644 index fde75a6d6..000000000 --- a/backend/src/services/smtp/templates/workspaceInvitation.handlebars +++ /dev/null @@ -1,17 +0,0 @@ - - - - - Project Invitation - - -

Join your team on Infisical

-

You have been invited to a new Infisical project named {{workspaceName}}

- Click to join -

What is Infisical?

-

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets - and configs.

- - {{emailFooter}} - - diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 317348cac..8687826c8 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -20,6 +20,7 @@ import { KMS_ROOT_CONFIG_UUID } from "../kms/kms-fns"; import { TKmsRootConfigDALFactory } from "../kms/kms-root-config-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { RootKeyEncryptionStrategy } from "../kms/kms-types"; +import { TMicrosoftTeamsServiceFactory } from "../microsoft-teams/microsoft-teams-service"; import { TOrgServiceFactory } from "../org/org-service"; import { TUserDALFactory } from "../user/user-dal"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; @@ -47,6 +48,7 @@ type TSuperAdminServiceFactoryDep = { orgService: Pick; keyStore: Pick; licenseService: Pick; + microsoftTeamsService: Pick; }; export type TSuperAdminServiceFactory = ReturnType; @@ -77,7 +79,8 @@ export const superAdminServiceFactory = ({ licenseService, identityAccessTokenDAL, identityTokenAuthDAL, - identityOrgMembershipDAL + identityOrgMembershipDAL, + microsoftTeamsService }: TSuperAdminServiceFactoryDep) => { const initServerCfg = async () => { // TODO(akhilmhdh): bad pattern time less change this later to me itself @@ -125,7 +128,13 @@ export const superAdminServiceFactory = ({ }; const updateServerCfg = async ( - data: TSuperAdminUpdate & { slackClientId?: string; slackClientSecret?: string }, + data: TSuperAdminUpdate & { + slackClientId?: string; + slackClientSecret?: string; + microsoftTeamsAppId?: string; + microsoftTeamsClientSecret?: string; + microsoftTeamsBotId?: string; + }, userId: string ) => { const updatedData = data; @@ -163,8 +172,8 @@ export const superAdminServiceFactory = ({ const canServerAdminAccessAfterApply = data.enabledLoginMethods.some((loginMethod) => - loginMethodToAuthMethod[loginMethod as LoginMethod].some( - (authMethod) => superAdminUser.authMethods?.includes(authMethod) + loginMethodToAuthMethod[loginMethod as LoginMethod].some((authMethod) => + superAdminUser.authMethods?.includes(authMethod) ) ) || isUserSamlAccessEnabled || @@ -192,10 +201,51 @@ export const superAdminServiceFactory = ({ updatedData.slackClientSecret = undefined; } + let microsoftTeamsSettingsUpdated = false; + if (data.microsoftTeamsAppId) { + const encryptedClientId = encryptWithRoot(Buffer.from(data.microsoftTeamsAppId)); + + updatedData.encryptedMicrosoftTeamsAppId = encryptedClientId; + updatedData.microsoftTeamsAppId = undefined; + microsoftTeamsSettingsUpdated = true; + } + + if (data.microsoftTeamsClientSecret) { + const encryptedClientSecret = encryptWithRoot(Buffer.from(data.microsoftTeamsClientSecret)); + + updatedData.encryptedMicrosoftTeamsClientSecret = encryptedClientSecret; + updatedData.microsoftTeamsClientSecret = undefined; + microsoftTeamsSettingsUpdated = true; + } + + if (data.microsoftTeamsBotId) { + const encryptedBotId = encryptWithRoot(Buffer.from(data.microsoftTeamsBotId)); + + updatedData.encryptedMicrosoftTeamsBotId = encryptedBotId; + updatedData.microsoftTeamsBotId = undefined; + microsoftTeamsSettingsUpdated = true; + } const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, updatedData); await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg)); + if ( + updatedServerCfg.encryptedMicrosoftTeamsAppId && + updatedServerCfg.encryptedMicrosoftTeamsClientSecret && + updatedServerCfg.encryptedMicrosoftTeamsBotId && + microsoftTeamsSettingsUpdated + ) { + const decryptWithRoot = kmsService.decryptWithRootKey(); + decryptWithRoot(updatedServerCfg.encryptedMicrosoftTeamsBotId); // validate that we're able to decrypt the bot ID + const decryptedAppId = decryptWithRoot(updatedServerCfg.encryptedMicrosoftTeamsAppId); + const decryptedAppPassword = decryptWithRoot(updatedServerCfg.encryptedMicrosoftTeamsClientSecret); + + await microsoftTeamsService.initializeTeamsBot({ + botAppId: decryptedAppId.toString(), + botAppPassword: decryptedAppPassword.toString() + }); + } + return updatedServerCfg; }; @@ -488,29 +538,40 @@ export const superAdminServiceFactory = ({ await userDAL.updateById(userId, { superAdmin: true }); }; - const getAdminSlackConfig = async () => { + const getAdminIntegrationsConfig = async () => { const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); if (!serverCfg) { throw new NotFoundError({ name: "AdminConfig", message: "Admin config not found" }); } - let clientId = ""; - let clientSecret = ""; - const decrypt = kmsService.decryptWithRootKey(); - if (serverCfg.encryptedSlackClientId) { - clientId = decrypt(serverCfg.encryptedSlackClientId).toString(); - } + const slackClientId = serverCfg.encryptedSlackClientId ? decrypt(serverCfg.encryptedSlackClientId).toString() : ""; + const slackClientSecret = serverCfg.encryptedSlackClientSecret + ? decrypt(serverCfg.encryptedSlackClientSecret).toString() + : ""; - if (serverCfg.encryptedSlackClientSecret) { - clientSecret = decrypt(serverCfg.encryptedSlackClientSecret).toString(); - } + const microsoftAppId = serverCfg.encryptedMicrosoftTeamsAppId + ? decrypt(serverCfg.encryptedMicrosoftTeamsAppId).toString() + : ""; + const microsoftClientSecret = serverCfg.encryptedMicrosoftTeamsClientSecret + ? decrypt(serverCfg.encryptedMicrosoftTeamsClientSecret).toString() + : ""; + const microsoftBotId = serverCfg.encryptedMicrosoftTeamsBotId + ? decrypt(serverCfg.encryptedMicrosoftTeamsBotId).toString() + : ""; return { - clientId, - clientSecret + slack: { + clientSecret: slackClientSecret, + clientId: slackClientId + }, + microsoftTeams: { + appId: microsoftAppId, + clientSecret: microsoftClientSecret, + botId: microsoftBotId + } }; }; @@ -578,7 +639,7 @@ export const superAdminServiceFactory = ({ getUsers, deleteUser, getIdentities, - getAdminSlackConfig, + getAdminIntegrationsConfig, updateRootEncryptionStrategy, getConfiguredEncryptionStrategies, grantServerAdminAccessToUser, diff --git a/backend/src/services/workflow-integration/workflow-integration-types.ts b/backend/src/services/workflow-integration/workflow-integration-types.ts index 9ae56b840..978b1e09e 100644 --- a/backend/src/services/workflow-integration/workflow-integration-types.ts +++ b/backend/src/services/workflow-integration/workflow-integration-types.ts @@ -1,7 +1,13 @@ import { TOrgPermission } from "@app/lib/types"; export enum WorkflowIntegration { - SLACK = "slack" + SLACK = "slack", + MICROSOFT_TEAMS = "microsoft-teams" +} + +export enum WorkflowIntegrationStatus { + PENDING = "pending", + INSTALLED = "installed" } export type TGetWorkflowIntegrationsByOrg = Omit; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 90165acbe..523e6de5b 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -25,7 +25,8 @@ "baseUrl": ".", "paths": { "@app/*": ["./src/*"] - } + }, + "jsx": "react-jsx" }, "include": ["src/**/*", "scripts/**/*", "e2e-test/**/*", "./.eslintrc.js", "./tsup.config.js"], "exclude": ["node_modules"] diff --git a/backend/tsup.config.js b/backend/tsup.config.js index 9e37870ba..e09a21ff2 100644 --- a/backend/tsup.config.js +++ b/backend/tsup.config.js @@ -2,8 +2,8 @@ import path from "node:path"; import fs from "fs/promises"; -import { replaceTscAliasPaths } from "tsc-alias"; -import { defineConfig } from "tsup"; +import {replaceTscAliasPaths} from "tsc-alias"; +import {defineConfig} from "tsup"; // Instead of using tsx or tsc for building, consider using tsup. // TSX serves as an alternative to Node.js, allowing you to build directly on the Node.js runtime. @@ -50,7 +50,17 @@ export default defineConfig({ const isFile = await fs .stat(`${absPath}.ts`) .then((el) => el.isFile) - .catch((err) => err.code === "ENOTDIR"); + .catch(async (err) => { + if (err.code === "ENOTDIR") { + return true; + } + + // If .ts file doesn't exist, try checking for .tsx file + return fs + .stat(`${absPath}.tsx`) + .then((el) => el.isFile) + .catch((err) => err.code === "ENOTDIR"); + }); return { path: isFile ? `${args.path}.mjs` : `${args.path}/index.mjs`, diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index b8fc6ed7b..b14fd04e2 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -338,7 +338,7 @@ func secretTemplateFunction(accessToken string, existingEtag string, currentEtag parsedArguments.SetDefaults() - res, err := util.GetPlainTextSecretsV3(accessToken, projectID, envSlug, secretPath, false, parsedArguments.IsRecursive, "", *parsedArguments.ShouldExpandSecretReferences) + res, err := util.GetPlainTextSecretsV3(accessToken, projectID, envSlug, secretPath, true, parsedArguments.IsRecursive, "", *parsedArguments.ShouldExpandSecretReferences) if err != nil { return nil, err } diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/available.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/available.mdx new file mode 100644 index 000000000..238f8ae18 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/azure-client-secrets/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/create.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/create.mdx new file mode 100644 index 000000000..c1c6bd6a8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/azure-client-secrets" +--- + + + Azure Client Secret Connections must be created through the Infisical UI. + Check out the configuration docs for [Azure Client Secret Connections](/integrations/app-connections/azure-client-secrets) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/delete.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/delete.mdx new file mode 100644 index 000000000..8482e2ad1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/azure-client-secrets/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-id.mdx new file mode 100644 index 000000000..555ae20f3 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/azure-client-secrets/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-name.mdx new file mode 100644 index 000000000..f6c23483e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/azure-client-secrets/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/list.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/list.mdx new file mode 100644 index 000000000..795906cfd --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/azure-client-secrets" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-client-secret/update.mdx b/docs/api-reference/endpoints/app-connections/azure-client-secret/update.mdx new file mode 100644 index 000000000..f60993285 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-client-secret/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/azure-client-secrets/{connectionId}" +--- + + + Azure Client Secret Connections must be updated through the Infisical UI. + Check out the configuration docs for [Azure Client Secret Connections](/integrations/app-connections/azure-client-secrets) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/app-connections/hashicorp-vault/available.mdx b/docs/api-reference/endpoints/app-connections/hashicorp-vault/available.mdx new file mode 100644 index 000000000..ff52a325a --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/hashicorp-vault/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/hashicorp-vault/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/hashicorp-vault/create.mdx b/docs/api-reference/endpoints/app-connections/hashicorp-vault/create.mdx new file mode 100644 index 000000000..6a978e780 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/hashicorp-vault/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/hashicorp-vault" +--- + + + Check out the configuration docs for [Hashicorp Vault Connections](/integrations/app-connections/hashicorp-vault) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/hashicorp-vault/delete.mdx b/docs/api-reference/endpoints/app-connections/hashicorp-vault/delete.mdx new file mode 100644 index 000000000..aaf378fb5 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/hashicorp-vault/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/hashicorp-vault/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/hashicorp-vault/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/hashicorp-vault/get-by-id.mdx new file mode 100644 index 000000000..39366f508 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/hashicorp-vault/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/hashicorp-vault/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/hashicorp-vault/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/hashicorp-vault/get-by-name.mdx new file mode 100644 index 000000000..9f3c41783 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/hashicorp-vault/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/hashicorp-vault/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/hashicorp-vault/list.mdx b/docs/api-reference/endpoints/app-connections/hashicorp-vault/list.mdx new file mode 100644 index 000000000..474c70fd8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/hashicorp-vault/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/hashicorp-vault" +--- diff --git a/docs/api-reference/endpoints/app-connections/hashicorp-vault/update.mdx b/docs/api-reference/endpoints/app-connections/hashicorp-vault/update.mdx new file mode 100644 index 000000000..e155c8e67 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/hashicorp-vault/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/hashicorp-vault/{connectionId}" +--- + + + Check out the configuration docs for [Hashicorp Vault Connections](/integrations/app-connections/hashicorp-vault) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/identity-specific-privilege/create-permanent.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/create-permanent.mdx similarity index 100% rename from docs/api-reference/endpoints/identity-specific-privilege/create-permanent.mdx rename to docs/api-reference/endpoints/identity-specific-privilege/v1/create-permanent.mdx diff --git a/docs/api-reference/endpoints/identity-specific-privilege/create-temporary.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/create-temporary.mdx similarity index 100% rename from docs/api-reference/endpoints/identity-specific-privilege/create-temporary.mdx rename to docs/api-reference/endpoints/identity-specific-privilege/v1/create-temporary.mdx diff --git a/docs/api-reference/endpoints/identity-specific-privilege/delete.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/delete.mdx similarity index 100% rename from docs/api-reference/endpoints/identity-specific-privilege/delete.mdx rename to docs/api-reference/endpoints/identity-specific-privilege/v1/delete.mdx diff --git a/docs/api-reference/endpoints/identity-specific-privilege/find-by-slug.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/find-by-slug.mdx similarity index 70% rename from docs/api-reference/endpoints/identity-specific-privilege/find-by-slug.mdx rename to docs/api-reference/endpoints/identity-specific-privilege/v1/find-by-slug.mdx index a6ec27217..cdf3ba5ea 100644 --- a/docs/api-reference/endpoints/identity-specific-privilege/find-by-slug.mdx +++ b/docs/api-reference/endpoints/identity-specific-privilege/v1/find-by-slug.mdx @@ -1,4 +1,4 @@ --- -title: "Find By Privilege Slug" +title: "Find By Slug" openapi: "GET /api/v1/additional-privilege/identity/{privilegeSlug}" --- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/list.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/list.mdx similarity index 100% rename from docs/api-reference/endpoints/identity-specific-privilege/list.mdx rename to docs/api-reference/endpoints/identity-specific-privilege/v1/list.mdx diff --git a/docs/api-reference/endpoints/identity-specific-privilege/update.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v1/update.mdx similarity index 100% rename from docs/api-reference/endpoints/identity-specific-privilege/update.mdx rename to docs/api-reference/endpoints/identity-specific-privilege/v1/update.mdx diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/create.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/create.mdx new file mode 100644 index 000000000..7ff358b40 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/identity-project-additional-privilege" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/delete.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/delete.mdx new file mode 100644 index 000000000..68d6596e3 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/identity-project-additional-privilege/{id}" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-id.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-id.mdx new file mode 100644 index 000000000..d9d40a473 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Find By ID" +openapi: "GET /api/v2/identity-project-additional-privilege/{id}" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-slug.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-slug.mdx new file mode 100644 index 000000000..290e332ed --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/find-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Find By Slug" +openapi: "GET /api/v2/identity-project-additional-privilege/slug/{privilegeSlug}" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/list.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/list.mdx new file mode 100644 index 000000000..33a4673d9 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/identity-project-additional-privilege" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/v2/update.mdx b/docs/api-reference/endpoints/identity-specific-privilege/v2/update.mdx new file mode 100644 index 000000000..b0dd9b4b2 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/v2/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/identity-project-additional-privilege/{id}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/create.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/create.mdx new file mode 100644 index 000000000..eb998767b --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/azure-client-secret" +--- + + + Check out the configuration docs for [Azure Client Secret Rotations](/documentation/platform/secret-rotation/azure-client-secret) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/delete.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/delete.mdx new file mode 100644 index 000000000..31e4a5697 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/azure-client-secret/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id.mdx new file mode 100644 index 000000000..db5ec7a2a --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/azure-client-secret/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name.mdx new file mode 100644 index 000000000..c1ca2d958 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/azure-client-secret/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..c6da0709c --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/azure-client-secret/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/list.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/list.mdx new file mode 100644 index 000000000..da970a16f --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/azure-client-secret" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets.mdx new file mode 100644 index 000000000..b824178da --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/azure-client-secret/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/azure-client-secret/update.mdx b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/update.mdx new file mode 100644 index 000000000..abb00bba0 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/azure-client-secret/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/azure-client-secret/{rotationId}" +--- + + + Check out the configuration docs for [Azure Client Secret Rotations](/documentation/platform/secret-rotation/azure-client-secret) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/create.mdx b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/create.mdx new file mode 100644 index 000000000..ab9171f7b --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/hashicorp-vault" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/delete.mdx b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/delete.mdx new file mode 100644 index 000000000..700438ba5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/hashicorp-vault/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/get-by-id.mdx new file mode 100644 index 000000000..7017416c1 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/hashicorp-vault/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/get-by-name.mdx new file mode 100644 index 000000000..a817732f1 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/hashicorp-vault/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/import-secrets.mdx new file mode 100644 index 000000000..3ee2c479c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/hashicorp-vault/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/list.mdx b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/list.mdx new file mode 100644 index 000000000..e3c08f125 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/hashicorp-vault" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/remove-secrets.mdx new file mode 100644 index 000000000..7b54e94d6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/hashicorp-vault/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/sync-secrets.mdx new file mode 100644 index 000000000..24f58d802 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/hashicorp-vault/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/update.mdx b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/update.mdx new file mode 100644 index 000000000..58ddc5a2b --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/hashicorp-vault/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/hashicorp-vault/{syncId}" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/add-host.mdx b/docs/api-reference/endpoints/ssh/groups/add-host.mdx new file mode 100644 index 000000000..9f903eccd --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/add-host.mdx @@ -0,0 +1,4 @@ +--- +title: "Add Host" +openapi: "POST /api/v1/ssh/host-groups/{sshHostGroupId}/hosts" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/create.mdx b/docs/api-reference/endpoints/ssh/groups/create.mdx new file mode 100644 index 000000000..be4755d8e --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/ssh/host-groups" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/delete.mdx b/docs/api-reference/endpoints/ssh/groups/delete.mdx new file mode 100644 index 000000000..19205f2eb --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/list-hosts.mdx b/docs/api-reference/endpoints/ssh/groups/list-hosts.mdx new file mode 100644 index 000000000..2db1fd11f --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/list-hosts.mdx @@ -0,0 +1,4 @@ +--- +title: "List Hosts" +openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}/hosts" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/list.mdx b/docs/api-reference/endpoints/ssh/groups/list.mdx new file mode 100644 index 000000000..089ab4096 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/workspace/{projectId}/ssh-host-groups" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/read.mdx b/docs/api-reference/endpoints/ssh/groups/read.mdx new file mode 100644 index 000000000..060a75bce --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/remove-host.mdx b/docs/api-reference/endpoints/ssh/groups/remove-host.mdx new file mode 100644 index 000000000..6933e5c9f --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/remove-host.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Host" +openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}/hosts/{sshHostId}" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/update.mdx b/docs/api-reference/endpoints/ssh/groups/update.mdx new file mode 100644 index 000000000..3e23bf4f9 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/ssh/host-groups/{sshHostGroupId}" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/create.mdx b/docs/api-reference/endpoints/ssh/hosts/create.mdx new file mode 100644 index 000000000..5860b6b49 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/ssh/hosts" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/delete.mdx b/docs/api-reference/endpoints/ssh/hosts/delete.mdx new file mode 100644 index 000000000..c52129b33 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/ssh/hosts/{sshHostId}" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/issue-host-cert.mdx b/docs/api-reference/endpoints/ssh/hosts/issue-host-cert.mdx new file mode 100644 index 000000000..bb843e466 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/issue-host-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue Host Certificate" +openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-host-cert" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/issue-user-cert.mdx b/docs/api-reference/endpoints/ssh/hosts/issue-user-cert.mdx new file mode 100644 index 000000000..a16e03a65 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/issue-user-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue User Certificate" +openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-user-cert" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/list-my.mdx b/docs/api-reference/endpoints/ssh/hosts/list-my.mdx new file mode 100644 index 000000000..2b7ab51c0 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/list-my.mdx @@ -0,0 +1,4 @@ +--- +title: "List My Hosts" +openapi: "GET /api/v1/ssh/hosts/" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/list.mdx b/docs/api-reference/endpoints/ssh/hosts/list.mdx new file mode 100644 index 000000000..3f61123e0 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/workspace/{projectId}/ssh-hosts" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/read-host-ca-pk.mdx b/docs/api-reference/endpoints/ssh/hosts/read-host-ca-pk.mdx new file mode 100644 index 000000000..15ca53e9d --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/read-host-ca-pk.mdx @@ -0,0 +1,4 @@ +--- +title: "Read Host CA Public Key" +openapi: "GET /api/v1/ssh/hosts/{sshHostId}/host-ca-public-key" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/read-user-ca-pk.mdx b/docs/api-reference/endpoints/ssh/hosts/read-user-ca-pk.mdx new file mode 100644 index 000000000..a6f31e93f --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/read-user-ca-pk.mdx @@ -0,0 +1,4 @@ +--- +title: "Read User CA Public Key" +openapi: "GET /api/v1/ssh/hosts/{sshHostId}/user-ca-public-key" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/read.mdx b/docs/api-reference/endpoints/ssh/hosts/read.mdx new file mode 100644 index 000000000..112296a5a --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/ssh/hosts/{sshHostId}" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/update.mdx b/docs/api-reference/endpoints/ssh/hosts/update.mdx new file mode 100644 index 000000000..d5555b813 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/ssh/hosts/{sshHostId}" +--- diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index 02d9c863a..7ccc098cd 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -73,6 +73,61 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t + + + The Gateway can be installed via [Helm](https://helm.sh/). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications. + + For production deployments on Kubernetes, install the Gateway using the Infisical Helm chart: + + ### Install the latest Helm Chart repository + ```bash + helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' + ``` + + ### Update the Helm Chart repository + ```bash + helm repo update + ``` + + ### Create a Kubernetes Secret with the gateway token + + Create a new Kubernetes secret containing the gateway token as the `TOKEN` key. You can optionally also set the `INFISICAL_API_URL` key to your Infisical instance URL. By default, `INFISICAL_API_URL` is set to `https://app.infisical.com`. + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=TOKEN= + ``` + + + The secret name is `infisical-gateway-environment` by default. The `TOKEN` key is required, and the `INFISICAL_API_URL` key is optional. + + + ### Install the Infisical Gateway Helm Chart + ```bash + helm install infisical-gateway infisical-helm-charts/infisical-gateway + ``` + + ### Check the gateway logs + After installing the gateway, you can check the logs to ensure it's running as expected. + + ```bash + kubectl logs deployment/infisical-gateway + ``` + + You should see the following output which indicates the gateway is running as expected. + ```bash + $ kubectl logs deployment/infisical-gateway + INF Provided relay port 5349. Using TLS + INF Connected with relay + INF 10.0.101.112:56735 + INF Starting relay connection health check + INF Gateway started successfully + INF New connection from: 10.0.1.8:34051 + INF Gateway is reachable by Infisical + ``` + + + For development or testing, you can run the Gateway directly. Log in with your machine identity and start the Gateway in one command: ```bash diff --git a/docs/documentation/platform/organization.mdx b/docs/documentation/platform/organization.mdx index f1c62ff37..3a53484fb 100644 --- a/docs/documentation/platform/organization.mdx +++ b/docs/documentation/platform/organization.mdx @@ -27,6 +27,10 @@ The **Settings** page lets you manage information about your organization includ ![organization settings auth](../../images/platform/organization/organization-settings-auth.png) + + You can adjust the maximum time a user token will remain valid for your organization. After this period, users will be required to re-authenticate. This helps improve security by enforcing regular sign-ins. + + ## Access Control The **Access Control** page is where you can manage identities (both people and machines) that are part of your organization. diff --git a/docs/documentation/platform/secret-rotation/azure-client-secret.mdx b/docs/documentation/platform/secret-rotation/azure-client-secret.mdx new file mode 100644 index 000000000..046b772f6 --- /dev/null +++ b/docs/documentation/platform/secret-rotation/azure-client-secret.mdx @@ -0,0 +1,142 @@ +--- +title: "Azure Client Secret" +description: "Learn how to automatically rotate Azure Client Secrets." +--- + +## Prerequisites + +- Create an [Azure Client Secret Connection](/integrations/app-connections/azure-client-secrets). + +## Create an Azure Client Secret Rotation in Infisical + + + + 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown. + ![Secret Manager Dashboard](/images/secret-rotations-v2/generic/add-secret-rotation.png) + + 2. Select the **Azure Client Secret** option. + ![Select Azure Client Secret](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-option.png) + + 3. Select the **Azure Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-configuration.png) + + - **Azure Connection** - the connection that will perform the rotation of the specified application's Client Secret. + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. + + 4. Select the Azure application whose Client Secret you want to rotate. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-parameters.png) + + 5. Specify the secret names that the client credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-mapping.png) + + - **Client ID** - the name of the secret that the application Client ID will be mapped to. + - **Client Secret** - the name of the secret that the rotated Client Secret will be mapped to. + + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-details.png) + + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. + + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-review.png) + + 8. Your **Azure Client Secret** credentials are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/azure-client-secret/azure-client-secret-created.png) + + + To create an Azure Client Secret Rotation, make an API request to the [Create Azure + Client Secret Rotation](/api-reference/endpoints/secret-rotations/azure-client-secret/create) API endpoint. + + You will first need the **Client ID** and **Object ID** of the Azure application you want to rotate the secret for. This can be obtained from the Applications dashboard. + ![Azure Client ID](/images/secret-rotations-v2/azure-client-secret/azure-app-client-id.png) + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/azure-client-secret \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-azure-rotation", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my client secret rotation", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": true, + "rotationInterval": 30, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "parameters": { + "objectId": "...", + "clientId": "...", + "appName": "..." + }, + "secretsMapping": { + "clientId": "AZURE_CLIENT_ID", + "clientSecret": "AZURE_CLIENT_SECRET" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-azure-rotation", + "description": "my client secret rotation", + "secretsMapping": { + "clientId": "AZURE_CLIENT_ID", + "clientSecret": "AZURE_CLIENT_SECRET" + }, + "isAutoRotationEnabled": true, + "activeIndex": 0, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 30, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "nextRotationAt": "2023-11-07T05:31:56Z", + "connection": { + "app": "azure", + "name": "my-azure-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "lastRotationMessage": null, + "type": "azure-client-secret", + "parameters": { + "objectId": "...", + "appName": "...", + "clientId": "..." + } + } + } + ``` + + diff --git a/docs/documentation/platform/ssh/host-groups.mdx b/docs/documentation/platform/ssh/host-groups.mdx new file mode 100644 index 000000000..877291183 --- /dev/null +++ b/docs/documentation/platform/ssh/host-groups.mdx @@ -0,0 +1,56 @@ +--- +title: "Infisical SSH" +sidebarTitle: "Host Groups" +description: "Learn how to organize SSH hosts into groups and manage access policies at scale." +--- + +## Concept + +Infisical SSH lets you configure host groups to organize and manage multiple SSH hosts with shared access configuration. +These host groups can be created based on environments (`development`, `staging`, `production`), geographical regions (`us-east`, `eu-west`, `ap-northeast`), or functions (`web-servers`, `database-servers`, `worker-nodes`) to streamline access management across your infrastructure. + +Using a host group, you can define login mappings at the group level and have them be applied to all hosts assigned to that group. For example, you can specify that `john@example.com` can login as `ubuntu` on all hosts assigned to the `production` host group. + +## Workflow + +The typical workflow for using Infisical SSH with host groups consists of the following steps: + +1. The administrator creates host groups based on logical groupings (environments, regions, functions, etc.). +2. The administrator configures login mappings at the host group level to define access policies. +3. The administrator registers remote hosts with Infisical using the Infisical CLI via the `infisical ssh add-host` command and assigns them to appropriate host groups either using the `--host-group` flag or by adding them to the host group via UI. +4. User(s) access the remote hosts using the Infisical CLI via the `infisical ssh connect` command, with access determined by the login mappings defined at both host and host group levels. + +## Admin Guide for Configuring Host Groups + +In the following steps, we'll walk through how to create and configure Host Groups in Infisical SSH, and how to add hosts to these groups. + + + + 1.1. Navigate to your Infisical SSH project and select the **Hosts** tab. + + 1.2. Click **Add Group** in the **Host Groups** section to create a new group. + + Enter a name (e.g., `production-servers` or `tokyo-region`) and login mapping(s) for the host group. + + A login mapping for a host group applies to all hosts assigned to the group and dictates what user(s) will be allowed access to the remote hosts + in that group under specific login user(s); in the allowed principals, you should select user(s) part of the Infisical SSH project that will + be allowed to login to the remote host as the login user. + + For instance, if you add a mapping to a host group with the login user `ec2-user` to some users John and Alice in Infisical, then they will be allowed to login to any remote host that is part of the group as `ec2-user` which is a system user that + exists on the remote host(s). + + ![ssh group add group](/images/platform/ssh/v2/ssh-group-add-group-1.png) + ![ssh group add group 2](/images/platform/ssh/v2/ssh-group-add-group-2.png) + + 1.3. Click **Add** to create the host group. + + + + + After creating the host group, you can assign a host to it from inside the host group page in the **SSH Hosts** section. Generally, this is where you'll manage the hosts in a group. + + ![ssh group add group - add host](/images/platform/ssh/v2/ssh-group-add-host-1.png) + ![ssh group add group - added host](/images/platform/ssh/v2/ssh-group-add-host-2.png) + + + diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh/overview.mdx similarity index 99% rename from docs/documentation/platform/ssh.mdx rename to docs/documentation/platform/ssh/overview.mdx index 2bc433f75..e71eeabe1 100644 --- a/docs/documentation/platform/ssh.mdx +++ b/docs/documentation/platform/ssh/overview.mdx @@ -1,6 +1,6 @@ --- title: "Infisical SSH" -sidebarTitle: "Infisical SSH" +sidebarTitle: "Overview" description: "Learn how to securely provision user SSH access to your infrastructure using SSH certificates." --- diff --git a/docs/documentation/platform/sso/auth0-oidc.mdx b/docs/documentation/platform/sso/auth0-oidc.mdx index bde87f42f..e8b532c1c 100644 --- a/docs/documentation/platform/sso/auth0-oidc.mdx +++ b/docs/documentation/platform/sso/auth0-oidc.mdx @@ -45,6 +45,10 @@ description: "Learn how to configure Auth0 OIDC for Infisical SSO." 3.2. For configuration type, select **Discovery URL**. Then, set **Discovery Document URL**, **JWT Signature Algorithm**, **Client ID**, and **Client Secret** from step 2.1 and 2.2. ![OIDC auth0 paste values into Infisical](../../../images/sso/auth0-oidc/org-update-oidc.png) + + Currently, the following JWT signature algorithms are supported: RS256, RS512, HS256, and EdDSA + + Once you've done that, press **Update** to complete the required configuration. diff --git a/docs/documentation/platform/sso/general-oidc.mdx b/docs/documentation/platform/sso/general-oidc.mdx index 11216b893..76e364b2f 100644 --- a/docs/documentation/platform/sso/general-oidc.mdx +++ b/docs/documentation/platform/sso/general-oidc.mdx @@ -44,7 +44,9 @@ Prerequisites: To configure OIDC via the custom endpoints, set the **Configuration Type** field to **Custom** and input the required endpoint fields. ![OIDC general custom config](../../../images/sso/general-oidc/custom-oidc-form.png) - 2.3. Optionally, you can define a whitelist of allowed email domains. + 2.3. Select the appropriate JWT signature algorithm for your IdP. Currently, the supported options are RS256, RS512, HS256, and EdDSA. + + 2.4. Optionally, you can define a whitelist of allowed email domains. Finally, fill out the **Client ID** and **Client Secret** fields and press **Update** to complete the required configuration. diff --git a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx index 6d8f4e4c8..803818a0e 100644 --- a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx +++ b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx @@ -72,6 +72,10 @@ description: "Learn how to configure Keycloak OIDC for Infisical SSO." 3.2. For configuration type, select Discovery URL. Then, set the appropriate values for **Discovery Document URL**, **JWT Signature Algorithm**, **Client ID**, and **Client Secret**. ![OIDC keycloak paste values into Infisical](/images/sso/keycloak-oidc/create-oidc.png) + + Currently, the following JWT signature algorithms are supported: RS256, RS512, HS256, and EdDSA + + Once you've done that, press **Update** to complete the required configuration. diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index 0d4b8da89..e5d5e5c16 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -35,6 +35,10 @@ Infisical supports these and many other identity providers: 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. + + For enhanced security, Infisical enforces PKCE (Proof Key for Code Exchange) with the OAuth 2.0-based SSO providers and OIDC. This provides additional protection against authorization code interception attacks and strengthens your authentication flow security. + + ## FAQ diff --git a/docs/documentation/platform/workflow-integrations/microsoft-teams-integration.mdx b/docs/documentation/platform/workflow-integrations/microsoft-teams-integration.mdx new file mode 100644 index 000000000..57f05984b --- /dev/null +++ b/docs/documentation/platform/workflow-integrations/microsoft-teams-integration.mdx @@ -0,0 +1,192 @@ +--- +title: "Microsoft Teams Integration" +description: "Learn how to setup the Microsoft Teams integration" +--- + +import MicrosoftTeamsWorkflowIntegration from '/snippets/documentation/platform/workflow-integrations/microsoft-teams-integration.mdx'; + + +This guide will provide step by step instructions on how to configure Microsoft Teams integration for your Infisical projects. + +## Setting up Microsoft Teams integration in your projects + + + + + + + ### Configure Azure Resources + To create a Microsoft Teams bot, you must first create an Azure Bot from the Azure Marketplace, an app registration, and a Microsoft Teams app. The steps below document in detail how to create and configure these resources. + + + + + Navigate to the [Microsoft Teams Developer Portal](https://dev.teams.microsoft.com/). + + Once you're on the Microsoft Teams Developer Portal, press the "Create a new app" button on the overview page. Give the bot a name and press the "Add" button. + + ![microsoft-dev-portal](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-create-app.png) + + + + After creating the Microsoft Teams app, you'll need to create a Microsoft Teams bot. + + Navigate to the app's bot settings page and click "Create a new bot". + + ![microsoft-dev-portal-create-bot](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-bot-app-feature.png) + ![microsoft-dev-portal-create-bot-2](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-create-a-new-bot.png) + + After clicking the "Create a new bot" button, you'll be navigated to the Teams Developer Portal for bot management. Press the "New bot" button, and enter the name of the bot. + Please keep in mind that the name of the bot can only contain alphanumeric characters, dashes, and underscores. + + ![microsoft-dev-portal-create-bot-3](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-create-bot.png) + + + + After creating the bot, you'll need to add a message endpoint to the bot. Navigate to the "Configure" tab, and input the following endpoint under "Endpoint address": + `https:///api/v1/workflow-integrations/microsoft-teams/message-endpoint` + Replace `` with the URL of your Infisical instance. + + Press the "Save" button to save the changes. + + ![microsoft-dev-portal-create-bot-4](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-add-bot-endpoint.png) + + + + When you create a bot through the Teams Developer Portal, an Azure App Registration is also created. + + Open your [Azure Portal](https://portal.azure.com/) and navigate to the "App Registrations" section to find the newly created app registration. + The name of the app registration will be the same as the name of the bot you created in the previous step. + + + Press the app registration to open the app registration overview page. + ![azure-app-registrations](/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registrations-bot.png) + + + + Navigate to the "API Permissions" section of the app registration, and add the following permissions: + - `AppCatalog.Read.All` + - `ChannelSettings.Read.All` + - `MultiTenantOrganization.Read.All` + - `Organization.Read.All` + - `Team.ReadBasic.All` + - `TeamsAppInstallation.Read.All` + + After adding the API permissions, press the "Grant admin consent" button to grant the permissions. + + ![azure-app-registration-api-permissions](/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-api-permissions.png) + + + + Navigate to the "Authentication" section of the App Registration, and press the "Add a platform" button. Select the "Web" platform and enter the following redirect URI: + `https:///organization/settings/oauth/callback`. Replace `` with the URL of your Infisical instance. + + ![azure-app-registration-register-callback](/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-register-callback.png) + + + + Next we need to get the application client ID and create a new client secret. **Save these values for later, as they're required to configure the Microsoft Teams integration in Infisical.** + + **Get the Application (Client) ID** + + To get the Application (Client) ID, press the "Copy" button next to the "Application (client) ID" field. + + ![copy-client-id](/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-copy-client-id.png) + + **Create a new client secret** + + Create a new client secret within the app registration. Navigate to the "Certificates & Secrets" section of the app registration, and press the "New client secret" button. + + ![create-client-secret](/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-create-client-secret.png) + + + Remember to rotate your client secret before it expires. Consider setting up a reminder or automated process to replace the secret and update your Infisical configuration before expiration. + + + + + Navigate back to the [Microsoft Teams Developer Portal](https://dev.teams.microsoft.com/), and press the "Apps" tab and select the app you created earlier. + Here you can find the Microsoft Teams App ID in the overview page, which you need to copy and save for later. + + ![microsoft-teams-app-id](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-copy-app-id.png) + + + + + You need to link the Microsoft Teams App with the bot/app registration you created earlier. + Inside the [Microsoft Teams Developer Portal](https://dev.teams.microsoft.com/), navigate to the "Bot" tab and select the bot you created earlier. Navigate to the "App Features" section, and press the "Bot" button. + + Under the "What can your bot do?" section, enable `Only send notifications (one-way conversations)`. + + Under the "Select the scopes where people can use your bot" section, select `Personal`, `Team`, and `Group Chat`. + + Finally, press the "Save" button to save the changes. + + ![microsoft-teams-app-features](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-bot-app-feature.png) + ![microsoft-teams-configure-bot](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-configure-bot.png) + + + + To ensure that the Microsoft Teams App is working correctly, you can run an app validation test. This step is optional, but recommended to ensure the app is working correctly. + + You should expect to see two errors related to sending welcome messages, because we haven't configured the Microsoft Teams App inside Infisical yet, which is required for proactive messages. + + ![microsoft-teams-app-validation-test](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation.png) + + + You may see manifest validation errors. Before running an app validation test, you must ensure that your app has all errors resolved, such as having a description and a valid name. + + + ![microsoft-teams-app-validation-test-results](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation-result.png) + + + If you see two errors for bot welcome messages, you can ignore them. This is expected until you configure the Microsoft Teams App inside Infisical. + + + + + Once the Microsoft Teams App is working correctly, you can download the app package by navigating to the "Publish to Store" page, and pressing the "Download app package" button. + + ![microsoft-teams-download-app-package](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-download-app-package.png) + + + + ### Configure Microsoft Teams Bot in Infisical + + After creating the Microsoft Teams App and Bot, you are ready to configure the Microsoft Teams integration in Infisical. + Please note that you must be an instance admin in order to configure the Microsoft Teams instance-wide settings. + + + + ![server-admin-console-tab](/images/platform/workflow-integrations/microsoft-teams-integration/infisical-server-admin-console-tab.png) + ![infisical-instance-configure-microsoft-teams](/images/platform/workflow-integrations/microsoft-teams-integration/infisical-instance-configure-microsoft-teams.png) + + Enter the values you saved from the earlier steps into the respective fields. + + - **Application (Client) ID**: The Client ID of the App Registration from the previous steps. + - **Client Secret**: The Client Secret of the App Registration from the previous steps. + - **Microsoft Teams App ID**: The App ID of the Microsoft Teams App from the previous steps. + + Once completed, press the "Save" button to save your changes. + + + + + + + +## Troubleshooting + + + If you recently added the Microsoft Teams app to your tenant, **it may take up to 24 hours for Microsoft Teams to propagate the changes.** + A common indication of propagation issues is that the workflow integration is shown as "Installed", and you're able to view the teams and channels when configuring the workflow integration on your project, but no notification is being sent. + + + + The workflow integration can get stuck on Pending if you created the workflow integration before the Infisical Microsoft Teams bot was installed in the tenant. + To resolve this, make sure you have installed the Infisical Microsoft Teams app in your tenant. + + You can manually recheck the installation status by pressing the "Check Installation Status" button in the workflow organization settings. + + ![microsoft-teams-check-installation-status](/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-check-installation-status.png) + \ No newline at end of file diff --git a/docs/documentation/platform/workflow-integrations/slack-integration.mdx b/docs/documentation/platform/workflow-integrations/slack-integration.mdx index 92b3feeca..2317baabe 100644 --- a/docs/documentation/platform/workflow-integrations/slack-integration.mdx +++ b/docs/documentation/platform/workflow-integrations/slack-integration.mdx @@ -1,6 +1,6 @@ --- -title: "Slack integration" -description: "Learn how to setup Slack integration" +title: "Slack Integration" +description: "Learn how to setup the Slack integration" --- This guide will provide step by step instructions on how to configure Slack integration for your Infisical projects. diff --git a/docs/images/app-connections/azure/client-secrets/config-credentials-1.png b/docs/images/app-connections/azure/client-secrets/config-credentials-1.png new file mode 100644 index 000000000..954f8aeb9 Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/config-credentials-1.png differ diff --git a/docs/images/app-connections/azure/client-secrets/create-oauth-method.png b/docs/images/app-connections/azure/client-secrets/create-oauth-method.png new file mode 100644 index 000000000..e38707ea1 Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/create-oauth-method.png differ diff --git a/docs/images/app-connections/azure/client-secrets/oauth-connection.png b/docs/images/app-connections/azure/client-secrets/oauth-connection.png new file mode 100644 index 000000000..5bc8e479a Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/oauth-connection.png differ diff --git a/docs/images/app-connections/azure/client-secrets/select-connection.png b/docs/images/app-connections/azure/client-secrets/select-connection.png new file mode 100644 index 000000000..84091ef25 Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/select-connection.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-access.png b/docs/images/app-connections/hashicorp-vault/vault-access.png new file mode 100644 index 000000000..b6b6504fe Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-access.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-approle.png b/docs/images/app-connections/hashicorp-vault/vault-approle.png new file mode 100644 index 000000000..89cff90fe Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-approle.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-authentication-methods.png b/docs/images/app-connections/hashicorp-vault/vault-authentication-methods.png new file mode 100644 index 000000000..fd9d607b9 Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-authentication-methods.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-cluster-urls.png b/docs/images/app-connections/hashicorp-vault/vault-cluster-urls.png new file mode 100644 index 000000000..57af4514f Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-cluster-urls.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-create-policy.png b/docs/images/app-connections/hashicorp-vault/vault-create-policy.png new file mode 100644 index 000000000..5d429fcc3 Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-create-policy.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-enable-method.png b/docs/images/app-connections/hashicorp-vault/vault-enable-method.png new file mode 100644 index 000000000..c034995ac Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-enable-method.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png b/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png new file mode 100644 index 000000000..872d11cab Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-page.png b/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-page.png new file mode 100644 index 000000000..4a91f8dbb Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-page.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-success.png b/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-success.png new file mode 100644 index 000000000..90d562e11 Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-success.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-policies-navigate.png b/docs/images/app-connections/hashicorp-vault/vault-policies-navigate.png new file mode 100644 index 000000000..05cab30eb Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-policies-navigate.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-policies-page.png b/docs/images/app-connections/hashicorp-vault/vault-policies-page.png new file mode 100644 index 000000000..3a84b86ee Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-policies-page.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-profile-token.png b/docs/images/app-connections/hashicorp-vault/vault-profile-token.png new file mode 100644 index 000000000..aca3b7f0a Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-profile-token.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-shell-output.png b/docs/images/app-connections/hashicorp-vault/vault-shell-output.png new file mode 100644 index 000000000..a5d654110 Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-shell-output.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-shell.png b/docs/images/app-connections/hashicorp-vault/vault-shell.png new file mode 100644 index 000000000..ca3ff0bc4 Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-shell.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-token.png b/docs/images/app-connections/hashicorp-vault/vault-token.png new file mode 100644 index 000000000..aca3b7f0a Binary files /dev/null and b/docs/images/app-connections/hashicorp-vault/vault-token.png differ diff --git a/docs/images/integrations/azure-client-secrets/app-api-permissions.png b/docs/images/integrations/azure-client-secrets/app-api-permissions.png new file mode 100644 index 000000000..f64d8b50f Binary files /dev/null and b/docs/images/integrations/azure-client-secrets/app-api-permissions.png differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-1.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-1.png deleted file mode 100644 index 367386709..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-1.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-2.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-2.png deleted file mode 100644 index 80de8df26..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-2.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-3.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-3.png deleted file mode 100644 index d51142541..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-3.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-auth.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-auth.png deleted file mode 100644 index b587777f9..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-auth.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-cluster-url.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-cluster-url.png deleted file mode 100644 index 619764b54..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-cluster-url.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-create.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-create.png deleted file mode 100644 index 7fdef0d4a..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-create.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-1.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-1.png deleted file mode 100644 index a65870b44..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-1.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-2.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-2.png deleted file mode 100644 index 34b03768f..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-2.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-3.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-3.png deleted file mode 100644 index 624fdc574..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-3.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-1.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-1.png deleted file mode 100644 index e2a77654a..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-1.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-2.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-2.png deleted file mode 100644 index 719659014..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-2.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-3.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-3.png deleted file mode 100644 index 76fe2de35..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-3.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-shell.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-shell.png deleted file mode 100644 index 7d63bde40..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-shell.png and /dev/null differ diff --git a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault.png deleted file mode 100644 index e556ffc6b..000000000 Binary files a/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-settings-auth.png b/docs/images/platform/organization/organization-settings-auth.png index ca2340e9f..fd7a946e0 100644 Binary files a/docs/images/platform/organization/organization-settings-auth.png and b/docs/images/platform/organization/organization-settings-auth.png differ diff --git a/docs/images/platform/ssh/v2/ssh-group-add-group-1.png b/docs/images/platform/ssh/v2/ssh-group-add-group-1.png new file mode 100644 index 000000000..be023335f Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-group-1.png differ diff --git a/docs/images/platform/ssh/v2/ssh-group-add-group-2.png b/docs/images/platform/ssh/v2/ssh-group-add-group-2.png new file mode 100644 index 000000000..0507d6e99 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-group-2.png differ diff --git a/docs/images/platform/ssh/v2/ssh-group-add-host-1.png b/docs/images/platform/ssh/v2/ssh-group-add-host-1.png new file mode 100644 index 000000000..de8568f61 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-host-1.png differ diff --git a/docs/images/platform/ssh/v2/ssh-group-add-host-2.png b/docs/images/platform/ssh/v2/ssh-group-add-host-2.png new file mode 100644 index 000000000..79143c4da Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-host-2.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-api-permissions.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-api-permissions.png new file mode 100644 index 000000000..f3d0cbf8a Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-api-permissions.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-copy-client-id.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-copy-client-id.png new file mode 100644 index 000000000..ea4b7527f Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-copy-client-id.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-create-client-secret.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-create-client-secret.png new file mode 100644 index 000000000..34523a2ef Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-create-client-secret.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-register-callback.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-register-callback.png new file mode 100644 index 000000000..0ec40c0ae Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registration-register-callback.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registrations-bot.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registrations-bot.png new file mode 100644 index 000000000..50fea25d7 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/azure-app-registrations-bot.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-instance-configure-microsoft-teams.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-instance-configure-microsoft-teams.png new file mode 100644 index 000000000..efe3c02d7 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-instance-configure-microsoft-teams.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-org-add-microsoft-teams-integration.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-org-add-microsoft-teams-integration.png new file mode 100644 index 000000000..67eb9eb0a Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-org-add-microsoft-teams-integration.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-org-microsoft-teams-integration-modal.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-org-microsoft-teams-integration-modal.png new file mode 100644 index 000000000..d17ceb363 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-org-microsoft-teams-integration-modal.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-org-overview.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-org-overview.png new file mode 100644 index 000000000..f9f2176c8 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-org-overview.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-project-microsoft-teams-integration-save.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-project-microsoft-teams-integration-save.png new file mode 100644 index 000000000..10f80aa43 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-project-microsoft-teams-integration-save.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-project-settings.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-project-settings.png new file mode 100644 index 000000000..29f36ccb5 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-project-settings.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-server-admin-console-tab.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-server-admin-console-tab.png new file mode 100644 index 000000000..efd1bf488 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/infisical-server-admin-console-tab.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-consent-page.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-consent-page.png new file mode 100644 index 000000000..c15745363 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-consent-page.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-add-app.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-add-app.png new file mode 100644 index 000000000..718ece554 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-add-app.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-check-installation-status.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-check-installation-status.png new file mode 100644 index 000000000..5c71f2205 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-check-installation-status.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-install-app.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-install-app.png new file mode 100644 index 000000000..13e036b46 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-install-app.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-submit-app.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-submit-app.png new file mode 100644 index 000000000..6f2dd404f Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-submit-app.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-workflow-integration-created.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-workflow-integration-created.png new file mode 100644 index 000000000..22aa8adfa Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-workflow-integration-created.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-add-bot-endpoint.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-add-bot-endpoint.png new file mode 100644 index 000000000..139fa90b0 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-add-bot-endpoint.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation-result.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation-result.png new file mode 100644 index 000000000..b8279846b Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation-result.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation.png new file mode 100644 index 000000000..913b8505c Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-bot-app-feature.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-bot-app-feature.png new file mode 100644 index 000000000..e70694eab Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-bot-app-feature.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-configure-bot.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-configure-bot.png new file mode 100644 index 000000000..526356b34 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-configure-bot.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-copy-app-id.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-copy-app-id.png new file mode 100644 index 000000000..7e6fea75b Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-copy-app-id.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-create-a-new-bot.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-create-a-new-bot.png new file mode 100644 index 000000000..6c015acf7 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-create-a-new-bot.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-create-app.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-create-app.png new file mode 100644 index 000000000..ba307e1b9 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-create-app.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-create-bot.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-create-bot.png new file mode 100644 index 000000000..12be86084 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-create-bot.png differ diff --git a/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-download-app-package.png b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-download-app-package.png new file mode 100644 index 000000000..8a72a9243 Binary files /dev/null and b/docs/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-download-app-package.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-app-client-id.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-app-client-id.png new file mode 100644 index 000000000..7c040f758 Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-app-client-id.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-configuration.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-configuration.png new file mode 100644 index 000000000..043d51bcf Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-configuration.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-created.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-created.png new file mode 100644 index 000000000..c4208d14c Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-created.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-details.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-details.png new file mode 100644 index 000000000..68c58d795 Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-details.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-mapping.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-mapping.png new file mode 100644 index 000000000..ee8a3b948 Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-mapping.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-option.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-option.png new file mode 100644 index 000000000..6ff3f3f00 Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-option.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-parameters.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-parameters.png new file mode 100644 index 000000000..a3917965d Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-parameters.png differ diff --git a/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-review.png b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-review.png new file mode 100644 index 000000000..fca3a4c4c Binary files /dev/null and b/docs/images/secret-rotations-v2/azure-client-secret/azure-client-secret-review.png differ diff --git a/docs/images/secret-syncs/hashicorp-vault/select-option.png b/docs/images/secret-syncs/hashicorp-vault/select-option.png new file mode 100644 index 000000000..3ed6ce079 Binary files /dev/null and b/docs/images/secret-syncs/hashicorp-vault/select-option.png differ diff --git a/docs/images/secret-syncs/hashicorp-vault/sync-created.png b/docs/images/secret-syncs/hashicorp-vault/sync-created.png new file mode 100644 index 000000000..6fc827f2c Binary files /dev/null and b/docs/images/secret-syncs/hashicorp-vault/sync-created.png differ diff --git a/docs/images/secret-syncs/hashicorp-vault/sync-destination.png b/docs/images/secret-syncs/hashicorp-vault/sync-destination.png new file mode 100644 index 000000000..42ec57273 Binary files /dev/null and b/docs/images/secret-syncs/hashicorp-vault/sync-destination.png differ diff --git a/docs/images/secret-syncs/hashicorp-vault/sync-details.png b/docs/images/secret-syncs/hashicorp-vault/sync-details.png new file mode 100644 index 000000000..61f045db0 Binary files /dev/null and b/docs/images/secret-syncs/hashicorp-vault/sync-details.png differ diff --git a/docs/images/secret-syncs/hashicorp-vault/sync-options.png b/docs/images/secret-syncs/hashicorp-vault/sync-options.png new file mode 100644 index 000000000..fd96843f0 Binary files /dev/null and b/docs/images/secret-syncs/hashicorp-vault/sync-options.png differ diff --git a/docs/images/secret-syncs/hashicorp-vault/sync-review.png b/docs/images/secret-syncs/hashicorp-vault/sync-review.png new file mode 100644 index 000000000..f95a12e0c Binary files /dev/null and b/docs/images/secret-syncs/hashicorp-vault/sync-review.png differ diff --git a/docs/images/secret-syncs/hashicorp-vault/sync-source.png b/docs/images/secret-syncs/hashicorp-vault/sync-source.png new file mode 100644 index 000000000..b3440a8df Binary files /dev/null and b/docs/images/secret-syncs/hashicorp-vault/sync-source.png differ diff --git a/docs/images/sso/general-oidc/custom-oidc-form.png b/docs/images/sso/general-oidc/custom-oidc-form.png index 2aee02680..92a7a056d 100644 Binary files a/docs/images/sso/general-oidc/custom-oidc-form.png and b/docs/images/sso/general-oidc/custom-oidc-form.png differ diff --git a/docs/images/sso/general-oidc/discovery-oidc-form.png b/docs/images/sso/general-oidc/discovery-oidc-form.png index ae99b35b2..fb0daf1e7 100644 Binary files a/docs/images/sso/general-oidc/discovery-oidc-form.png and b/docs/images/sso/general-oidc/discovery-oidc-form.png differ diff --git a/docs/integrations/app-connections/azure-client-secrets.mdx b/docs/integrations/app-connections/azure-client-secrets.mdx new file mode 100644 index 000000000..55416303a --- /dev/null +++ b/docs/integrations/app-connections/azure-client-secrets.mdx @@ -0,0 +1,103 @@ +--- +title: "Azure Client Secrets Connection" +description: "Learn how to configure an Azure Client Secrets Connection for Infisical." +--- + +Infisical currently only supports one method for connecting to Azure, which is OAuth. + + + Using the Azure Client Secrets connection on a self-hosted instance of Infisical requires configuring an application in Azure + and registering your instance with it. + + **Prerequisites:** + + - Set up Azure. + + + + Navigate to Azure Active Directory > App registrations to create a new application. + + + Azure Active Directory is now Microsoft Entra ID. + + ![Azure client secrets](/images/integrations/azure-app-configuration/config-aad.png) + ![Azure client secrets](/images/integrations/azure-app-configuration/config-new-app.png) + + Create the application. As part of the form, set the **Redirect URI** to `https://your-domain.com/organization/app-connections/azure/oauth/callback`. + + The domain you defined in the Redirect URI should be equivalent to the `SITE_URL` configured in your Infisical instance. + + + ![Azure client secrets](/images/app-connections/azure/register-callback.png) + + + + For the Azure Connection to work with Client Secrets, you need to assign the following permission to the application. + + #### Azure Client Secrets permissions + + Set the API permissions of the Azure application to include the following permissions: + - Microsoft Graph + - `Application.ReadWrite.All` + - `Application.ReadWrite.OwnedBy` + - `Application.ReadWrite.All` (Delegated) + - `Directory.ReadWrite.All` (Delegated) + - `User.Read` (Delegated) + - Azure App Configuration + - `KeyValue.Delete` (Delegated) + - `KeyValue.Read` (Delegated) + - `KeyValue.Write` (Delegated) + - Access Key Vault + - `user_impersonation` (Delegated) + + ![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png) + + + + + Obtain the **Application (Client) ID** and **Directory (Tenant) ID** (this will be used later in the Infisical connection) in Overview and generate a **Client Secret** in Certificate & secrets for your Azure application. + + ![Azure client secrets](../../images/app-connections/azure/client-secrets/config-credentials-1.png) + ![Azure client secrets](../../images/integrations/azure-app-configuration/config-credentials-2.png) + ![Azure client secrets](../../images/integrations/azure-app-configuration/config-credentials-3.png) + + Back in your Infisical instance, add two new environment variables for the credentials of your Azure application. + + - `INF_APP_CONNECTION_AZURE_CLIENT_ID`: The **Application (Client) ID** of your Azure application. + - `INF_APP_CONNECTION_AZURE_CLIENT_SECRET`: The **Client Secret** of your Azure application. + + Once added, restart your Infisical instance and use the Azure Client Secrets connection. + + + + + +## Setup Azure Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. ![App Connections + Tab](/images/app-connections/general/add-connection.png) + + + Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/client-secrets/select-connection.png) + + + Fill in the **Tenant ID** field with the Directory (Tenant) ID you obtained in the previous step. + + Now select the **OAuth** method and click **Connect to Azure**. + + ![Connect via Azure OAUth](/images/app-connections/azure/client-secrets/create-oauth-method.png) + + + + + + You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, + you will be redirected back to Infisical's App Connections page. ![Azure Client Secrets + Authorization](/images/app-connections/azure/grant-access.png) + + + Your **Azure Client Secrets Connection** is now available for use. ![Azure Client Secrets](/images/app-connections/azure/client-secrets/oauth-connection.png) + + diff --git a/docs/integrations/app-connections/hashicorp-vault.mdx b/docs/integrations/app-connections/hashicorp-vault.mdx new file mode 100644 index 000000000..6e2ff68bf --- /dev/null +++ b/docs/integrations/app-connections/hashicorp-vault.mdx @@ -0,0 +1,214 @@ +--- +title: "Hashicorp Vault Connection" +description: "Learn how to configure a Hashicorp Vault Connection for Infisical." +--- + + + Infisical is compatible with Vault Self-hosted, HCP Vault Dedicated, and HCP Vault Enterprise deployments. Please note that HCP Generic Secrets are currently not supported. + + +Infisical supports two methods for connecting to Hashicorp Vault. + + + + + + ![Vault Access](/images/app-connections/hashicorp-vault/vault-access.png) + + + In the **Authentication Methods** tab, click on **Enable new method**. + + ![Vault Enable Method](/images/app-connections/hashicorp-vault/vault-authentication-methods.png) + + + ![Vault AppRole](/images/app-connections/hashicorp-vault/vault-approle.png) + + + You may change the name of the method, but we suggest keeping it as `approle`. + + ![Vault Enable Method](/images/app-connections/hashicorp-vault/vault-enable-method.png) + + + From the home page, navigate to **Policies**. + + ![Vault Policies Navigate](/images/app-connections/hashicorp-vault/vault-policies-navigate.png) + + + ![Vault Policies Page](/images/app-connections/hashicorp-vault/vault-policies-page.png) + + + You may name your policy whatever you want, but remember the name as it will be used in future steps. + + Depending on your use case, you may have different policy configurations: + + + + ```hcl + path "demo_mount/data/*" { + capabilities = [ "create", "read", "update" ] + } + + path "sys/mounts" { + capabilities = ["read"] + } + ``` + + - **demo_mount**: The name of the target secrets engine (e.g., 'secret', 'kv'). + - **data/\***: The path within the secrets engine used for storing secrets. The wildcard (*) grants access to all secrets within this mount point. + + + Make sure to replace the policy path with the specific path where you intend to sync your secrets. For better security and control, it's recommended to use a more granular path instead of a wildcard (*). You can also specify a path that doesn’t yet exist—Infisical will automatically create it for you during the sync process. + + + + + ![Vault Create Policy](/images/app-connections/hashicorp-vault/vault-create-policy.png) + + + **Open Vault Shell** + + ![Vault Shell](/images/app-connections/hashicorp-vault/vault-shell.png) + + + If you used custom approle or policy names in previous steps, you'll need to customize the following commands. + + + **Create Infisical Role** + + ```hcl + vault write auth/approle/role/infisical token_policies="infisical-policy" token_ttl=30s token_max_ttl=2m + ``` + + **Read RoleID** + + ```hcl + vault read auth/approle/role/infisical/role-id + ``` + + **Generate New SecretID** + + ```hcl + vault write -force auth/approle/role/infisical/secret-id + ``` + + Your shell output should look similar to the image below. Save the RoleID and SecretID values for later steps. + + ![Vault Shell Output](/images/app-connections/hashicorp-vault/vault-shell-output.png) + + + + + ## Get a Hashicorp Vault Access Token + + Open your profile dropdown and click **Copy token**. This token will be used in later steps. + + ![Vault Profile Copy Token](/images/app-connections/hashicorp-vault/vault-profile-token.png) + + + +## Getting Vault Instance URL + + + + For self-hosted instances, locate and copy your vault's base URL (for example: `https://vault.example.com`). + + Save the URL for later steps. + + + On HCP instances, you may need to navigate to **Cluster Overview** to see your cluster URL. Save this value for later steps. + + ![Vault Cluster URLs](/images/app-connections/hashicorp-vault/vault-cluster-urls.png) + + + Cluster Overview is found in the HCP dashboard, not in your cluster's web UI. + + + + +## Setup Vault Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and select the **App Connections** tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **+ Add Connection** button and select the **Hashicorp Vault Connection** option. + + ![Select Vault Connection](/images/app-connections/hashicorp-vault/vault-infisical-connect-page.png) + + + Configure your Vault Connection using the Instance URL and credentials from the steps above. **Depending on if you chose to authenticate with an Access Token or AppRole, you may need to input different information.** + + ![Vault Configure Connection](/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png) + + + + - **Name**: The name of the connection being created. Must be slug-friendly. + - **Description**: An optional description to provide details about this connection. + - **Instance URL**: The URL of your Hashicorp Vault instance. + - **Namespace (optional)**: The namespace within your vault. Self-hosted and enterprise clusters may not use namespaces. + - **Role ID**: The Role ID generated in the steps above. + - **Secret ID**: The Secret ID generated in the steps above. + + + - **Name**: The name of the connection being created. Must be slug-friendly. + - **Description**: An optional description to provide details about this connection. + - **Instance URL**: The URL of your Hashicorp Vault instance. + - **Namespace (optional)**: The namespace within your vault. Self-hosted and enterprise clusters may not use namespaces. + - **Access Token**: The Access Token generated in the steps above. + + + + + Your Vault Connection is now available for use. + ![Vault Connection Created](/images/app-connections/hashicorp-vault/vault-infisical-connect-success.png) + + + + + To create a Vault Connection, make an API request to the [Create Hashicorp Vault + Connection](/api-reference/endpoints/app-connections/hashicorp-vault/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/hashicorp-vault \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-vault-connection", + "method": "app-role", + "credentials": { + "instanceUrl": "https://vault.example.com", + "roleId": "4797c4fa-7794-71f0-c8b1-7c87759df5bf", + "secretId": "ad24df93-19c8-c865-9997-6b8513253d3a" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-vault-connection", + "version": 1, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-04-01T05:31:56Z", + "updatedAt": "2025-04-01T05:31:56Z", + "app": "hashicorp-vault", + "method": "app-role", + "credentials": { + "instanceUrl": "https://vault.example.com", + "roleId": "4797c4fa-7794-71f0-c8b1-7c87759df5bf" + } + } + } + ``` + + diff --git a/docs/integrations/cloud/hashicorp-vault.mdx b/docs/integrations/cloud/hashicorp-vault.mdx index 51a66f3ff..df2542ce7 100644 --- a/docs/integrations/cloud/hashicorp-vault.mdx +++ b/docs/integrations/cloud/hashicorp-vault.mdx @@ -4,158 +4,5 @@ description: "How to sync secrets from Infisical to HashiCorp Vault" --- - Infisical connects to Vault via the AppRole auth method. - - Currently, each Infisical project can only point and sync secrets to one Vault cluster / namespace - but with unlimited integrations to different paths within it. - - This tutorial makes use of Vault's UI but, in principle, instructions can executed via - Vault CLI or API call. - - Lastly, you should note that we provide a simple use-case and, in practice, you should adapt and extend it to your own Vault use-case and follow best practices, for instance when defining fine-grained ACL policies. + The Hashicorp Vault Native Integration will be deprecated in 2026. Please migrate to our new [Hashicorp Vault Sync](../secret-syncs/hashicorp-vault). - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) -- Have experience with [HashiCorp Vault](https://www.vaultproject.io/). - -## Navigate to your project's integrations tab - -![integrations](../../images/integrations.png) - -## Prepare Vault - -This section mirrors the latter parts of the [Vault quickstart](https://developer.hashicorp.com/vault/tutorials/cloud/getting-started-intro) provided by HashiCorp and uses sample names/values for demonstration. - -To begin, navigate to the cluster / namespace that you want to sync secrets to in Vault; we'll use the default `admin` namespace (in practice, we recommend creating a namespace and not using the default `admin` namespace). - -### Enable KV Secrets Engine - -In Secrets, enable a KV Secrets Engine at a path for Infisical to sync secrets to; we'll use the path `kv`. - -![integrations hashicorp vault secrets engine](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-1.png) -![integrations hashicorp vault secrets engine](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-2.png) -![integrations hashicorp vault secrets engine](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-3.png) - -### Enable the AppRole auth method - -In Access > Auth Methods, enable the AppRole auth method. - -![integrations hashicorp vault access](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-1.png) -![integrations hashicorp vault access](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-2.png) -![integrations hashicorp vault access](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-3.png) - -### Create an ACL Policy - -Now in Policies, create a new ACL policy scoped to the path(s) you wish Infisical to be able to sync secrets to. - -We'll call the policy `test` and have it grant access to the `dev` path in the KV Secrets Engine where we will be syncing secrets to from Infisical. - -```console -path "kv/data/dev" { - capabilities = [ "create", "read", "update" ] -} - -path "sys/namespaces/*" { - capabilities = [ "create", "read", "update", "delete", "list" ] -} -``` - - - `kv` comes from the path of the KV Secrets Engine that we enabled and `dev` is the chosen path within it - that we want to sync secrets to. - - -![integrations hashicorp vault policy](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-1.png) -![integrations hashicorp vault policy](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-2.png) -![integrations hashicorp vault policy](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-3.png) - -### Create a role with the policy attached - -We now create a `infisical` role with the generated token's time-to-live (TTL) set to 1 hour and can be renewed for up to 4 hours from the time of its creation. - -1. Click the Vault CLI shell icon (`>_`) to open a command shell in the browser. - -![integrations hashicorp vault shell](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-shell.png) - -2. Copy the command below. - -```console -vault write auth/approle/role/infisical token_policies="test" token_ttl=1h token_max_ttl=4h -``` - -3. Paste the command into the command shell in the browser and press the enter button. - -### Generate a RoleID and SecretID - -Finally, we need to generate a **RoleID** and **SecretID** (like a username and password) that Infisical can use -to authenticate with Vault. - -1. Click the Vault CLI shell icon (>_) again to open a command shell. - -2. Read the RoleID. - -```console -vault read auth/approle/role/infisical/role-id -``` - -Example output: - -```console -Key Value -role_id b6ccdcca-183b-ce9c-6b98-b556b9a0edb9 -``` - -3. Generate a new SecretID of the `infisical` role. - -```console -vault write -force auth/approle/role/infisical/secret-id -``` - -Example output: - - -```console -Key Value -secret_id 735a47cc-7a98-77cc-0128-12b1e96a4157 -secret_id_accessor 3ab305d1-1eab-df4b-4079-ef7135635c49 -...snip... -``` - -Great. We're now ready to connect Infisical to Vault! - -## Enter your Vault instance and authentication details - -Back in Infisical, press on the HashiCorp Vault tile and input your Vault instance and `infisical` role RoleID and SecretID. - -![integrations hashicorp vault authorization](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-auth.png) - -For additional details on each field: - -- Vault Cluster URL: The address of your cluster, either HCP or self-hosted. - -If using HCP, you can copy your Cluster URL in the Cluster Overview: - -![integrations hashicorp vault cluster URL](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-cluster-url.png) - -- Vault Namespace: The Vault namespace you wish to connect to. -- Vault RoleID: The RoleID previously created for the `infisical` role. -- Vault SecretID: The SecretID previously created for the `infisical` role. - -## Start integration - -Select which Infisical environment secrets you want to sync to Vault. - -For additional details on each field: - -- Vault KV Secrets Engine Path: the path at which you enabled the intended KV Secrets Engine; in this demonstration, we used `kv`. -- Vault Secret(s) Path: the path in the KV Secrets Engine that you wish to sync secrets to. - -Press create integration to start syncing secrets to Vault. - -![integrations hashicorp vault](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-create.png) -![integrations hashicorp vault](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault.png) - - - diff --git a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx index 4c33b893b..a79a8d0a5 100644 --- a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx @@ -1230,13 +1230,13 @@ To address this, we added functionality to automatically redeploy your deploymen #### Enabling Automatic Redeployment -To enable auto redeployment you simply have to add the following annotation to the deployment, statefulset, or daemonset that consumes a managed secret. +To enable auto redeployment you simply have to add the following annotation to the Deployment, StatefulSet, or DaemonSet that consumes a managed secret. ```yaml secrets.infisical.com/auto-reload: "true" ``` - + ```yaml apiVersion: apps/v1 kind: Deployment @@ -1266,10 +1266,82 @@ secrets.infisical.com/auto-reload: "true" - containerPort: 80 ``` + + + ```yaml + apiVersion: apps/v1 + kind: DaemonSet + metadata: + name: log-agent + labels: + app: log-agent + annotations: + secrets.infisical.com/auto-reload: "true" # <- redeployment annotation + spec: + selector: + matchLabels: + app: log-agent + template: + metadata: + labels: + app: log-agent + spec: + containers: + - name: log-agent + image: mycompany/log-agent:latest + envFrom: + - secretRef: + name: managed-secret # <- name of the managed secret + volumeMounts: + - name: config-volume + mountPath: /etc/config + readOnly: true + volumes: + - name: config-volume + secret: + secretName: managed-secret + ``` + + + + ```yaml + apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: db-worker + labels: + app: db-worker + annotations: + secrets.infisical.com/auto-reload: "true" # <- redeployment annotation + spec: + selector: + matchLabels: + app: db-worker + serviceName: "db-worker" + replicas: 2 + template: + metadata: + labels: + app: db-worker + spec: + containers: + - name: db-worker + image: mycompany/db-worker:stable + env: + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: managed-secret + key: DB_PASSWORD + ports: + - containerPort: 5432 + ``` + + #### How it works - When a secret change occurs, the operator will check to see which deployments are using the operator-managed Kubernetes secret that received the update. - Then, for each deployment that has this annotation present, a rolling update will be triggered. + When a managed secret is updated, the operator checks for any Deployments, DaemonSets, or StatefulSets that consume the updated secret and have the annotation + `secrets.infisical.com/auto-reload: "true"`. For each matching workload, the operator triggers a rolling restart to ensure it picks up the latest secret values. ## Using Managed ConfigMap In Your Deployment diff --git a/docs/integrations/secret-syncs/hashicorp-vault.mdx b/docs/integrations/secret-syncs/hashicorp-vault.mdx new file mode 100644 index 000000000..0d6c0d644 --- /dev/null +++ b/docs/integrations/secret-syncs/hashicorp-vault.mdx @@ -0,0 +1,160 @@ +--- +title: "Hashicorp Vault Sync" +description: "Learn how to configure a Hashicorp Vault Sync for Infisical." +--- + +**Prerequisites:** + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [Hashicorp Vault Connection](/integrations/app-connections/hashicorp-vault) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Hashicorp Vault](/images/secret-syncs/hashicorp-vault/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/hashicorp-vault/sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed. + + ![Configure Destination](/images/secret-syncs/hashicorp-vault/sync-destination.png) + + - **Hashicorp Vault Connection**: The Vault Connection to authenticate with. + - **Secrets Engine Mount**: The secrets engine to sync secrets with (e.g., 'secret', 'kv'). + - **Path**: The specific path within the secrets engine where secrets will be stored. + + After configuring these parameters, click the **Next** button to continue to the Sync Options step. + + + If the **path** you provide does not exist in Vault, it will be created. + + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/hashicorp-vault/sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Hashicorp Vault when keys conflict. + - **Import Secrets (Prioritize Hashicorp Vault)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Hashicorp Vault over Infisical when keys conflict. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Hashicorp Vault Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/hashicorp-vault/sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Hashicorp Vault Sync configuration, then click **Create Sync**. + + ![Confirm Configuration](/images/secret-syncs/hashicorp-vault/sync-review.png) + + + If enabled, your Hashicorp Vault Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/hashicorp-vault/sync-created.png) + + + + + To create an **Hashicorp Vault Sync**, make an API request to the [Create Hashicorp Vault Sync](/api-reference/endpoints/secret-syncs/hashicorp-vault/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/hashicorp-vault \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-vault-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "mount": "secret", + "path": "dev/nested" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-vault-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "hashicorp-vault", + "name": "my-vault-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "destination": "hashicorp-vault", + "destinationConfig": { + "mount": "secret", + "path": "dev/nested" + } + } + } + ``` + + diff --git a/docs/internals/bug-bounty.mdx b/docs/internals/bug-bounty.mdx new file mode 100644 index 000000000..b823e6246 --- /dev/null +++ b/docs/internals/bug-bounty.mdx @@ -0,0 +1,60 @@ +--- +title: "Bug bounty program" +description: " Learn about our bug bounty program and how to report vulnerabilities." +--- + +The Infisical Bug Bounty Program is our way of recognizing and rewarding the work of security researchers who help keep our platform secure. By reporting vulnerabilities or potential risks, you help us protect secrets, infrastructure, and the organizations who rely on us. + +We value reports that help identify vulnerabilities that affect the integrity of secrets, prevent unauthorized access to environments, or expose flaws in our authentication or authorization flows. + +### How to Report + +- Send reports to **security@infisical.com** with clear steps to reproduce, impact, and (if possible) a proof-of-concept. +- We will acknowledge receipt within 3 business days. +- We'll provide an initial assessment or next steps within 5 business days. + +### What's in Scope? + +- Vulnerabilities in our cloud-hosted platform (e.g., `app.infisical.com`, `eu.infisical.com`) +- Security issues in the open source Infisical codebase, as maintained in our official GitHub repository +- Authentication bypass, privilege escalation, or access to secrets/data without authorization + +### Reward Guidelines + +Bounties are based on severity, impact, and exploitability, as well as whether the report introduces a new vulnerability class or helps improve an existing fix. + +| Severity | Examples | Typical Reward (USD currency) | +| --- | --- | --- | +| **Critical** | Full unauthorized access to secrets, authentication bypass, cross-tenant access, RCE, full compromise, etc | $2,000 - $5,000 | +| **High** | Privilege escalation, project-level access without authorization, persistent DoS | $750 - $2,000 | +| **Medium** | Info disclosure, scoped DoS (e.g. ReDoS with auth), or minor access control issues | $250 - $1,000 | +| **Low / Informational** | Missing headers, CSP warnings, theoretical flaws, self-hosting misconfigurations | Recognition only | + + +We may award lower amounts for: +- Duplicate class vulnerabilities already under review +- Patch bypasses of previously rewarded issues +- Vulnerabilities requiring unrealistic attacker conditions + +All final reward amounts are determined at Infisical's discretion based on impact, report quality, and how actionable the issue is. + + +### Out of Scope + +- Social engineering or phishing +- Rate limiting issues on non-sensitive endpoints +- Denial-of-service attacks that require authentication and don't impact core service availability +- Findings based on outdated or forked code not maintained by the Infisical team +- Vulnerabilities in third-party dependencies unless they result in a direct risk to Infisical users + + +### Responsible Disclosure + +We ask that researchers: + +- Avoid accessing data that isn't yours +- Do not publicly disclose without coordination +- Use testing accounts where possible +- Give us a reasonable window to investigate and patch before going public + +Researchers can also spin up our [self-hosted version of Infisical](/self-hosting/overview) to test for vulnerabilities locally. \ No newline at end of file diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx index 17daf88cb..219c32287 100644 --- a/docs/internals/security.mdx +++ b/docs/internals/security.mdx @@ -118,8 +118,6 @@ It should be noted that, even on Infisical Cloud, it is physically impossible fo Please email security@infisical.com if you have any specific inquiries about employee data and security policies. -## Get in touch - -If you have any concerns about Infisical or believe you have uncovered a vulnerability, please get in touch via the e-mail address security@infisical.com. In the message, try to provide a description of the issue and ideally a way of reproducing it. The security team will get back to you as soon as possible. - -Note that this security address should be used for undisclosed vulnerabilities. Please report any security problems to us before disclosing it publicly. +## Bug Bounty Program +We run a [Bug Bounty Program](/internals/bug-bounty) to recognize and reward security researchers who help make Infisical more secure. +If you've found a vulnerability, please review the program details for scope, disclosure guidelines, and reward tiers. \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index 63eb41ddb..a25a70124 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -118,7 +118,13 @@ "documentation/platform/pki/alerting" ] }, - "documentation/platform/ssh", + { + "group": "Infisical SSH", + "pages": [ + "documentation/platform/ssh/overview", + "documentation/platform/ssh/host-groups" + ] + }, { "group": "Key Management (KMS)", "pages": [ @@ -181,6 +187,7 @@ "documentation/platform/secret-rotation/overview", "documentation/platform/secret-rotation/auth0-client-secret", "documentation/platform/secret-rotation/aws-iam-user-secret", + "documentation/platform/secret-rotation/azure-client-secret", "documentation/platform/secret-rotation/ldap-password", "documentation/platform/secret-rotation/mssql-credentials", "documentation/platform/secret-rotation/postgres-credentials" @@ -221,7 +228,8 @@ { "group": "Workflow Integrations", "pages": [ - "documentation/platform/workflow-integrations/slack-integration" + "documentation/platform/workflow-integrations/slack-integration", + "documentation/platform/workflow-integrations/microsoft-teams-integration" ] }, { @@ -430,11 +438,13 @@ "integrations/app-connections/auth0", "integrations/app-connections/aws", "integrations/app-connections/azure-app-configuration", + "integrations/app-connections/azure-client-secrets", "integrations/app-connections/azure-key-vault", "integrations/app-connections/camunda", "integrations/app-connections/databricks", "integrations/app-connections/gcp", "integrations/app-connections/github", + "integrations/app-connections/hashicorp-vault", "integrations/app-connections/humanitec", "integrations/app-connections/ldap", "integrations/app-connections/mssql", @@ -462,6 +472,7 @@ "integrations/secret-syncs/databricks", "integrations/secret-syncs/gcp-secret-manager", "integrations/secret-syncs/github", + "integrations/secret-syncs/hashicorp-vault", "integrations/secret-syncs/humanitec", "integrations/secret-syncs/teamcity", "integrations/secret-syncs/terraform-cloud", @@ -887,6 +898,19 @@ "api-reference/endpoints/secret-rotations/aws-iam-user-secret/update" ] }, + { + "group": "Azure Client Secret", + "pages": [ + "api-reference/endpoints/secret-rotations/azure-client-secret/create", + "api-reference/endpoints/secret-rotations/azure-client-secret/delete", + "api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id", + "api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name", + "api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/azure-client-secret/list", + "api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets", + "api-reference/endpoints/secret-rotations/azure-client-secret/update" + ] + }, { "group": "LDAP Password", "pages": [ @@ -931,12 +955,28 @@ { "group": "Identity Specific Privilege", "pages": [ - "api-reference/endpoints/identity-specific-privilege/create-permanent", - "api-reference/endpoints/identity-specific-privilege/create-temporary", - "api-reference/endpoints/identity-specific-privilege/update", - "api-reference/endpoints/identity-specific-privilege/delete", - "api-reference/endpoints/identity-specific-privilege/find-by-slug", - "api-reference/endpoints/identity-specific-privilege/list" + { + "group": "V1 (Legacy)", + "pages": [ + "api-reference/endpoints/identity-specific-privilege/v1/create-permanent", + "api-reference/endpoints/identity-specific-privilege/v1/create-temporary", + "api-reference/endpoints/identity-specific-privilege/v1/update", + "api-reference/endpoints/identity-specific-privilege/v1/delete", + "api-reference/endpoints/identity-specific-privilege/v1/find-by-slug", + "api-reference/endpoints/identity-specific-privilege/v1/list" + ] + }, + { + "group": "V2", + "pages": [ + "api-reference/endpoints/identity-specific-privilege/v2/create", + "api-reference/endpoints/identity-specific-privilege/v2/update", + "api-reference/endpoints/identity-specific-privilege/v2/delete", + "api-reference/endpoints/identity-specific-privilege/v2/list", + "api-reference/endpoints/identity-specific-privilege/v2/find-by-id", + "api-reference/endpoints/identity-specific-privilege/v2/find-by-slug" + ] + } ] }, { @@ -980,6 +1020,18 @@ "api-reference/endpoints/app-connections/azure-app-configuration/delete" ] }, + { + "group": "Azure Client Secret", + "pages": [ + "api-reference/endpoints/app-connections/azure-client-secret/list", + "api-reference/endpoints/app-connections/azure-client-secret/available", + "api-reference/endpoints/app-connections/azure-client-secret/get-by-id", + "api-reference/endpoints/app-connections/azure-client-secret/get-by-name", + "api-reference/endpoints/app-connections/azure-client-secret/create", + "api-reference/endpoints/app-connections/azure-client-secret/update", + "api-reference/endpoints/app-connections/azure-client-secret/delete" + ] + }, { "group": "Azure Key Vault", "pages": [ @@ -1040,6 +1092,18 @@ "api-reference/endpoints/app-connections/github/delete" ] }, + { + "group": "Hashicorp Vault", + "pages": [ + "api-reference/endpoints/app-connections/hashicorp-vault/list", + "api-reference/endpoints/app-connections/hashicorp-vault/available", + "api-reference/endpoints/app-connections/hashicorp-vault/get-by-id", + "api-reference/endpoints/app-connections/hashicorp-vault/get-by-name", + "api-reference/endpoints/app-connections/hashicorp-vault/create", + "api-reference/endpoints/app-connections/hashicorp-vault/update", + "api-reference/endpoints/app-connections/hashicorp-vault/delete" + ] + }, { "group": "Humanitec", "pages": [ @@ -1252,6 +1316,20 @@ "api-reference/endpoints/secret-syncs/github/remove-secrets" ] }, + { + "group": "Hashicorp Vault", + "pages": [ + "api-reference/endpoints/secret-syncs/hashicorp-vault/list", + "api-reference/endpoints/secret-syncs/hashicorp-vault/get-by-id", + "api-reference/endpoints/secret-syncs/hashicorp-vault/get-by-name", + "api-reference/endpoints/secret-syncs/hashicorp-vault/create", + "api-reference/endpoints/secret-syncs/hashicorp-vault/update", + "api-reference/endpoints/secret-syncs/hashicorp-vault/delete", + "api-reference/endpoints/secret-syncs/hashicorp-vault/sync-secrets", + "api-reference/endpoints/secret-syncs/hashicorp-vault/import-secrets", + "api-reference/endpoints/secret-syncs/hashicorp-vault/remove-secrets" + ] + }, { "group": "Humanitec", "pages": [ @@ -1415,6 +1493,34 @@ { "group": "Infisical SSH", "pages": [ + { + "group": "Hosts", + "pages": [ + "api-reference/endpoints/ssh/hosts/list-my", + "api-reference/endpoints/ssh/hosts/list", + "api-reference/endpoints/ssh/hosts/create", + "api-reference/endpoints/ssh/hosts/read", + "api-reference/endpoints/ssh/hosts/update", + "api-reference/endpoints/ssh/hosts/delete", + "api-reference/endpoints/ssh/hosts/issue-host-cert", + "api-reference/endpoints/ssh/hosts/issue-user-cert", + "api-reference/endpoints/ssh/hosts/read-user-ca-pk", + "api-reference/endpoints/ssh/hosts/read-host-ca-pk" + ] + }, + { + "group": "Host Groups", + "pages": [ + "api-reference/endpoints/ssh/groups/list", + "api-reference/endpoints/ssh/groups/create", + "api-reference/endpoints/ssh/groups/read", + "api-reference/endpoints/ssh/groups/update", + "api-reference/endpoints/ssh/groups/delete", + "api-reference/endpoints/ssh/groups/add-host", + "api-reference/endpoints/ssh/groups/list-hosts", + "api-reference/endpoints/ssh/groups/remove-host" + ] + }, { "group": "Certificates", "pages": [ @@ -1493,6 +1599,7 @@ }, "internals/components", "internals/security", + "internals/bug-bounty", "internals/service-tokens" ] }, diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 8318869f2..d9eef9cb0 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -73,7 +73,8 @@ The platform utilizes Postgres to persist all of its data and Redis for caching ### PostgreSQL - Please note that the database user must have **CREATE** privileges along with ability to create and modify tables. This is needed for Infisical to run schema migrations. + Please note that the database user you create must be granted all privileges on the Infisical database. + This includes the ability to create new schemas, create, update, delete, modify tables and indexes, etc. diff --git a/docs/snippets/documentation/platform/workflow-integrations/microsoft-teams-integration.mdx b/docs/snippets/documentation/platform/workflow-integrations/microsoft-teams-integration.mdx new file mode 100644 index 000000000..a6fc1be50 --- /dev/null +++ b/docs/snippets/documentation/platform/workflow-integrations/microsoft-teams-integration.mdx @@ -0,0 +1,100 @@ + ### Create Microsoft Teams workflow integration + + + + Currently, Infisical requires you to install a custom Microsoft Teams app into your Microsoft Teams tenant. + + You can download the Infisical Microsoft Teams app package here: + - [Infisical Microsoft Teams app package](https://infisical-microsoft-teams-app.s3.us-east-1.amazonaws.com/Infisical.zip) + + + **Important for self-hosted users:** + + If you're self-hosting Infisical, you can skip the download step. Instead you should use the app package file you downloaded from the Microsoft Teams Developer Portal when you followed the Self-hosted guide. + + + Once you've downloaded the app package, you can install the app in your Microsoft Teams tenant by navigating to the **Apps** > **Upload a custom app** page, and selecting the "Upload an app" button. + + ![microsoft-teams-install-app](/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-install-app.png) + ![microsoft-teams-submit-app](/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-submit-app.png) + + Once the app has been submitted, your Microsoft Teams tenant admin will need to approve the app in the [Microsoft Teams Admin Center](https://admin.teams.microsoft.com/policies/manage-apps). + + + After the app has been approved, it can take a few hours _(up to 24 hours in some cases)_ for Microsoft Teams to reflect the new app. During this period, the Infisical app will not be visible in Microsoft Teams, and won't be usable. + + + Once the app has been approved, you will be able to use the Infisical Microsoft Teams integration in your projects. + + + + Once the app has been approved and installed in your Microsoft Teams tenant, you can add the app to your Microsoft Teams teams. + + ![microsoft-teams-add-app](/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-add-app.png) + + Navigate to **Apps** > **Built for your org**, select the "Infisical" app, and press the "Add" button to select the teams and channels you wish to add the app to. + + + This can also be done later through the [Microsoft Teams Admin Center](https://admin.teams.microsoft.com/policies/manage-apps), or through the Microsoft Teams client itself by navigating to the individual team's app settings. + + + Once the app has been added to the team, you will be able to use the Infisical Microsoft Teams integration in the team. + + + + + After installing the Microsoft Teams app, you are now ready to configure the Microsoft Teams integration within Infisical. + + Navigate to the **Workflow Integrations** tab in your organization settings, and press the "Add" button. + + ![org-integrations-overview](/images/platform/workflow-integrations/microsoft-teams-integration/infisical-org-overview.png) + + + + In order to use the Infisical Microsoft Teams integration, you will need to grant admin consent to the app. Once the consent is granted, the Microsoft Teams workflow integration will be created in your Infisical organization. + + Press the "Add" button and select the "Microsoft Teams" platform option. + ![add-microsoft-teams-integration](/images/platform/workflow-integrations/microsoft-teams-integration/infisical-org-add-microsoft-teams-integration.png) + + Select the Microsoft Teams integration you wish to configure, and press the "Configure" button. + + Here you will be prompted to enter an alias, tenant ID, and an optional description for your workflow integration. + The tenant ID is the ID of the Microsoft 365 / Azure AD tenant that you installed the Infisical Microsoft Teams app in, in the previous steps. + + ![configure-microsoft-teams-integration](/images/platform/workflow-integrations/microsoft-teams-integration/infisical-org-microsoft-teams-integration-modal.png) + + Press the "Create Microsoft Teams Integration" button, and you'll be navigated to the Azure AD consent page. + + ![microsoft-consent-page](/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-consent-page.png) + + + Please note that you must be a privileged administrator user of your Microsoft 365 / Azure AD tenant in order to grant admin consent to the app. + + + Once you've granted admin consent, you'll be navigated back to the Infisical organization settings, where you can now select the Microsoft Teams integration you just created. + + ![microsoft-teams-workflow-integration-created](/images/platform/workflow-integrations/microsoft-teams-integration/microsoft-teams-workflow-integration-created.png) + + + + ### Configure project to use Microsoft Teams workflow integration + + + + + To add a new Microsoft Teams workflow integration, navigate to **Project Settings** > **Workflow Integrations** and press the "Add". + ![project-settings-workflow-integrations](/images/platform/workflow-integrations/microsoft-teams-integration/infisical-project-settings.png) + + Select the "Microsoft Teams" option from the list of available workflow integrations. + + + + Your project will send notifications to the connected Microsoft Teams team of the + selected Microsoft Teams integration when the configured events are triggered. + + + Press the "Save" button to save your Microsoft Teams workflow integration. + ![infisical-project-microsoft-teams-integration-save](/images/platform/workflow-integrations/microsoft-teams-integration/infisical-project-microsoft-teams-integration-save.png) + + + Once you've created the project Microsoft Teams workflow integration, you will now receive Access Requests and Secret Approval Requests notifications in Microsoft Teams according to your configuration. \ No newline at end of file diff --git a/frontend/src/components/auth/Mfa.tsx b/frontend/src/components/auth/Mfa.tsx index 3eff8a89c..64c8a5ee6 100644 --- a/frontend/src/components/auth/Mfa.tsx +++ b/frontend/src/components/auth/Mfa.tsx @@ -31,6 +31,25 @@ const codeInputProps = { } } as const; +const codeInputPropsPhone = { + 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; + type Props = { successCallback: () => void | Promise; closeMfa?: () => void; @@ -172,6 +191,24 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop )} +
+ {method === MfaMethod.EMAIL && ( + + )} + {method === MfaMethod.TOTP && ( +
+ setMfaCode(e.target.value)} /> +
+ )} +
{typeof triesLeft === "number" && ( )} diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials.tsx new file mode 100644 index 000000000..7dd94b6c5 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials.tsx @@ -0,0 +1,38 @@ +import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay"; +import { TAzureClientSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/azure-client-secret-rotation"; + +import { ViewRotationGeneratedCredentialsDisplay } from "./shared"; + +type Props = { + generatedCredentialsResponse: TAzureClientSecretRotationGeneratedCredentialsResponse; +}; + +export const ViewAzureClientSecretRotationGeneratedCredentials = ({ + generatedCredentialsResponse: { generatedCredentials, activeIndex } +}: Props) => { + const inactiveIndex = activeIndex === 0 ? 1 : 0; + + const activeCredentials = generatedCredentials[activeIndex]; + const inactiveCredentials = generatedCredentials[inactiveIndex]; + + return ( + + {activeCredentials?.clientId} + + {activeCredentials?.clientSecret} + + + } + inactiveCredentials={ + <> + {inactiveCredentials?.clientId} + + {inactiveCredentials?.clientSecret} + + + } + /> + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx index d5af51eef..a8a00e17f 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { ViewAuth0ClientSecretRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAuth0ClientSecretRotationGeneratedCredentials"; +import { ViewAzureClientSecretRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials"; import { ViewLdapPasswordRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials"; import { Modal, ModalContent, Spinner } from "@app/components/v2"; import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2"; @@ -75,6 +76,13 @@ const Content = ({ secretRotation }: ContentProps) => { /> ); break; + case SecretRotation.AzureClientSecret: + Component = ( + + ); + break; case SecretRotation.LdapPassword: Component = ( { + const { control, watch, setValue } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AzureClientSecret; + } + >(); + + const connectionId = watch("connection.id"); + + const { data: clients, isPending: isClientsPending } = useAzureConnectionListClients( + connectionId, + { enabled: Boolean(connectionId) } + ); + + return ( + ( + + Ensure that your connection has the{" "} + + Application.ReadWrite.All, Directory.ReadWrite.All, + Application.ReadWrite.OwnedBy, user_impersonation and User.Read + {" "} + permissions and the application exists in Azure. + + } + > +
+ Don't see the application you're looking for?{" "} + +
+ + } + > + client.id === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue("parameters.appName", (option as SingleValue)?.name ?? ""); + setValue("parameters.clientId", (option as SingleValue)?.appId ?? ""); + }} + options={clients} + placeholder="Select an application..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx index cdbf63111..a871eb54f 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx @@ -5,6 +5,7 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2Form } from "../schemas"; import { Auth0ClientSecretRotationParametersFields } from "./Auth0ClientSecretRotationParametersFields"; import { AwsIamUserSecretRotationParametersFields } from "./AwsIamUserSecretRotationParametersFields"; +import { AzureClientSecretRotationParametersFields } from "./AzureClientSecretRotationParametersFields"; import { LdapPasswordRotationParametersFields } from "./LdapPasswordRotationParametersFields"; import { SqlCredentialsRotationParametersFields } from "./shared"; @@ -12,6 +13,7 @@ const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationParametersFields, + [SecretRotation.AzureClientSecret]: AzureClientSecretRotationParametersFields, [SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationParametersFields }; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AzureClientSecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AzureClientSecretRotationReviewFields.tsx new file mode 100644 index 000000000..9770d14a4 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AzureClientSecretRotationReviewFields.tsx @@ -0,0 +1,30 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +import { SecretRotationReviewSection } from "./shared"; + +export const AzureClientSecretRotationReviewFields = () => { + const { watch } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AzureClientSecret; + } + >(); + + const [parameters, { clientId, clientSecret }] = watch(["parameters", "secretsMapping"]); + + return ( + <> + + {parameters.appName} + {parameters.objectId} + + + {clientId} + {clientSecret} + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx index 23ee25d7f..2bfdc16fd 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx @@ -8,6 +8,7 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { Auth0ClientSecretRotationReviewFields } from "./Auth0ClientSecretRotationReviewFields"; import { AwsIamUserSecretRotationReviewFields } from "./AwsIamUserSecretRotationReviewFields"; +import { AzureClientSecretRotationReviewFields } from "./AzureClientSecretRotationReviewFields"; import { LdapPasswordRotationReviewFields } from "./LdapPasswordRotationReviewFields"; import { SqlCredentialsRotationReviewFields } from "./shared"; @@ -15,6 +16,7 @@ const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationReviewFields, + [SecretRotation.AzureClientSecret]: AzureClientSecretRotationReviewFields, [SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationReviewFields }; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AzureClientSecretRotationSecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AzureClientSecretRotationSecretsMappingFields.tsx new file mode 100644 index 000000000..77a34d083 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AzureClientSecretRotationSecretsMappingFields.tsx @@ -0,0 +1,58 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { FormControl, Input } from "@app/components/v2"; +import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2"; + +import { SecretsMappingTable } from "./shared"; + +export const AzureClientSecretRotationSecretsMappingFields = () => { + const { control } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AzureClientSecret; + } + >(); + + const { rotationOption } = useSecretRotationV2Option(SecretRotation.AzureClientSecret); + + const items = [ + { + name: "Client ID", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.clientId" + /> + ) + }, + { + name: "Client Secret", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.clientSecret" + /> + ) + } + ]; + + return ; +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx index 16bffe6cf..9da51272b 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx @@ -5,6 +5,7 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2Form } from "../schemas"; import { Auth0ClientSecretRotationSecretsMappingFields } from "./Auth0ClientSecretRotationSecretsMappingFields"; import { AwsIamUserSecretRotationSecretsMappingFields } from "./AwsIamUserSecretRotationSecretsMappingFields"; +import { AzureClientSecretRotationSecretsMappingFields } from "./AzureClientSecretRotationSecretsMappingFields"; import { LdapPasswordRotationSecretsMappingFields } from "./LdapPasswordRotationSecretsMappingFields"; import { SqlCredentialsRotationSecretsMappingFields } from "./shared"; @@ -12,6 +13,7 @@ const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationSecretsMappingFields, + [SecretRotation.AzureClientSecret]: AzureClientSecretRotationSecretsMappingFields, [SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationSecretsMappingFields }; diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema.ts new file mode 100644 index 000000000..3db113003 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +export const AzureClientSecretRotationSchema = z + .object({ + type: z.literal(SecretRotation.AzureClientSecret), + parameters: z.object({ + objectId: z.string().trim().min(1, "Object ID required"), + appName: z.string().trim().min(1, "App Name required"), + clientId: z.string().trim().min(1, "Client ID required") + }), + secretsMapping: z.object({ + clientId: z.string().trim().min(1, "Client ID required"), + clientSecret: z.string().trim().min(1, "Client Secret required") + }) + }) + .merge(BaseSecretRotationSchema); diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts index 6dd65b0bb..b0484ae67 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts @@ -2,14 +2,16 @@ import { z } from "zod"; import { Auth0ClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/auth0-client-secret-rotation-schema"; import { AwsIamUserSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema"; +import { AzureClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema"; import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema"; import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema"; import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema"; const SecretRotationUnionSchema = z.discriminatedUnion("type", [ + Auth0ClientSecretRotationSchema, + AzureClientSecretRotationSchema, PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, - Auth0ClientSecretRotationSchema, LdapPasswordRotationSchema, AwsIamUserSecretRotationSchema ]); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/HCVaultSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/HCVaultSyncFields.tsx new file mode 100644 index 000000000..76e14b6d7 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/HCVaultSyncFields.tsx @@ -0,0 +1,86 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Input, Tooltip } from "@app/components/v2"; +import { useHCVaultConnectionListMounts } from "@app/hooks/api/appConnections/hc-vault"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const HCVaultSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.HCVault } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: mounts, isLoading: isMountsLoading } = useHCVaultConnectionListMounts( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + return ( + <> + { + setValue("destinationConfig.mount", ""); + setValue("destinationConfig.path", ""); + }} + /> + + ( + +
+ Don't see the mount you're looking for?{" "} + +
+ + } + > + + onChange((option as SingleValue<{ value: string }>)?.value ?? null) + } + options={mounts?.map((v) => ({ label: v, value: v }))} + placeholder="Select a Secrets Engine Mount..." + /> +
+ )} + /> + ( + + + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 47afc6f04..1d7a1dd55 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -11,6 +11,7 @@ import { CamundaSyncFields } from "./CamundaSyncFields"; import { DatabricksSyncFields } from "./DatabricksSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; +import { HCVaultSyncFields } from "./HCVaultSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; import { TeamCitySyncFields } from "./TeamCitySyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; @@ -47,6 +48,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Windmill: return ; + case SecretSync.HCVault: + return ; case SecretSync.TeamCity: return ; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index e2c285b6f..e4aa4ad65 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -43,6 +43,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Camunda: case SecretSync.Vercel: case SecretSync.Windmill: + case SecretSync.HCVault: case SecretSync.TeamCity: AdditionalSyncOptionsFieldsComponent = null; break; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HCVaultSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HCVaultSyncReviewFields.tsx new file mode 100644 index 000000000..2e1abac21 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HCVaultSyncReviewFields.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const HCVaultSyncReviewFields = () => { + const { watch } = useFormContext(); + const mount = watch("destinationConfig.mount"); + const path = watch("destinationConfig.path"); + + return ( + <> + {mount} + {path} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index da9651535..62402e540 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -21,6 +21,7 @@ import { CamundaSyncReviewFields } from "./CamundaSyncReviewFields"; import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; +import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; @@ -89,6 +90,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Windmill: DestinationFieldsComponent = ; break; + case SecretSync.HCVault: + DestinationFieldsComponent = ; + break; case SecretSync.TeamCity: DestinationFieldsComponent = ; break; diff --git a/frontend/src/components/secret-syncs/forms/schemas/hc-vault-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/hc-vault-sync-destination-schema.ts new file mode 100644 index 000000000..a02778aaf --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/hc-vault-sync-destination-schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const HCVaultSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.HCVault), + destinationConfig: z.object({ + mount: z.string().trim().min(1, "Secrets Engine Mount required"), + path: z + .string() + .trim() + .min(1, "Path required") + .transform((val) => val.trim().replace(/^\/+|\/+$/g, "")) // removes leading/trailing slashes + .refine((val) => /^([a-zA-Z0-9._-]+\/)*[a-zA-Z0-9._-]+$/.test(val), { + message: + "Invalid Vault path format. Use alphanumerics, dots, dashes, underscores, and single slashes between segments." + }) + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 50221dc39..bc6184bc7 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -8,6 +8,7 @@ import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema" import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { GitHubSyncDestinationSchema } from "./github-sync-destination-schema"; +import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; import { TeamCitySyncDestinationSchema } from "./teamcity-sync-destination-schema"; import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; @@ -27,6 +28,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ CamundaSyncDestinationSchema, VercelSyncDestinationSchema, WindmillSyncDestinationSchema, + HCVaultSyncDestinationSchema, TeamCitySyncDestinationSchema ]); diff --git a/frontend/src/components/v2/Accordion/Accordion.tsx b/frontend/src/components/v2/Accordion/Accordion.tsx index 203ca3b2c..ad10314b9 100644 --- a/frontend/src/components/v2/Accordion/Accordion.tsx +++ b/frontend/src/components/v2/Accordion/Accordion.tsx @@ -47,8 +47,10 @@ AccordionTrigger.displayName = "AccordionTrigger"; export const AccordionContent = forwardRef< HTMLDivElement, - AccordionPrimitive.AccordionContentProps ->(({ children, className, ...props }, forwardedRef) => ( + AccordionPrimitive.AccordionContentProps & { + childrenClassName?: string; + } +>(({ children, className, childrenClassName, ...props }, forwardedRef) => ( -
{children}
+
{children}
)); diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index cd3c6675f..5efe7ca69 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -17,6 +17,12 @@ export const ROUTE_PATHS = Object.freeze({ PasswordSetupPage: setRoute("/password-setup", "/_authenticate/password-setup") }, Organization: { + Settings: { + OauthCallbackPage: setRoute( + "/organization/settings/oauth/callback", + "/_authenticate/_inject-org-details/_org-layout/organization/settings/oauth/callback" + ) + }, SecretScanning: setRoute( "/organization/secret-scanning", "/_authenticate/_inject-org-details/_org-layout/organization/secret-scanning" @@ -31,7 +37,7 @@ export const ROUTE_PATHS = Object.freeze({ ), SettingsPage: setRoute( "/organization/settings", - "/_authenticate/_inject-org-details/_org-layout/organization/settings" + "/_authenticate/_inject-org-details/_org-layout/organization/settings/" ), GroupDetailsByIDPage: setRoute( "/organization/groups/$groupId", @@ -298,6 +304,10 @@ export const ROUTE_PATHS = Object.freeze({ SshCaByIDPage: setRoute( "/ssh/$projectId/ca/$caId", "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId" + ), + SshHostGroupDetailsByIDPage: setRoute( + "/ssh/$projectId/ssh-host-groups/$sshHostGroupId", + "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId" ) }, Public: { diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 5aee9c483..d1a257653 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -183,6 +183,7 @@ export enum ProjectPermissionSub { SshCertificateTemplates = "ssh-certificate-templates", SshCertificates = "ssh-certificates", SshHosts = "ssh-hosts", + SshHostGroups = "ssh-host-groups", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -280,6 +281,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] + | [ProjectPermissionActions, ProjectPermissionSub.SshHostGroups] | [ProjectPermissionSshHostActions, ProjectPermissionSub.SshHosts] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 02c0fbbcb..68715f9a0 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -13,11 +13,13 @@ import { Auth0ConnectionMethod, AwsConnectionMethod, AzureAppConfigurationConnectionMethod, + AzureClientSecretsConnectionMethod, AzureKeyVaultConnectionMethod, CamundaConnectionMethod, DatabricksConnectionMethod, GcpConnectionMethod, GitHubConnectionMethod, + HCVaultConnectionMethod, HumanitecConnectionMethod, LdapConnectionMethod, MsSqlConnectionMethod, @@ -44,6 +46,10 @@ export const APP_CONNECTION_MAP: Record< name: "Azure App Configuration", image: "Microsoft Azure.png" }, + [AppConnection.AzureClientSecrets]: { + name: "Azure Client Secrets", + image: "Microsoft Azure.png" + }, [AppConnection.Databricks]: { name: "Databricks", image: "Databricks.png" }, [AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" }, [AppConnection.TerraformCloud]: { name: "Terraform Cloud", image: "Terraform Cloud.png" }, @@ -53,6 +59,7 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Camunda]: { name: "Camunda", image: "Camunda.png" }, [AppConnection.Windmill]: { name: "Windmill", image: "Windmill.png" }, [AppConnection.Auth0]: { name: "Auth0", image: "Auth0.png", size: 40 }, + [AppConnection.HCVault]: { name: "Hashicorp Vault", image: "Vault.png", size: 65 }, [AppConnection.LDAP]: { name: "LDAP", image: "LDAP.png", size: 65 }, [AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" } }; @@ -63,6 +70,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "GitHub App", icon: faGithub }; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: + case AzureClientSecretsConnectionMethod.OAuth: case GitHubConnectionMethod.OAuth: return { name: "OAuth", icon: faPassport }; case AwsConnectionMethod.AccessKey: @@ -82,11 +90,14 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: return { name: "Username & Password", icon: faLock }; + case HCVaultConnectionMethod.AccessToken: case TeamCityConnectionMethod.AccessToken: case WindmillConnectionMethod.AccessToken: return { name: "Access Token", icon: faKey }; case Auth0ConnectionMethod.ClientCredentials: return { name: "Client Credentials", icon: faServer }; + case HCVaultConnectionMethod.AppRole: + return { name: "App Role", icon: faUser }; case LdapConnectionMethod.SimpleBind: return { name: "Simple Bind", icon: faLink }; default: diff --git a/frontend/src/helpers/secretRotationsV2.ts b/frontend/src/helpers/secretRotationsV2.ts index 8acfaafe5..451658135 100644 --- a/frontend/src/helpers/secretRotationsV2.ts +++ b/frontend/src/helpers/secretRotationsV2.ts @@ -20,6 +20,11 @@ export const SECRET_ROTATION_MAP: Record< image: "Auth0.png", size: 35 }, + [SecretRotation.AzureClientSecret]: { + name: "Azure Client Secret", + image: "Microsoft Azure.png", + size: 65 + }, [SecretRotation.LdapPassword]: { name: "LDAP Password", image: "LDAP.png", @@ -36,6 +41,7 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: true, [SecretRotation.MsSqlCredentials]: true, [SecretRotation.Auth0ClientSecret]: false, + [SecretRotation.AzureClientSecret]: true, [SecretRotation.LdapPassword]: false, [SecretRotation.AwsIamUserSecret]: true }; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 009c2804b..58d9f3e48 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -40,6 +40,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Camunda]: AppConnection.Camunda, [SecretSync.Vercel]: AppConnection.Vercel, [SecretSync.Windmill]: AppConnection.Windmill, + [SecretSync.HCVault]: AppConnection.HCVault, [SecretSync.TeamCity]: AppConnection.TeamCity }; diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index d43fbc080..bc812e18e 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -4,13 +4,12 @@ export { useAdminRemoveIdentitySuperAdminAccess, useCreateAdminUser, useRemoveUserServerAdminAccess, - useUpdateAdminSlackConfig, useUpdateServerConfig, useUpdateServerEncryptionStrategy } from "./mutation"; export { useAdminGetUsers, - useGetAdminSlackConfig, + useGetAdminIntegrationsConfig, useGetServerConfig, useGetServerRootKmsEncryptionDetails } from "./queries"; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index b3e1e37b4..2c88d4fd8 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -6,11 +6,10 @@ import { organizationKeys } from "../organization/queries"; import { User } from "../users/types"; import { adminQueryKeys, adminStandaloneKeys } from "./queries"; import { - AdminSlackConfig, RootKeyEncryptionStrategy, TCreateAdminUserDTO, TServerConfig, - TUpdateAdminSlackConfigDTO + TUpdateServerConfigDTO } from "./types"; export const useCreateAdminUser = () => { @@ -34,11 +33,7 @@ export const useCreateAdminUser = () => { export const useUpdateServerConfig = () => { const queryClient = useQueryClient(); - return useMutation< - TServerConfig, - object, - Partial - >({ + return useMutation({ mutationFn: async (opt) => { const { data } = await apiRequest.patch<{ config: TServerConfig }>( "/api/v1/admin/config", @@ -48,6 +43,7 @@ export const useUpdateServerConfig = () => { }, onSuccess: (data) => { queryClient.setQueryData(adminQueryKeys.serverConfig(), data); + queryClient.invalidateQueries({ queryKey: adminQueryKeys.getAdminIntegrationsConfig() }); queryClient.invalidateQueries({ queryKey: adminQueryKeys.serverConfig() }); queryClient.invalidateQueries({ queryKey: organizationKeys.getUserOrganizations }); } @@ -119,23 +115,6 @@ export const useAdminGrantServerAdminAccess = () => { }); }; -export const useUpdateAdminSlackConfig = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (dto) => { - const { data } = await apiRequest.put( - "/api/v1/admin/integrations/slack/config", - dto - ); - - return data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: adminQueryKeys.getAdminSlackConfig() }); - } - }); -}; - export const useUpdateServerEncryptionStrategy = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index b24841dbd..1d44a93d0 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -7,7 +7,7 @@ import { User } from "../types"; import { AdminGetIdentitiesFilters, AdminGetUsersFilters, - AdminSlackConfig, + AdminIntegrationsConfig, TGetServerRootKmsEncryptionDetails, TServerConfig } from "./types"; @@ -22,7 +22,7 @@ export const adminQueryKeys = { getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const, getIdentities: (filters: AdminGetIdentitiesFilters) => [adminStandaloneKeys.getIdentities, { filters }] as const, - getAdminSlackConfig: () => ["admin-slack-config"] as const, + getAdminIntegrationsConfig: () => ["admin-integrations-config"] as const, getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const }; @@ -95,13 +95,11 @@ export const useAdminGetIdentities = (filters: AdminGetIdentitiesFilters) => { }); }; -export const useGetAdminSlackConfig = () => { +export const useGetAdminIntegrationsConfig = () => { return useQuery({ - queryKey: adminQueryKeys.getAdminSlackConfig(), + queryKey: adminQueryKeys.getAdminIntegrationsConfig(), queryFn: async () => { - const { data } = await apiRequest.get( - "/api/v1/admin/integrations/slack/config" - ); + const { data } = await apiRequest.get("/api/v1/admin/integrations"); return data; } diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 11f2cf44f..4533b5963 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -26,6 +26,14 @@ export type TServerConfig = { pageFrameContent?: string; }; +export type TUpdateServerConfigDTO = { + slackClientId?: string; + slackClientSecret?: string; + microsoftTeamsAppId?: string; + microsoftTeamsClientSecret?: string; + microsoftTeamsBotId?: string; +} & Partial; + export type TCreateAdminUserDTO = { email: string; password: string; @@ -42,11 +50,6 @@ export type TCreateAdminUserDTO = { salt: string; }; -export type TUpdateAdminSlackConfigDTO = { - clientId: string; - clientSecret: string; -}; - export type AdminGetUsersFilters = { limit: number; searchTerm: string; @@ -58,9 +61,16 @@ export type AdminGetIdentitiesFilters = { searchTerm: string; }; -export type AdminSlackConfig = { - clientId: string; - clientSecret: string; +export type AdminIntegrationsConfig = { + slack: { + clientId: string; + clientSecret: string; + }; + microsoftTeams: { + appId: string; + clientSecret: string; + botId: string; + }; }; export type TGetServerRootKmsEncryptionDetails = { diff --git a/frontend/src/hooks/api/appConnections/azure/index.ts b/frontend/src/hooks/api/appConnections/azure/index.ts new file mode 100644 index 000000000..b69c25120 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/azure/index.ts @@ -0,0 +1 @@ +export * from "./queries"; diff --git a/frontend/src/hooks/api/appConnections/azure/queries.tsx b/frontend/src/hooks/api/appConnections/azure/queries.tsx new file mode 100644 index 000000000..98d1c2d29 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/azure/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TAzureClient } from "./types"; + +const azureConnectionKeys = { + all: [...appConnectionKeys.all, "azure"] as const, + listClients: (connectionId: string) => + [...azureConnectionKeys.all, "clients", connectionId] as const +}; + +export const useAzureConnectionListClients = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TAzureClient[], + unknown, + TAzureClient[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: azureConnectionKeys.listClients(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ clients: TAzureClient[] }>( + `/api/v1/app-connections/azure-client-secrets/${connectionId}/clients` + ); + + return data.clients; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/azure/types.ts b/frontend/src/hooks/api/appConnections/azure/types.ts new file mode 100644 index 000000000..29da637f0 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/azure/types.ts @@ -0,0 +1,5 @@ +export type TAzureClient = { + name: string; + appId: string; + id: string; +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 5b9d3fad4..5e1f84cb4 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -4,6 +4,7 @@ export enum AppConnection { GCP = "gcp", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", + AzureClientSecrets = "azure-client-secrets", Databricks = "databricks", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", @@ -13,6 +14,7 @@ export enum AppConnection { Camunda = "camunda", Windmill = "windmill", Auth0 = "auth0", + HCVault = "hashicorp-vault", LDAP = "ldap", TeamCity = "teamcity" } diff --git a/frontend/src/hooks/api/appConnections/hc-vault/index.ts b/frontend/src/hooks/api/appConnections/hc-vault/index.ts new file mode 100644 index 000000000..b69c25120 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/hc-vault/index.ts @@ -0,0 +1 @@ +export * from "./queries"; diff --git a/frontend/src/hooks/api/appConnections/hc-vault/queries.tsx b/frontend/src/hooks/api/appConnections/hc-vault/queries.tsx new file mode 100644 index 000000000..6f1b6ae46 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/hc-vault/queries.tsx @@ -0,0 +1,36 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; + +const hcVaultConnectionKeys = { + all: [...appConnectionKeys.all, "hcvault"] as const, + listMounts: (connectionId: string) => + [...hcVaultConnectionKeys.all, "mounts", connectionId] as const +}; + +export const useHCVaultConnectionListMounts = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + string[], + unknown, + string[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: hcVaultConnectionKeys.listMounts(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/hashicorp-vault/${connectionId}/mounts` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 10c1076cd..910716c02 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -31,6 +31,11 @@ export type TAzureAppConfigurationConnectionOption = TAppConnectionOptionBase & oauthClientId?: string; }; +export type TAzureClientSecretsConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.AzureClientSecrets; + oauthClientId?: string; +}; + export type TDatabricksConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Databricks; }; @@ -67,6 +72,10 @@ export type TAuth0ConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Auth0; }; +export type THCVaultConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.HCVault; +}; + export type TLdapConnectionOption = TAppConnectionOptionBase & { app: AppConnection.LDAP; }; @@ -81,6 +90,7 @@ export type TAppConnectionOption = | TGcpConnectionOption | TAzureAppConfigurationConnectionOption | TAzureKeyVaultConnectionOption + | TAzureClientSecretsConnectionOption | TDatabricksConnectionOption | THumanitecConnectionOption | TTerraformCloudConnectionOption @@ -90,6 +100,7 @@ export type TAppConnectionOption = | TCamundaConnectionOption | TWindmillConnectionOption | TAuth0ConnectionOption + | THCVaultConnectionOption | TTeamCityConnectionOption; export type TAppConnectionOptionMap = { @@ -98,6 +109,7 @@ export type TAppConnectionOptionMap = { [AppConnection.GCP]: TGcpConnectionOption; [AppConnection.AzureKeyVault]: TAzureKeyVaultConnectionOption; [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption; + [AppConnection.AzureClientSecrets]: TAzureClientSecretsConnectionOption; [AppConnection.Databricks]: TDatabricksConnectionOption; [AppConnection.Humanitec]: THumanitecConnectionOption; [AppConnection.TerraformCloud]: TTerraformCloudConnectionOption; @@ -107,6 +119,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Camunda]: TCamundaConnectionOption; [AppConnection.Windmill]: TWindmillConnectionOption; [AppConnection.Auth0]: TAuth0ConnectionOption; + [AppConnection.HCVault]: THCVaultConnectionOption; [AppConnection.LDAP]: TLdapConnectionOption; [AppConnection.TeamCity]: TTeamCityConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/azure-client-secrets-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-client-secrets-connection.ts new file mode 100644 index 000000000..04ad167d2 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/azure-client-secrets-connection.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum AzureClientSecretsConnectionMethod { + OAuth = "oauth" +} + +export type TAzureClientSecretsConnection = TRootAppConnection & { + app: AppConnection.AzureClientSecrets; +} & { + method: AzureClientSecretsConnectionMethod.OAuth; + credentials: { + code: string; + tenantId: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/hc-vault-connection.ts b/frontend/src/hooks/api/appConnections/types/hc-vault-connection.ts new file mode 100644 index 000000000..f3cddfd96 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/hc-vault-connection.ts @@ -0,0 +1,27 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum HCVaultConnectionMethod { + AccessToken = "access-token", + AppRole = "app-role" +} + +export type THCVaultConnection = TRootAppConnection & { app: AppConnection.HCVault } & ( + | { + method: HCVaultConnectionMethod.AccessToken; + credentials: { + instanceUrl: string; + namespace?: string; + accessToken: string; + }; + } + | { + method: HCVaultConnectionMethod.AppRole; + credentials: { + instanceUrl: string; + namespace?: string; + roleId: string; + secretId: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 2eabee1f2..00c0c3f3a 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -3,11 +3,13 @@ import { TAppConnectionOption } from "./app-options"; import { TAuth0Connection } from "./auth0-connection"; import { TAwsConnection } from "./aws-connection"; import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection"; +import { TAzureClientSecretsConnection } from "./azure-client-secrets-connection"; import { TAzureKeyVaultConnection } from "./azure-key-vault-connection"; import { TCamundaConnection } from "./camunda-connection"; import { TDatabricksConnection } from "./databricks-connection"; import { TGcpConnection } from "./gcp-connection"; import { TGitHubConnection } from "./github-connection"; +import { THCVaultConnection } from "./hc-vault-connection"; import { THumanitecConnection } from "./humanitec-connection"; import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; @@ -20,11 +22,13 @@ import { TWindmillConnection } from "./windmill-connection"; export * from "./auth0-connection"; export * from "./aws-connection"; export * from "./azure-app-configuration-connection"; +export * from "./azure-client-secrets-connection"; export * from "./azure-key-vault-connection"; export * from "./camunda-connection"; export * from "./databricks-connection"; export * from "./gcp-connection"; export * from "./github-connection"; +export * from "./hc-vault-connection"; export * from "./humanitec-connection"; export * from "./ldap-connection"; export * from "./mssql-connection"; @@ -40,6 +44,7 @@ export type TAppConnection = | TGcpConnection | TAzureKeyVaultConnection | TAzureAppConfigurationConnection + | TAzureClientSecretsConnection | TDatabricksConnection | THumanitecConnection | TTerraformCloudConnection @@ -49,6 +54,7 @@ export type TAppConnection = | TCamundaConnection | TWindmillConnection | TAuth0Connection + | THCVaultConnection | TLdapConnection | TTeamCityConnection; @@ -83,6 +89,7 @@ export type TAppConnectionMap = { [AppConnection.GCP]: TGcpConnection; [AppConnection.AzureKeyVault]: TAzureKeyVaultConnection; [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; + [AppConnection.AzureClientSecrets]: TAzureClientSecretsConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; [AppConnection.TerraformCloud]: TTerraformCloudConnection; @@ -92,6 +99,7 @@ export type TAppConnectionMap = { [AppConnection.Camunda]: TCamundaConnection; [AppConnection.Windmill]: TWindmillConnection; [AppConnection.Auth0]: TAuth0Connection; + [AppConnection.HCVault]: THCVaultConnection; [AppConnection.LDAP]: TLdapConnection; [AppConnection.TeamCity]: TTeamCityConnection; }; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 0c1c986a0..36daed4b7 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -96,8 +96,10 @@ export const eventToNameMap: { [K in EventType]: string } = { "Create certificate template EST configuration", [EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG]: "Update certificate template EST configuration", - [EventType.UPDATE_PROJECT_SLACK_CONFIG]: "Update project slack configuration", - [EventType.GET_PROJECT_SLACK_CONFIG]: "Get project slack configuration", + [EventType.UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG]: + "Update project workflow integration configuration", + [EventType.GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG]: + "Get project workflow integration configuration", [EventType.INTEGRATION_SYNCED]: "Integration sync", [EventType.CREATE_SHARED_SECRET]: "Create shared secret", [EventType.DELETE_SHARED_SECRET]: "Delete shared secret", @@ -165,7 +167,22 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.CREATE_SECRET_ROTATION]: "Create Secret Rotation", [EventType.UPDATE_SECRET_ROTATION]: "Update Secret Rotation", [EventType.DELETE_SECRET_ROTATION]: "Delete Secret Rotation", - [EventType.SECRET_ROTATION_ROTATE_SECRETS]: "Secret Rotation secrets rotated" + [EventType.SECRET_ROTATION_ROTATE_SECRETS]: "Secret Rotation secrets rotated", + + [EventType.GET_PROJECT_SLACK_CONFIG]: "Get Project Slack Config", + [EventType.UPDATE_PROJECT_SLACK_CONFIG]: "Update Project Slack Config", + + [EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CREATE]: + "Create Microsoft Teams Workflow Integration", + [EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_DELETE]: + "Delete Microsoft Teams Workflow Integration", + [EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_UPDATE]: + "Update Microsoft Teams Workflow Integration", + [EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CHECK_INSTALLATION_STATUS]: + "Microsoft Teams Workflow Integration Check Installation Status", + [EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET_TEAMS]: "Get Microsoft Teams tenant teams", + [EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET]: "Get Microsoft Teams Workflow Integration", + [EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST]: "List Microsoft Teams Workflow Integration" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 74c159aed..ac57def4f 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -100,8 +100,8 @@ export enum EventType { CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config", UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", - UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", - GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", + UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG = "update-project-workflow-integration-config", + GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG = "get-project-workflow-integration-config", INTEGRATION_SYNCED = "integration-synced", CREATE_SHARED_SECRET = "create-shared-secret", DELETE_SHARED_SECRET = "delete-shared-secret", @@ -165,5 +165,16 @@ export enum EventType { CREATE_SECRET_ROTATION = "create-secret-rotation", UPDATE_SECRET_ROTATION = "update-secret-rotation", DELETE_SECRET_ROTATION = "delete-secret-rotation", - SECRET_ROTATION_ROTATE_SECRETS = "secret-rotation-rotate-secrets" + SECRET_ROTATION_ROTATE_SECRETS = "secret-rotation-rotate-secrets", + + GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", + UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", + + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CREATE = "microsoft-teams-workflow-integration-create", + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_DELETE = "microsoft-teams-workflow-integration-delete", + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_UPDATE = "microsoft-teams-workflow-integration-update", + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CHECK_INSTALLATION_STATUS = "microsoft-teams-workflow-integration-check-installation-status", + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET_TEAMS = "microsoft-teams-workflow-integration-get-teams", + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET = "microsoft-teams-workflow-integration-get", + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 04cd60236..745d0368f 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -1,6 +1,7 @@ import { CaStatus } from "../ca"; import { IdentityTrustedIp } from "../identities/types"; import { PkiItemType } from "../pkiCollections/constants"; +import { WorkflowIntegration } from "../workflowIntegrations/types"; import { ActorType, EventType, UserAgentType } from "./enums"; export type TGetAuditLogsFilter = { @@ -804,11 +805,12 @@ interface GetCertificateTemplateEstConfig { }; } -interface UpdateProjectSlackConfig { - type: EventType.UPDATE_PROJECT_SLACK_CONFIG; +interface UpdateProjectWorkflowIntegrationConfig { + type: EventType.UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG; metadata: { id: string; - slackIntegrationId: string; + integrationId: string; + integration: WorkflowIntegration; isAccessRequestNotificationEnabled: boolean; accessRequestChannels: string; isSecretRequestNotificationEnabled: boolean; @@ -816,10 +818,11 @@ interface UpdateProjectSlackConfig { }; } -interface GetProjectSlackConfig { - type: EventType.GET_PROJECT_SLACK_CONFIG; +interface GetProjectWorkflowIntegrationConfig { + type: EventType.GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG; metadata: { id: string; + integration: WorkflowIntegration; }; } @@ -918,8 +921,8 @@ export type Event = | UpdateCertificateTemplateEstConfig | CreateCertificateTemplateEstConfig | GetCertificateTemplateEstConfig - | UpdateProjectSlackConfig - | GetProjectSlackConfig + | UpdateProjectWorkflowIntegrationConfig + | GetProjectWorkflowIntegrationConfig | IntegrationSyncedEvent; export type AuditLog = { diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index 32610c28d..cbd20b643 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -107,6 +107,7 @@ export type CompleteAccountSignupDTO = CompleteAccountDTO & { providerAuthToken?: string; attributionSource?: string; organizationName: string; + useDefaultOrg?: boolean; }; export type VerifySignupInviteDTO = { diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 2a80c6174..4bc06f7e3 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -44,6 +44,7 @@ export * from "./serviceTokens"; export * from "./sshCa"; export * from "./sshCertificateTemplates"; export * from "./sshHost"; +export * from "./sshHostGroup"; export * from "./ssoConfig"; export * from "./subscriptions"; export * from "./tags"; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 902bb6b09..06125b1d7 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -111,7 +111,8 @@ export const useUpdateOrg = () => { enforceMfa, selectedMfaMethod, allowSecretSharingOutsideOrganization, - bypassOrgAuthEnabled + bypassOrgAuthEnabled, + userTokenExpiration }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, @@ -122,7 +123,8 @@ export const useUpdateOrg = () => { enforceMfa, selectedMfaMethod, allowSecretSharingOutsideOrganization, - bypassOrgAuthEnabled + bypassOrgAuthEnabled, + userTokenExpiration }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index e0687922d..6f63d003e 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -18,6 +18,7 @@ export type Organization = { selectedMfaMethod?: MfaMethod; shouldUseNewPrivilegeSystem: boolean; allowSecretSharingOutsideOrganization?: boolean; + userTokenExpiration?: string; userRole: string; }; @@ -32,6 +33,7 @@ export type UpdateOrgDTO = { selectedMfaMethod?: MfaMethod; allowSecretSharingOutsideOrganization?: boolean; bypassOrgAuthEnabled?: boolean; + userTokenExpiration?: string; }; export type BillingDetails = { diff --git a/frontend/src/hooks/api/secretRotationsV2/enums.ts b/frontend/src/hooks/api/secretRotationsV2/enums.ts index 4ddf4ee0c..3b38c1d49 100644 --- a/frontend/src/hooks/api/secretRotationsV2/enums.ts +++ b/frontend/src/hooks/api/secretRotationsV2/enums.ts @@ -2,6 +2,7 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", Auth0ClientSecret = "auth0-client-secret", + AzureClientSecret = "azure-client-secret", LdapPassword = "ldap-password", AwsIamUserSecret = "aws-iam-user-secret" } diff --git a/frontend/src/hooks/api/secretRotationsV2/types/azure-client-secret-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/azure-client-secret-rotation.ts new file mode 100644 index 000000000..d4e06d45b --- /dev/null +++ b/frontend/src/hooks/api/secretRotationsV2/types/azure-client-secret-rotation.ts @@ -0,0 +1,38 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { + TSecretRotationV2Base, + TSecretRotationV2GeneratedCredentialsResponseBase +} from "@app/hooks/api/secretRotationsV2/types/shared"; + +export type TAzureClientSecretRotation = TSecretRotationV2Base & { + type: SecretRotation.AzureClientSecret; + parameters: { + objectId: string; + appName: string; + }; + secretsMapping: { + clientId: string; + clientSecret: string; + }; +}; + +export type TAzureClientSecretRotationGeneratedCredentials = { + clientId: string; + clientSecret: string; +}; + +export type TAzureClientSecretRotationGeneratedCredentialsResponse = + TSecretRotationV2GeneratedCredentialsResponseBase< + SecretRotation.AzureClientSecret, + TAzureClientSecretRotationGeneratedCredentials + >; + +export type TAzureClientSecretRotationOption = { + name: string; + type: SecretRotation.AzureClientSecret; + connection: AppConnection.AzureClientSecrets; + template: { + secretsMapping: TAzureClientSecretRotation["secretsMapping"]; + }; +}; diff --git a/frontend/src/hooks/api/secretRotationsV2/types/index.ts b/frontend/src/hooks/api/secretRotationsV2/types/index.ts index 4119efc15..a1ef0bd15 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/index.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/index.ts @@ -9,6 +9,11 @@ import { TAwsIamUserSecretRotationGeneratedCredentialsResponse, TAwsIamUserSecretRotationOption } from "@app/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation"; +import { + TAzureClientSecretRotation, + TAzureClientSecretRotationGeneratedCredentialsResponse, + TAzureClientSecretRotationOption +} from "@app/hooks/api/secretRotationsV2/types/azure-client-secret-rotation"; import { TLdapPasswordRotation, TLdapPasswordRotationGeneratedCredentialsResponse, @@ -30,6 +35,7 @@ export type TSecretRotationV2 = ( | TPostgresCredentialsRotation | TMsSqlCredentialsRotation | TAuth0ClientSecretRotation + | TAzureClientSecretRotation | TLdapPasswordRotation | TAwsIamUserSecretRotation ) & { @@ -39,6 +45,7 @@ export type TSecretRotationV2 = ( export type TSecretRotationV2Option = | TSqlCredentialsRotationOption | TAuth0ClientSecretRotationOption + | TAzureClientSecretRotationOption | TLdapPasswordRotationOption | TAwsIamUserSecretRotationOption; @@ -50,6 +57,7 @@ export type TViewSecretRotationGeneratedCredentialsResponse = | TPostgresCredentialsRotationGeneratedCredentialsResponse | TMsSqlCredentialsRotationGeneratedCredentialsResponse | TAuth0ClientSecretRotationGeneratedCredentialsResponse + | TAzureClientSecretRotationGeneratedCredentialsResponse | TLdapPasswordRotationGeneratedCredentialsResponse | TAwsIamUserSecretRotationGeneratedCredentialsResponse; @@ -98,6 +106,7 @@ export type TSecretRotationOptionMap = { [SecretRotation.PostgresCredentials]: TSqlCredentialsRotationOption; [SecretRotation.MsSqlCredentials]: TSqlCredentialsRotationOption; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationOption; + [SecretRotation.AzureClientSecret]: TAzureClientSecretRotationOption; [SecretRotation.LdapPassword]: TLdapPasswordRotationOption; [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationOption; }; @@ -106,6 +115,7 @@ export type TSecretRotationGeneratedCredentialsResponseMap = { [SecretRotation.PostgresCredentials]: TPostgresCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.MsSqlCredentials]: TMsSqlCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationGeneratedCredentialsResponse; + [SecretRotation.AzureClientSecret]: TAzureClientSecretRotationGeneratedCredentialsResponse; [SecretRotation.LdapPassword]: TLdapPasswordRotationGeneratedCredentialsResponse; [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationGeneratedCredentialsResponse; }; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 450df773a..d078765bc 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -11,6 +11,7 @@ export enum SecretSync { Camunda = "camunda", Vercel = "vercel", Windmill = "windmill", + HCVault = "hashicorp-vault", TeamCity = "teamcity" } diff --git a/frontend/src/hooks/api/secretSyncs/types/hc-vault-sync.ts b/frontend/src/hooks/api/secretSyncs/types/hc-vault-sync.ts new file mode 100644 index 000000000..388698ef8 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/hc-vault-sync.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type THCVaultSync = TRootSecretSync & { + destination: SecretSync.HCVault; + destinationConfig: { + mount: string; + path: string; + }; + connection: { + app: AppConnection.HCVault; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index e9ac538fe..2dba65649 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -9,6 +9,7 @@ import { TCamundaSync } from "./camunda-sync"; import { TDatabricksSync } from "./databricks-sync"; import { TGcpSync } from "./gcp-sync"; import { TGitHubSync } from "./github-sync"; +import { THCVaultSync } from "./hc-vault-sync"; import { THumanitecSync } from "./humanitec-sync"; import { TTeamCitySync } from "./teamcity-sync"; import { TTerraformCloudSync } from "./terraform-cloud-sync"; @@ -34,6 +35,7 @@ export type TSecretSync = | TCamundaSync | TVercelSync | TWindmillSync + | THCVaultSync | TTeamCitySync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/sshHost/types.ts b/frontend/src/hooks/api/sshHost/types.ts index ebeb5130a..e92ddeaa8 100644 --- a/frontend/src/hooks/api/sshHost/types.ts +++ b/frontend/src/hooks/api/sshHost/types.ts @@ -1,3 +1,16 @@ +export enum LoginMappingSource { + HOST = "host", + HOST_GROUP = "hostGroup" +} + +export type TLoginMapping = { + loginUser: string; + allowedPrincipals: { + usernames: string[]; + }; + source: LoginMappingSource; +}; + export type TSshHost = { id: string; projectId: string; @@ -5,26 +18,15 @@ export type TSshHost = { alias: string | null; userCertTtl: string; hostCertTtl: string; - loginMappings: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings: TLoginMapping[]; }; - export type TCreateSshHostDTO = { projectId: string; hostname: string; alias?: string; userCertTtl?: string; hostCertTtl?: string; - loginMappings: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings: Omit[]; }; export type TUpdateSshHostDTO = { @@ -33,12 +35,7 @@ export type TUpdateSshHostDTO = { alias?: string; userCertTtl?: string; hostCertTtl?: string; - loginMappings?: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings?: Omit[]; }; export type TDeleteSshHostDTO = { diff --git a/frontend/src/hooks/api/sshHostGroup/index.tsx b/frontend/src/hooks/api/sshHostGroup/index.tsx new file mode 100644 index 000000000..b131e1611 --- /dev/null +++ b/frontend/src/hooks/api/sshHostGroup/index.tsx @@ -0,0 +1,8 @@ +export { + useAddHostToSshHostGroup, + useCreateSshHostGroup, + useDeleteSshHostGroup, + useRemoveHostFromSshHostGroup, + useUpdateSshHostGroup +} from "./mutations"; +export { useGetSshHostGroupById, useListSshHostGroupHosts } from "./queries"; diff --git a/frontend/src/hooks/api/sshHostGroup/mutations.tsx b/frontend/src/hooks/api/sshHostGroup/mutations.tsx new file mode 100644 index 000000000..b75cff187 --- /dev/null +++ b/frontend/src/hooks/api/sshHostGroup/mutations.tsx @@ -0,0 +1,105 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/query-keys"; +import { sshHostGroupKeys } from "./queries"; +import { + TCreateSshHostGroupDTO, + TDeleteSshHostGroupDTO, + TSshHostGroup, + TUpdateSshHostGroupDTO +} from "./types"; + +export const useCreateSshHostGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data: hostGroup } = await apiRequest.post("/api/v1/ssh/host-groups", body); + return hostGroup; + }, + onSuccess: ({ projectId, id }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) + }); + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.getSshHostGroupById(id) + }); + } + }); +}; + +export const useUpdateSshHostGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sshHostGroupId, ...body }) => { + const { data: hostGroup } = await apiRequest.patch( + `/api/v1/ssh/host-groups/${sshHostGroupId}`, + body + ); + return hostGroup; + }, + onSuccess: ({ projectId }, { sshHostGroupId }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) + }); + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHosts(projectId) + }); + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId) + }); + } + }); +}; + +export const useDeleteSshHostGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sshHostGroupId }) => { + const { data: hostGroup } = await apiRequest.delete( + `/api/v1/ssh/host-groups/${sshHostGroupId}` + ); + return hostGroup; + }, + onSuccess: ({ projectId }, { sshHostGroupId }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) + }); + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceSshHosts(projectId) + }); + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId) + }); + } + }); +}; + +export const useAddHostToSshHostGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sshHostGroupId, sshHostId }) => { + await apiRequest.post(`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts/${sshHostId}`); + }, + onSuccess: (_, { sshHostGroupId }) => { + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId) + }); + } + }); +}; + +export const useRemoveHostFromSshHostGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sshHostGroupId, sshHostId }) => { + await apiRequest.delete(`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts/${sshHostId}`); + }, + onSuccess: (_, { sshHostGroupId }) => { + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/sshHostGroup/queries.tsx b/frontend/src/hooks/api/sshHostGroup/queries.tsx new file mode 100644 index 000000000..8be42dd7b --- /dev/null +++ b/frontend/src/hooks/api/sshHostGroup/queries.tsx @@ -0,0 +1,60 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { EHostGroupMembershipFilter, TListSshHostGroupHostsResponse, TSshHostGroup } from "./types"; + +export const sshHostGroupKeys = { + getSshHostGroupById: (sshHostGroupId: string) => [{ sshHostGroupId }, "ssh-host-group"], + allSshHostGroupHosts: () => ["ssh-host-group-hosts"] as const, + forSshHostGroupHosts: (sshHostGroupId: string) => + [...sshHostGroupKeys.allSshHostGroupHosts(), sshHostGroupId] as const, + specificSshHostGroupHosts: ({ + sshHostGroupId, + filter + }: { + sshHostGroupId: string; + filter?: EHostGroupMembershipFilter; + }) => [...sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId), { filter }] as const +}; + +export const useGetSshHostGroupById = (sshHostGroupId: string) => { + return useQuery({ + queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId), + queryFn: async () => { + const { data: sshHostGroup } = await apiRequest.get( + `/api/v1/ssh/host-groups/${sshHostGroupId}` + ); + return sshHostGroup; + }, + enabled: Boolean(sshHostGroupId) + }); +}; + +export const useListSshHostGroupHosts = ({ + sshHostGroupId, + filter +}: { + sshHostGroupId: string; + filter?: EHostGroupMembershipFilter; +}) => { + return useQuery({ + queryKey: sshHostGroupKeys.specificSshHostGroupHosts({ sshHostGroupId, filter }), + queryFn: async () => { + const params = new URLSearchParams({ + ...(filter ? { filter } : {}) + }); + + const { data } = await apiRequest.get( + `/api/v1/ssh/host-groups/${sshHostGroupId}/hosts`, + { + params + } + ); + return data; + }, + enabled: Boolean(sshHostGroupId), + staleTime: 0, + gcTime: 0 + }); +}; diff --git a/frontend/src/hooks/api/sshHostGroup/types.ts b/frontend/src/hooks/api/sshHostGroup/types.ts new file mode 100644 index 000000000..6e193c8b6 --- /dev/null +++ b/frontend/src/hooks/api/sshHostGroup/types.ts @@ -0,0 +1,34 @@ +import { TLoginMapping, TSshHost } from "../sshHost/types"; + +export type TSshHostGroup = { + id: string; + projectId: string; + name: string; + loginMappings: Omit[]; +}; + +export type TCreateSshHostGroupDTO = { + projectId: string; + name: string; + loginMappings: Omit[]; +}; + +export type TUpdateSshHostGroupDTO = { + sshHostGroupId: string; + name?: string; + loginMappings?: Omit[]; +}; + +export type TDeleteSshHostGroupDTO = { + sshHostGroupId: string; +}; + +export type TListSshHostGroupHostsResponse = { + hosts: (TSshHost & { joinedGroupAt: string; isPartOfGroup: boolean })[]; + totalCount: number; +}; + +export enum EHostGroupMembershipFilter { + GROUP_MEMBERS = "group-members", + NON_GROUP_MEMBERS = "non-group-members" +} diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index ab277ddc8..ec7b6a2dd 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -24,6 +24,7 @@ export type SubscriptionPlan = { workspacesUsed: number; environmentLimit: number; samlSSO: boolean; + sshHostGroups: boolean; secretAccessInsights: boolean; hsm: boolean; oidcSSO: boolean; diff --git a/frontend/src/hooks/api/workflowIntegrations/index.ts b/frontend/src/hooks/api/workflowIntegrations/index.ts index e4730bd7d..f4e57d7cc 100644 --- a/frontend/src/hooks/api/workflowIntegrations/index.ts +++ b/frontend/src/hooks/api/workflowIntegrations/index.ts @@ -1,13 +1,2 @@ -export { - useDeleteSlackIntegration, - useUpdateProjectSlackConfig, - useUpdateSlackIntegration -} from "./mutation"; -export { - fetchSlackInstallUrl, - fetchSlackReinstallUrl, - useGetSlackIntegrationById, - useGetSlackIntegrationChannels, - useGetSlackIntegrations, - useGetWorkflowIntegrations -} from "./queries"; +export * from "./mutation"; +export * from "./queries"; diff --git a/frontend/src/hooks/api/workflowIntegrations/mutation.tsx b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx index e87b0dd6b..89c65f888 100644 --- a/frontend/src/hooks/api/workflowIntegrations/mutation.tsx +++ b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx @@ -5,8 +5,13 @@ import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace/query-keys"; import { workflowIntegrationKeys } from "./queries"; import { + TCheckMicrosoftTeamsIntegrationInstallationStatusDTO, + TCreateMicrosoftTeamsIntegrationDTO, + TDeleteMicrosoftTeamsIntegrationDTO, + TDeleteProjectWorkflowIntegrationDTO, TDeleteSlackIntegrationDTO, - TUpdateProjectSlackConfigDTO, + TUpdateMicrosoftTeamsIntegrationDTO, + TUpdateProjectWorkflowIntegrationConfigDTO, TUpdateSlackIntegrationDTO } from "./types"; @@ -28,6 +33,47 @@ export const useUpdateSlackIntegration = () => { }); }; +export const useUpdateMicrosoftTeamsIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch( + `/api/v1/workflow-integrations/microsoft-teams/${dto.id}`, + dto + ); + + return data; + }, + onSuccess: (_, { orgId, id }) => { + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegration(id) + }); + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId) + }); + queryClient.invalidateQueries({ queryKey: workflowIntegrationKeys.getIntegrations(orgId) }); + } + }); +}; +export const useCreateMicrosoftTeamsIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post("/api/v1/workflow-integrations/microsoft-teams", dto); + + return data; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId) + }); + queryClient.invalidateQueries({ queryKey: workflowIntegrationKeys.getIntegrations(orgId) }); + } + }); +}; + export const useDeleteSlackIntegration = () => { const queryClient = useQueryClient(); @@ -44,21 +90,86 @@ export const useDeleteSlackIntegration = () => { }); }; -export const useUpdateProjectSlackConfig = () => { +export const useDeleteMicrosoftTeamsIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete( + `/api/v1/workflow-integrations/microsoft-teams/${dto.id}` + ); + + return data; + }, + onSuccess: (_, { orgId, id }) => { + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegration(id) + }); + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId) + }); + queryClient.invalidateQueries({ queryKey: workflowIntegrationKeys.getIntegrations(orgId) }); + } + }); +}; + +export const useUpdateProjectWorkflowIntegrationConfig = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (dto: TUpdateProjectSlackConfigDTO) => { + mutationFn: async (dto: TUpdateProjectWorkflowIntegrationConfigDTO) => { const { data } = await apiRequest.put( - `/api/v1/workspace/${dto.workspaceId}/slack-config`, + `/api/v1/workspace/${dto.workspaceId}/workflow-integration`, dto ); return data; }, - onSuccess: (_, { workspaceId }) => { + onSuccess: (_, { workspaceId, integration }) => { queryClient.invalidateQueries({ - queryKey: workspaceKeys.getWorkspaceSlackConfig(workspaceId) + queryKey: workspaceKeys.getWorkspaceWorkflowIntegrationConfig(workspaceId, integration) }); } }); }; + +export const useDeleteProjectWorkflowIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: TDeleteProjectWorkflowIntegrationDTO) => { + const { data } = await apiRequest.delete( + `/api/v1/workspace/${dto.projectId}/workflow-integration/${dto.integration}/${dto.integrationId}` + ); + + return data; + }, + onSuccess: (_, { projectId, integration }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceWorkflowIntegrationConfig(projectId, integration) + }); + } + }); +}; + +export const useCheckMicrosoftTeamsIntegrationInstallationStatus = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: TCheckMicrosoftTeamsIntegrationInstallationStatusDTO) => { + const { data } = await apiRequest.post( + `/api/v1/workflow-integrations/microsoft-teams/${dto.workflowIntegrationId}/installation-status` + ); + + return data; + }, + onSuccess: (_, { workflowIntegrationId, orgId }) => { + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegration(workflowIntegrationId) + }); + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId) + }); + queryClient.invalidateQueries({ queryKey: workflowIntegrationKeys.getIntegrations(orgId) }); + } + }); +}; diff --git a/frontend/src/hooks/api/workflowIntegrations/queries.tsx b/frontend/src/hooks/api/workflowIntegrations/queries.tsx index bcf092c73..999215f1f 100644 --- a/frontend/src/hooks/api/workflowIntegrations/queries.tsx +++ b/frontend/src/hooks/api/workflowIntegrations/queries.tsx @@ -2,13 +2,29 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { SlackIntegration, SlackIntegrationChannel, WorkflowIntegration } from "./types"; +import { + MicrosoftTeamsIntegration, + MicrosoftTeamsIntegrationTeam, + SlackIntegration, + SlackIntegrationChannel, + WorkflowIntegration +} from "./types"; export const workflowIntegrationKeys = { getIntegrations: (orgId?: string) => [{ orgId }, "workflow-integrations"], getSlackIntegrations: (orgId?: string) => [{ orgId }, "slack-workflow-integrations"], getSlackIntegration: (id?: string) => [{ id }, "slack-workflow-integration"], - getSlackIntegrationChannels: (id?: string) => [{ id }, "slack-workflow-integration-channels"] + getMicrosoftTeamsIntegrations: (orgId?: string) => [ + { orgId }, + "microsoft-teams-workflow-integrations" + ], + getMicrosoftTeamsIntegration: (id?: string) => [{ id }, "microsoft-teams-workflow-integration"], + getMicrosoftTeamsIntegrationTeams: (id?: string) => [ + { id }, + "microsoft-teams-workflow-integration-teams" + ], + getSlackIntegrationChannels: (id?: string) => [{ id }, "slack-workflow-integration-channels"], + getMicrosoftTeamsClientId: () => ["microsoft-teams-client-id"] }; export const fetchSlackInstallUrl = async ({ @@ -66,13 +82,58 @@ export const fetchWorkflowIntegrations = async () => { return data; }; -export const useGetSlackIntegrations = (orgId?: string) => +export const fetchMicrosoftTeamsIntegrations = async () => { + const { data } = await apiRequest.get( + "/api/v1/workflow-integrations/microsoft-teams" + ); + + return data; +}; + +export const fetchMicrosoftTeamsIntegrationById = async (id?: string) => { + const { data } = await apiRequest.get( + `/api/v1/workflow-integrations/microsoft-teams/${id}` + ); + + return data; +}; +export const fetchMicrosoftTeamsIntegrationTeams = async (id?: string) => { + const { data } = await apiRequest.get( + `/api/v1/workflow-integrations/microsoft-teams/${id}/teams` + ); + + return data; +}; + +export const fetchMicrosoftTeamsClientId = async () => { + const { data } = await apiRequest.get<{ clientId: string }>( + "/api/v1/workflow-integrations/microsoft-teams/client-id" + ); + + return data; +}; + +export const useGetMicrosoftTeamsIntegrations = (orgId?: string) => useQuery({ - queryKey: workflowIntegrationKeys.getSlackIntegrations(orgId), - queryFn: () => fetchSlackIntegrations(), + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId), + queryFn: () => fetchMicrosoftTeamsIntegrations(), enabled: Boolean(orgId) }); +export const useGetMicrosoftTeamsIntegrationById = (id?: string) => + useQuery({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegration(id), + queryFn: () => fetchMicrosoftTeamsIntegrationById(id), + enabled: Boolean(id) + }); + +export const useGetMicrosoftTeamsIntegrationTeams = (id?: string) => + useQuery({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrationTeams(id), + queryFn: () => fetchMicrosoftTeamsIntegrationTeams(id), + enabled: Boolean(id) + }); + export const useGetSlackIntegrationById = (id?: string) => useQuery({ queryKey: workflowIntegrationKeys.getSlackIntegration(id), @@ -93,3 +154,10 @@ export const useGetWorkflowIntegrations = (id?: string) => queryFn: () => fetchWorkflowIntegrations(), enabled: Boolean(id) }); + +export const useGetMicrosoftTeamsClientId = () => + useQuery({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsClientId(), + queryFn: () => fetchMicrosoftTeamsClientId(), + enabled: true + }); diff --git a/frontend/src/hooks/api/workflowIntegrations/types.ts b/frontend/src/hooks/api/workflowIntegrations/types.ts index e30193776..4d2baf4bf 100644 --- a/frontend/src/hooks/api/workflowIntegrations/types.ts +++ b/frontend/src/hooks/api/workflowIntegrations/types.ts @@ -1,14 +1,25 @@ export enum WorkflowIntegrationPlatform { - SLACK = "slack" + SLACK = "slack", + MICROSOFT_TEAMS = "microsoft-teams" } export type WorkflowIntegration = { id: string; slug: string; description: string; + status: WorkflowIntegrationStatus; integration: WorkflowIntegrationPlatform; }; +export type MicrosoftTeamsIntegrationTeam = { + teamId: string; + teamName: string; + channels: { + channelId: string; + channelName: string; + }[]; +}; + export type SlackIntegration = { id: string; slug: string; @@ -16,6 +27,18 @@ export type SlackIntegration = { teamName: string; }; +export enum WorkflowIntegrationStatus { + Pending = "pending", + Installed = "installed" +} + +export type MicrosoftTeamsIntegration = { + id: string; + slug: string; + description: string; + tenantId: string; +}; + export type SlackIntegrationChannel = { id: string; name: string; @@ -28,25 +51,90 @@ export type TUpdateSlackIntegrationDTO = { description?: string; }; +export type TUpdateMicrosoftTeamsIntegrationDTO = { + id: string; + orgId: string; + slug?: string; + description?: string; +}; + +export type TCreateMicrosoftTeamsIntegrationDTO = { + code: string; + tenantId: string; + slug: string; + description?: string; + redirectUri: string; + orgId: string; +}; + export type TDeleteSlackIntegrationDTO = { id: string; orgId: string; }; -export type ProjectSlackConfig = { +export type TDeleteMicrosoftTeamsIntegrationDTO = { id: string; - slackIntegrationId: string; - isAccessRequestNotificationEnabled: boolean; - accessRequestChannels: string; - isSecretRequestNotificationEnabled: boolean; - secretRequestChannels: string; + orgId: string; }; -export type TUpdateProjectSlackConfigDTO = { - workspaceId: string; - slackIntegrationId: string; - isAccessRequestNotificationEnabled: boolean; - accessRequestChannels: string; - isSecretRequestNotificationEnabled: boolean; - secretRequestChannels: string; +export type ProjectWorkflowIntegrationConfig = + | { + id: string; + integration: WorkflowIntegrationPlatform.SLACK; + integrationId: string; + isAccessRequestNotificationEnabled: boolean; + accessRequestChannels: string; + isSecretRequestNotificationEnabled: boolean; + secretRequestChannels: string; + } + | { + id: string; + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS; + integrationId: string; + isAccessRequestNotificationEnabled: boolean; + isSecretRequestNotificationEnabled: boolean; + accessRequestChannels: { + teamId: string; + channelIds: string[]; + }; + secretRequestChannels: { + teamId: string; + channelIds: string[]; + }; + }; + +export type TUpdateProjectWorkflowIntegrationConfigDTO = + | { + integration: WorkflowIntegrationPlatform.SLACK; + workspaceId: string; + integrationId: string; + isAccessRequestNotificationEnabled: boolean; + accessRequestChannels: string; + isSecretRequestNotificationEnabled: boolean; + secretRequestChannels: string; + } + | { + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS; + workspaceId: string; + integrationId: string; + isAccessRequestNotificationEnabled: boolean; + isSecretRequestNotificationEnabled: boolean; + accessRequestChannels?: { + teamId: string; + channelIds: string[]; + }; + secretRequestChannels?: { + teamId: string; + channelIds: string[]; + }; + }; +export type TDeleteProjectWorkflowIntegrationDTO = { + projectId: string; + integration: WorkflowIntegrationPlatform; + integrationId: string; +}; + +export type TCheckMicrosoftTeamsIntegrationInstallationStatusDTO = { + workflowIntegrationId: string; + orgId: string; }; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index c4defb68d..b841f4bff 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -26,9 +26,9 @@ export { useGetWorkspaceIndexStatus, useGetWorkspaceIntegrations, useGetWorkspaceSecrets, - useGetWorkspaceSlackConfig, useGetWorkspaceUserDetails, useGetWorkspaceUsers, + useGetWorkspaceWorkflowIntegrationConfig, useListWorkspaceCas, useListWorkspaceCertificates, useListWorkspaceCertificateTemplates, @@ -38,6 +38,7 @@ export { useListWorkspaceSshCas, useListWorkspaceSshCertificates, useListWorkspaceSshCertificateTemplates, + useListWorkspaceSshHostGroups, useListWorkspaceSshHosts, useNameWorkspaceSecrets, useSearchProjects, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 0a2bf491b..441b9aefa 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -18,9 +18,13 @@ import { EncryptedSecret } from "../secrets/types"; import { TSshCertificate, TSshCertificateAuthority } from "../sshCa/types"; import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; import { TSshHost } from "../sshHost/types"; +import { TSshHostGroup } from "../sshHostGroup/types"; import { userKeys } from "../users/query-keys"; import { TWorkspaceUser } from "../users/types"; -import { ProjectSlackConfig } from "../workflowIntegrations/types"; +import { + ProjectWorkflowIntegrationConfig, + WorkflowIntegrationPlatform +} from "../workflowIntegrations/types"; import { workspaceKeys } from "./query-keys"; import { CreateEnvironmentDTO, @@ -870,6 +874,21 @@ export const useListWorkspaceSshHosts = (projectId: string) => { }); }; +export const useListWorkspaceSshHostGroups = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId), + queryFn: async () => { + const { + data: { groups } + } = await apiRequest.get<{ groups: (TSshHostGroup & { hostCount: number })[] }>( + `/api/v2/workspace/${projectId}/ssh-host-groups` + ); + return groups; + }, + enabled: Boolean(projectId) + }); +}; + export const useListWorkspaceSshCertificateTemplates = (projectId: string) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId), @@ -883,13 +902,27 @@ export const useListWorkspaceSshCertificateTemplates = (projectId: string) => { }); }; -export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: string }) => { +export const useGetWorkspaceWorkflowIntegrationConfig = ({ + workspaceId, + integration +}: { + workspaceId: string; + integration: WorkflowIntegrationPlatform; +}) => { return useQuery({ - queryKey: workspaceKeys.getWorkspaceSlackConfig(workspaceId), + queryKey: workspaceKeys.getWorkspaceWorkflowIntegrationConfig(workspaceId, integration), queryFn: async () => { - const { data } = await apiRequest.get( - `/api/v1/workspace/${workspaceId}/slack-config` - ); + const { data } = await apiRequest + .get( + `/api/v1/workspace/${workspaceId}/workflow-integration-config/${integration}` + ) + .catch((err) => { + if (err.response.status === 404) { + return { data: null }; + } + + throw err; + }); return data; }, diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 05e9c7588..91fe95a04 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -1,6 +1,7 @@ import { TListProjectIdentitiesDTO, TSearchProjectsDTO } from "@app/hooks/api/workspace/types"; import type { CaStatus } from "../ca"; +import { WorkflowIntegrationPlatform } from "../workflowIntegrations/types"; export const workspaceKeys = { getWorkspaceById: (workspaceId: string) => ["workspaces", { workspaceId }] as const, @@ -54,12 +55,16 @@ export const workspaceKeys = { [{ workspaceId }, "workspace-pki-collections"] as const, getWorkspaceCertificateTemplates: (workspaceId: string) => [{ workspaceId }, "workspace-certificate-templates"] as const, - getWorkspaceSlackConfig: (workspaceId: string) => - [{ workspaceId }, "workspace-slack-config"] as const, + getWorkspaceWorkflowIntegrationConfig: ( + workspaceId: string, + integration: WorkflowIntegrationPlatform + ) => [{ workspaceId, integration }, "workspace-workflow-integration-config"] as const, getWorkspaceSshCas: (projectId: string) => [{ projectId }, "workspace-ssh-cas"] as const, allWorkspaceSshCertificates: (projectId: string) => [{ projectId }, "workspace-ssh-certificates"] as const, getWorkspaceSshHosts: (projectId: string) => [{ projectId }, "workspace-ssh-hosts"] as const, + getWorkspaceSshHostGroups: (projectId: string) => + [{ projectId }, "workspace-ssh-host-groups"] as const, specificWorkspaceSshCertificates: ({ offset, limit, diff --git a/frontend/src/index.css b/frontend/src/index.css index 1c5b0c465..3b00a91da 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -197,4 +197,4 @@ html { position: absolute; top: 0.5rem; @apply text-sm text-gray-500 opacity-50; -} +} \ No newline at end of file diff --git a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx index a9242c252..af93b3c2a 100644 --- a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx @@ -235,7 +235,7 @@ export const OverviewPage = () => { Default organization
- Select the default organization you want to set for SAML/LDAP/OIDC based + Select the default organization you want to set for SAML/LDAP/OIDC/Github logins. When selected, user logins will be automatically scoped to the selected organization.
diff --git a/frontend/src/pages/admin/OverviewPage/components/IntegrationPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/IntegrationPanel.tsx index 9bb005ce4..2ef4f7329 100644 --- a/frontend/src/pages/admin/OverviewPage/components/IntegrationPanel.tsx +++ b/frontend/src/pages/admin/OverviewPage/components/IntegrationPanel.tsx @@ -1,166 +1,24 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; +import { useGetAdminIntegrationsConfig } from "@app/hooks/api"; -import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; -import { useGetAdminSlackConfig, useUpdateServerConfig } from "@app/hooks/api"; - -const slackFormSchema = z.object({ - clientId: z.string(), - clientSecret: z.string() -}); - -type TSlackForm = z.infer; - -const getCustomSlackAppCreationUrl = () => - `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent( - JSON.stringify({ - display_information: { - name: "Infisical", - description: "Get real-time Infisical updates in Slack", - background_color: "#c2d62b", - long_description: `This Slack application is designed specifically for use with your self-hosted Infisical instance, allowing seamless integration between your Infisical projects and your Slack workspace. With this integration, your team can stay up-to-date with the latest events, changes, and notifications directly inside Slack. - - Notifications: Receive real-time updates and alerts about critical events in your Infisical projects. Whether it's a new project being created, updates to secrets, or changes to your team's configuration, you will be promptly notified within the designated Slack channels of your choice. - - Customization: Tailor the notifications to your team's specific needs by configuring which types of events trigger alerts and in which channels they are sent. - - Collaboration: Keep your entire team in the loop with notifications that help facilitate more efficient collaboration by ensuring that everyone is aware of important developments in your Infisical projects. - - By integrating Infisical with Slack, you can enhance your workflow by combining the power of secure secrets management with the communication capabilities of Slack.` - }, - features: { - app_home: { - home_tab_enabled: false, - messages_tab_enabled: false, - messages_tab_read_only_enabled: true - }, - bot_user: { - display_name: "Infisical", - always_online: true - } - }, - oauth_config: { - redirect_urls: [`${window.origin}/api/v1/workflow-integrations/slack/oauth_redirect`], - scopes: { - bot: ["chat:write.public", "chat:write", "channels:read", "groups:read"] - } - }, - settings: { - org_deploy_enabled: false, - socket_mode_enabled: false, - token_rotation_enabled: false - } - }) - )}`; +import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm"; +import { SlackIntegrationForm } from "./SlackIntegrationForm"; export const IntegrationPanel = () => { - const { - control, - handleSubmit, - setValue, - formState: { isSubmitting, isDirty } - } = useForm({ - resolver: zodResolver(slackFormSchema) - }); - - const { data: adminSlackConfig } = useGetAdminSlackConfig(); - const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig(); - const [isSlackClientIdFocused, setIsSlackClientIdFocused] = useToggle(); - const [isSlackClientSecretFocused, setIsSlackClientSecretFocused] = useToggle(); - - useEffect(() => { - if (adminSlackConfig) { - setValue("clientId", adminSlackConfig.clientId); - setValue("clientSecret", adminSlackConfig.clientSecret); - } - }, [adminSlackConfig]); - - const onSlackFormSubmit = async (data: TSlackForm) => { - await updateAdminServerConfig({ - slackClientId: data.clientId, - slackClientSecret: data.clientSecret - }); - - createNotification({ - text: "Updated admin slack configuration", - type: "success" - }); - }; + const { data: adminIntegrationsConfig } = useGetAdminIntegrationsConfig(); return ( -
-
-
Slack Integration
-
- Step 1: Create your Infisical Slack App +
+
+
Integrations
+
+ Configure your instance-wide settings to enable integration with Slack and Microsoft + Teams.
-
- -
-
- Step 2: Configure your instance-wide settings to enable integration with Slack. Copy the - values from the App Credentials page of your custom Slack App. -
- ( - - setIsSlackClientIdFocused.on()} - onBlur={() => setIsSlackClientIdFocused.off()} - onChange={(e) => field.onChange(e.target.value)} - /> - - )} - /> - ( - - setIsSlackClientSecretFocused.on()} - onBlur={() => setIsSlackClientSecretFocused.off()} - onChange={(e) => field.onChange(e.target.value)} - /> - - )} - />
- - +
+ + +
+
); }; diff --git a/frontend/src/pages/admin/OverviewPage/components/MicrosoftTeamsIntegrationForm.tsx b/frontend/src/pages/admin/OverviewPage/components/MicrosoftTeamsIntegrationForm.tsx new file mode 100644 index 000000000..ecd8288f4 --- /dev/null +++ b/frontend/src/pages/admin/OverviewPage/components/MicrosoftTeamsIntegrationForm.tsx @@ -0,0 +1,183 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { BsMicrosoftTeams } from "react-icons/bs"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input +} from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { useUpdateServerConfig } from "@app/hooks/api"; +import { AdminIntegrationsConfig } from "@app/hooks/api/admin/types"; + +const microsoftTeamsFormSchema = z.object({ + appId: z.string(), + clientSecret: z.string(), + botId: z.string() +}); + +type TMicrosoftTeamsForm = z.infer; + +type Props = { + adminIntegrationsConfig?: AdminIntegrationsConfig; +}; + +export const MicrosoftTeamsIntegrationForm = ({ adminIntegrationsConfig }: Props) => { + const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig(); + const [isMicrosoftTeamsAppIdFocused, setIsMicrosoftTeamsAppIdFocused] = useToggle(); + const [isMicrosoftTeamsClientSecretFocused, setIsMicrosoftTeamsClientSecretFocused] = useToggle(); + const [isMicrosoftTeamsBotIdFocused, setIsMicrosoftTeamsBotIdFocused] = useToggle(); + const { + control, + handleSubmit, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(microsoftTeamsFormSchema) + }); + + const onSubmit = async (data: TMicrosoftTeamsForm) => { + await updateAdminServerConfig({ + microsoftTeamsAppId: data.appId, + microsoftTeamsClientSecret: data.clientSecret, + microsoftTeamsBotId: data.botId + }); + + createNotification({ + text: "Updated admin Microsoft Teams configuration", + type: "success" + }); + }; + + useEffect(() => { + if (adminIntegrationsConfig) { + setValue("appId", adminIntegrationsConfig.microsoftTeams.appId); + setValue("clientSecret", adminIntegrationsConfig.microsoftTeams.clientSecret); + setValue("botId", adminIntegrationsConfig.microsoftTeams.botId); + } + }, [adminIntegrationsConfig]); + + return ( +
+ + + +
+ +
Microsoft Teams Integration
+
+
+ +
+
+ Step 1: Create and configure Microsoft Teams bot and Azure Resources. Please refer + to the documentation below for more information. +
+ +
+ Step 2: Configure your instance-wide settings to enable integration with Microsoft + Teams. Copy the App ID and Client Secret from your Microsoft Teams bot's App + Registration page. The Client Secret is the password for the bot. +
+ ( + + setIsMicrosoftTeamsAppIdFocused.on()} + onBlur={() => setIsMicrosoftTeamsAppIdFocused.off()} + onChange={(e) => field.onChange(e.target.value)} + /> + + )} + /> + ( + + setIsMicrosoftTeamsClientSecretFocused.on()} + onBlur={() => setIsMicrosoftTeamsClientSecretFocused.off()} + onChange={(e) => field.onChange(e.target.value)} + /> + + )} + /> + + ( + + setIsMicrosoftTeamsBotIdFocused.on()} + onBlur={() => setIsMicrosoftTeamsBotIdFocused.off()} + onChange={(e) => field.onChange(e.target.value)} + /> + + )} + /> +
+ +
+
+
+
+
+
+ ); +}; diff --git a/frontend/src/pages/admin/OverviewPage/components/SlackIntegrationForm.tsx b/frontend/src/pages/admin/OverviewPage/components/SlackIntegrationForm.tsx new file mode 100644 index 000000000..63da0e384 --- /dev/null +++ b/frontend/src/pages/admin/OverviewPage/components/SlackIntegrationForm.tsx @@ -0,0 +1,189 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { BsSlack } from "react-icons/bs"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input +} from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { useUpdateServerConfig } from "@app/hooks/api"; +import { AdminIntegrationsConfig } from "@app/hooks/api/admin/types"; + +const getCustomSlackAppCreationUrl = () => + `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent( + JSON.stringify({ + display_information: { + name: "Infisical", + description: "Get real-time Infisical updates in Slack", + background_color: "#c2d62b", + long_description: `This Slack application is designed specifically for use with your self-hosted Infisical instance, allowing seamless integration between your Infisical projects and your Slack workspace. With this integration, your team can stay up-to-date with the latest events, changes, and notifications directly inside Slack. + - Notifications: Receive real-time updates and alerts about critical events in your Infisical projects. Whether it's a new project being created, updates to secrets, or changes to your team's configuration, you will be promptly notified within the designated Slack channels of your choice. + - Customization: Tailor the notifications to your team's specific needs by configuring which types of events trigger alerts and in which channels they are sent. + - Collaboration: Keep your entire team in the loop with notifications that help facilitate more efficient collaboration by ensuring that everyone is aware of important developments in your Infisical projects. + + By integrating Infisical with Slack, you can enhance your workflow by combining the power of secure secrets management with the communication capabilities of Slack.` + }, + features: { + app_home: { + home_tab_enabled: false, + messages_tab_enabled: false, + messages_tab_read_only_enabled: true + }, + bot_user: { + display_name: "Infisical", + always_online: true + } + }, + oauth_config: { + redirect_urls: [`${window.origin}/api/v1/workflow-integrations/slack/oauth_redirect`], + scopes: { + bot: ["chat:write.public", "chat:write", "channels:read", "groups:read"] + } + }, + settings: { + org_deploy_enabled: false, + socket_mode_enabled: false, + token_rotation_enabled: false + } + }) + )}`; + +const slackFormSchema = z.object({ + clientId: z.string(), + clientSecret: z.string() +}); + +type TSlackForm = z.infer; + +type Props = { + adminIntegrationsConfig?: AdminIntegrationsConfig; +}; + +export const SlackIntegrationForm = ({ adminIntegrationsConfig }: Props) => { + const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig(); + const [isSlackClientIdFocused, setIsSlackClientIdFocused] = useToggle(); + const [isSlackClientSecretFocused, setIsSlackClientSecretFocused] = useToggle(); + + const { + control, + handleSubmit, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(slackFormSchema) + }); + + const onSubmit = async (data: TSlackForm) => { + await updateAdminServerConfig({ + slackClientId: data.clientId, + slackClientSecret: data.clientSecret + }); + + createNotification({ + text: "Updated admin slack configuration", + type: "success" + }); + }; + + useEffect(() => { + if (adminIntegrationsConfig) { + setValue("clientId", adminIntegrationsConfig.slack.clientId); + setValue("clientSecret", adminIntegrationsConfig.slack.clientSecret); + } + }, [adminIntegrationsConfig]); + + return ( +
+ + + +
+ +
Slack Integration
+
+
+ +
+
+ Step 1: Create your Infisical Slack App +
+
+ +
+
+ Step 2: Configure your instance-wide settings to enable integration with Slack. Copy + the values from the App Credentials page of your custom Slack App. +
+ ( + + setIsSlackClientIdFocused.on()} + onBlur={() => setIsSlackClientIdFocused.off()} + onChange={(e) => field.onChange(e.target.value)} + /> + + )} + /> + ( + + setIsSlackClientSecretFocused.on()} + onBlur={() => setIsSlackClientSecretFocused.off()} + onChange={(e) => field.onChange(e.target.value)} + /> + + )} + /> +
+ +
+
+
+
+
+
+ ); +}; diff --git a/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx b/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx index 40075ae54..6cb4da82b 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/SignUpSsoPage.tsx @@ -13,6 +13,7 @@ export const SignupSsoPage = () => { const { t } = useTranslation(); const search = useSearch({ from: ROUTE_PATHS.Auth.SignUpSsoPage.id }); const token = search.token as string; + const defaultOrgAllowed = search.defaultOrgAllowed as boolean | undefined; const [step, setStep] = useState(0); const [password, setPassword] = useState(""); @@ -57,6 +58,7 @@ export const SignupSsoPage = () => { password={password} setPassword={setPassword} providerAuthToken={token} + forceDefaultOrg={defaultOrgAllowed} /> ); default: diff --git a/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx index a6ab0623b..2f2ade8e7 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -30,6 +30,7 @@ type Props = { name: string; providerOrganizationName: string; providerAuthToken?: string; + forceDefaultOrg?: boolean; }; /** @@ -51,7 +52,8 @@ export const UserInfoSSOStep = ({ providerOrganizationName, password, setPassword, - providerAuthToken + providerAuthToken, + forceDefaultOrg }: Props) => { const [nameError, setNameError] = useState(false); const [organizationName, setOrganizationName] = useState(""); @@ -84,7 +86,7 @@ export const UserInfoSSOStep = ({ } else { setNameError(false); } - if (!organizationName) { + if (!organizationName && !forceDefaultOrg) { setOrganizationNameError(true); errorCheck = true; } else { @@ -160,7 +162,8 @@ export const UserInfoSSOStep = ({ salt: result.salt, verifier: result.verifier, organizationName, - attributionSource + attributionSource, + useDefaultOrg: forceDefaultOrg }); // unset signup JWT token and set JWT token @@ -267,7 +270,7 @@ export const UserInfoSSOStep = ({

)}
- {providerOrganizationName === undefined && ( + {!forceDefaultOrg && providerOrganizationName === undefined && (

Organization Name @@ -279,7 +282,7 @@ export const UserInfoSSOStep = ({ isRequired className="h-12" maxLength={64} - disabled + isDisabled={forceDefaultOrg} /> {organizationNameError && (

diff --git a/frontend/src/pages/auth/SignUpSsoPage/route.tsx b/frontend/src/pages/auth/SignUpSsoPage/route.tsx index a255efaf6..986735ad0 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/route.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/route.tsx @@ -5,7 +5,8 @@ import { z } from "zod"; import { SignupSsoPage } from "./SignUpSsoPage"; const SignupSSOPageQueryParamsSchema = z.object({ - token: z.string() + token: z.string(), + defaultOrgAllowed: z.boolean().optional() }); export const Route = createFileRoute("/_restrict-login-signup/signup/sso")({ diff --git a/frontend/src/pages/middlewares/restrict-login-signup.tsx b/frontend/src/pages/middlewares/restrict-login-signup.tsx index 97ffff752..f85ea44f2 100644 --- a/frontend/src/pages/middlewares/restrict-login-signup.tsx +++ b/frontend/src/pages/middlewares/restrict-login-signup.tsx @@ -97,7 +97,11 @@ export const Route = createFileRoute("/_restrict-login-signup")({ } if (!data.organizationId) { - if (location.pathname.endsWith("select-organization")) return; + if ( + location.pathname.endsWith("select-organization") || + location.pathname.endsWith("verify-email") + ) + return; throw redirect({ to: "/login/select-organization" }); } throw redirect({ diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index f47de43dd..5c004ce96 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -12,11 +12,13 @@ import { AppConnectionHeader } from "../AppConnectionHeader"; import { Auth0ConnectionForm } from "./Auth0ConnectionForm"; import { AwsConnectionForm } from "./AwsConnectionForm"; import { AzureAppConfigurationConnectionForm } from "./AzureAppConfigurationConnectionForm"; +import { AzureClientSecretsConnectionForm } from "./AzureClientSecretsConnectionForm"; import { AzureKeyVaultConnectionForm } from "./AzureKeyVaultConnectionForm"; import { CamundaConnectionForm } from "./CamundaConnectionForm"; import { DatabricksConnectionForm } from "./DatabricksConnectionForm"; import { GcpConnectionForm } from "./GcpConnectionForm"; import { GitHubConnectionForm } from "./GitHubConnectionForm"; +import { HCVaultConnectionForm } from "./HCVaultConnectionForm"; import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; import { LdapConnectionForm } from "./LdapConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; @@ -87,10 +89,14 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Camunda: return ; + case AppConnection.AzureClientSecrets: + return ; case AppConnection.Windmill: return ; case AppConnection.Auth0: return ; + case AppConnection.HCVault: + return ; case AppConnection.LDAP: return ; case AppConnection.TeamCity: @@ -155,10 +161,14 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Camunda: return ; + case AppConnection.AzureClientSecrets: + return ; case AppConnection.Windmill: return ; case AppConnection.Auth0: return ; + case AppConnection.HCVault: + return ; case AppConnection.LDAP: return ; case AppConnection.TeamCity: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureClientSecretsConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureClientSecretsConnectionForm.tsx new file mode 100644 index 000000000..9076c95ce --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureClientSecretsConnectionForm.tsx @@ -0,0 +1,169 @@ +import crypto from "crypto"; + +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { isInfisicalCloud } from "@app/helpers/platform"; +import { + AzureClientSecretsConnectionMethod, + TAzureClientSecretsConnection, + useGetAppConnectionOption +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TAzureClientSecretsConnection; +}; + +const formSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.AzureClientSecrets), + method: z.nativeEnum(AzureClientSecretsConnectionMethod), + tenantId: z.string().trim().min(1, "Tenant ID is required") +}); + +type FormData = z.infer; + +export const AzureClientSecretsConnectionForm = ({ appConnection }: Props) => { + const isUpdate = Boolean(appConnection); + const [isRedirecting, setIsRedirecting] = useState(false); + + const { + option: { oauthClientId }, + isLoading + } = useGetAppConnectionOption(AppConnection.AzureClientSecrets); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection + ? { + ...appConnection, + tenantId: appConnection.credentials.tenantId + } + : { + app: AppConnection.AzureClientSecrets, + method: AzureClientSecretsConnectionMethod.OAuth + } + }); + + const { + handleSubmit, + control, + watch, + formState: { isSubmitting, isDirty } + } = form; + + const selectedMethod = watch("method"); + + const onSubmit = (formData: FormData) => { + setIsRedirecting(true); + const state = crypto.randomBytes(16).toString("hex"); + localStorage.setItem("latestCSRFToken", state); + localStorage.setItem( + "azureClientSecretsConnectionFormData", + JSON.stringify({ ...formData, connectionId: appConnection?.id }) + ); + + switch (formData.method) { + case AzureClientSecretsConnectionMethod.OAuth: + window.location.assign( + `https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://azconfig.io/.default%20openid%20offline_access&state=${state}<:>azure-client-secrets` + ); + break; + default: + throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`); + } + }; + + const isMissingConfig = !oauthClientId; + + const methodDetails = getAppConnectionMethodDetails(selectedMethod); + + return ( + +

+ {!isUpdate && } + + ( + + + + )} + /> + + ( + + + + )} + /> +
+ + + + +
+ + + ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HCVaultConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HCVaultConnectionForm.tsx new file mode 100644 index 000000000..af3954b45 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HCVaultConnectionForm.tsx @@ -0,0 +1,224 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { HCVaultConnectionMethod, THCVaultConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: THCVaultConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.HCVault) +}); + +const InstanceUrlSchema = z + .string() + .trim() + .min(1, "Instance URL required") + .url("Invalid Instance URL"); + +const NamespaceSchema = z.string().trim().optional(); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(HCVaultConnectionMethod.AccessToken), + credentials: z.object({ + instanceUrl: InstanceUrlSchema, + namespace: NamespaceSchema, + accessToken: z.string().trim().min(1, "Access Token required") + }) + }), + rootSchema.extend({ + method: z.literal(HCVaultConnectionMethod.AppRole), + credentials: z.object({ + instanceUrl: InstanceUrlSchema, + namespace: NamespaceSchema, + roleId: z.string().trim().min(1, "Role ID required"), + secretId: z.string().trim().min(1, "Secret ID required") + }) + }) +]); + +type FormData = z.infer; + +export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.HCVault, + method: HCVaultConnectionMethod.AppRole + } + }); + + const { + handleSubmit, + control, + watch, + formState: { isSubmitting, isDirty } + } = form; + + const selectedMethod = watch("method"); + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {selectedMethod === HCVaultConnectionMethod.AccessToken ? ( + ( + + + + )} + /> + ) : ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + + )} +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx index 8832d611e..ce7a1b489 100644 --- a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx +++ b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx @@ -7,9 +7,11 @@ import { ROUTE_PATHS } from "@app/const/routes"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { AzureAppConfigurationConnectionMethod, + AzureClientSecretsConnectionMethod, AzureKeyVaultConnectionMethod, GitHubConnectionMethod, TAzureAppConfigurationConnection, + TAzureClientSecretsConnection, TAzureKeyVaultConnection, TGitHubConnection, useCreateAppConnection, @@ -32,18 +34,26 @@ type AzureAppConfigurationFormData = BaseFormData & Pick & Pick; +type AzureClientSecretsFormData = BaseFormData & + Pick & + Pick; + type FormDataMap = { [AppConnection.GitHub]: GithubFormData & { app: AppConnection.GitHub }; [AppConnection.AzureKeyVault]: AzureKeyVaultFormData & { app: AppConnection.AzureKeyVault }; [AppConnection.AzureAppConfiguration]: AzureAppConfigurationFormData & { app: AppConnection.AzureAppConfiguration; }; + [AppConnection.AzureClientSecrets]: AzureClientSecretsFormData & { + app: AppConnection.AzureClientSecrets; + }; }; const formDataStorageFieldMap: Partial> = { [AppConnection.GitHub]: "githubConnectionFormData", [AppConnection.AzureKeyVault]: "azureKeyVaultConnectionFormData", - [AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData" + [AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData", + [AppConnection.AzureClientSecrets]: "azureClientSecretsConnectionFormData" }; export const OAuthCallbackPage = () => { @@ -194,6 +204,54 @@ export const OAuthCallbackPage = () => { }; }, []); + const handleAzureClientSecrets = useCallback(async () => { + const formData = getFormData(AppConnection.AzureClientSecrets); + if (formData === null) return null; + + clearState(AppConnection.AzureClientSecrets); + + const { connectionId, name, description, returnUrl } = formData; + + try { + if (connectionId) { + await updateAppConnection.mutateAsync({ + app: AppConnection.AzureClientSecrets, + connectionId, + credentials: { + code: code as string, + tenantId: formData.tenantId + } + }); + } else { + await createAppConnection.mutateAsync({ + app: AppConnection.AzureClientSecrets, + name, + description, + method: AzureClientSecretsConnectionMethod.OAuth, + credentials: { + code: code as string, + tenantId: formData.tenantId + } + }); + } + } catch (err: any) { + createNotification({ + title: `Failed to ${connectionId ? "update" : "add"} Azure Client Secrets Connection`, + text: err?.message, + type: "error" + }); + navigate({ + to: returnUrl ?? "/organization/app-connections" + }); + } + + return { + connectionId, + returnUrl, + appConnectionName: formData.app + }; + }, []); + const handleGithub = useCallback(async () => { const formData = getFormData(AppConnection.GitHub); if (formData === null) return null; @@ -280,6 +338,8 @@ export const OAuthCallbackPage = () => { data = await handleAzureKeyVault(); } else if (appConnection === AppConnection.AzureAppConfiguration) { data = await handleAzureAppConfiguration(); + } else if (appConnection === AppConnection.AzureClientSecrets) { + data = await handleAzureClientSecrets(); } if (data) { diff --git a/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/OauthCallbackPage.tsx b/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/OauthCallbackPage.tsx new file mode 100644 index 000000000..df5bf4f60 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/OauthCallbackPage.tsx @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useState } from "react"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { ContentLoader } from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { useOrganization } from "@app/context"; +import { useCreateMicrosoftTeamsIntegration } from "@app/hooks/api"; + +const stateSchema = z.object({ + redirectUri: z.string(), + tenantId: z.string(), + slug: z.string(), + description: z.string().optional(), + csrfToken: z.string(), + clientId: z.string() +}); + +export const OAuthCallbackPage = () => { + const navigate = useNavigate(); + const { currentOrg } = useOrganization(); + const [isReady, setIsReady] = useState(false); + + const search = useSearch({ + from: ROUTE_PATHS.Organization.Settings.OauthCallbackPage.id + }); + + const createMicrosoftTeamsWorkflowIntegration = useCreateMicrosoftTeamsIntegration(); + + const { state: rawState, code } = search; + + const state = stateSchema.parse(rawState); + + const clearState = () => { + if (state.csrfToken !== localStorage.getItem("latestCSRFToken")) { + throw new Error("Invalid CSRF token"); + } + + localStorage.removeItem("latestCSRFToken"); + }; + + const handleMicrosoftTeams = useCallback(async () => { + clearState(); + + if (!code) { + throw new Error("No code provided"); + } + + await createMicrosoftTeamsWorkflowIntegration.mutateAsync({ + orgId: currentOrg.id, + tenantId: state.tenantId, + code, + slug: state.slug, + description: state.description ?? "", + redirectUri: state.redirectUri + }); + + navigate({ + to: ROUTE_PATHS.Organization.SettingsPage.path + }); + }, []); + + // Ensure that the localstorage is ready for use, to avoid the form data being malformed + useEffect(() => { + if (!isReady) { + setIsReady(!!localStorage.length); + } + }, [localStorage.length]); + + useEffect(() => { + if (!isReady) return; + + (async () => { + try { + await handleMicrosoftTeams(); + + createNotification({ + text: "Successfully created Microsoft Teams workflow integration", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create Microsoft Teams workflow integration", + type: "error" + }); + } + })(); + }, [isReady]); + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/route.tsx b/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/route.tsx new file mode 100644 index 000000000..8c6af10f3 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/route.tsx @@ -0,0 +1,27 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; + +import { OAuthCallbackPage } from "./OauthCallbackPage"; + +const SettingsOAuthCallbackPageQueryParamsSchema = z.object({ + state: z + .object({ + clientId: z.string(), + tenantId: z.string(), + slug: z.string(), + description: z.string().optional(), + redirectUri: z.string(), + csrfToken: z.string() + }) + .nullable() + .catch(null), + code: z.string().catch("") +}); + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/_org-layout/organization/settings/oauth/callback" +)({ + component: OAuthCallbackPage, + validateSearch: zodValidator(SettingsOAuthCallbackPageQueryParamsSchema) +}); diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx index bf40c7484..05d105192 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx @@ -23,6 +23,7 @@ import { OrgLDAPSection } from "./OrgLDAPSection"; import { OrgOIDCSection } from "./OrgOIDCSection"; import { OrgScimSection } from "./OrgSCIMSection"; import { OrgSSOSection } from "./OrgSSOSection"; +import { OrgUserAccessTokenLimitSection } from "./OrgUserAccessTokenLimitSection"; import { SSOModal } from "./SSOModal"; export const OrgAuthTab = withPermission( @@ -167,6 +168,7 @@ export const OrgAuthTab = withPermission( return ( <> + {shouldShowCreateIdentityProviderView ? ( createIdentityProviderView ) : ( diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgUserAccessTokenLimitSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgUserAccessTokenLimitSection.tsx new file mode 100644 index 000000000..43e72bd41 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgUserAccessTokenLimitSection.tsx @@ -0,0 +1,171 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { useUpdateOrg } from "@app/hooks/api"; + +const formSchema = z.object({ + expirationValue: z.number().min(1, "Value must be at least 1"), + expirationUnit: z.enum(["m", "h", "d", "w"], { + invalid_type_error: "Please select a valid time unit" + }) +}); + +type TForm = z.infer; + +// Function to parse duration string like "30d" into value and unit +const parseDuration = (duration: string): { value: number; unit: string } => { + const match = duration.match(/^(\d+)([mhdw])$/); + if (match) { + return { + value: parseInt(match[1], 10), + unit: match[2] + }; + } + // Default to 30 days if invalid format + return { value: 30, unit: "d" }; +}; + +// Function to format value and unit back to duration string +const formatDuration = (value: number, unit: string): string => { + return `${value}${unit}`; +}; + +export const OrgUserAccessTokenLimitSection = () => { + const { mutateAsync: updateUserTokenExpiration } = useUpdateOrg(); + const { currentOrg } = useOrganization(); + + // Parse the current duration or use default + const currentDuration = parseDuration(currentOrg?.userTokenExpiration || "30d"); + + const { + control, + formState: { isSubmitting, isDirty }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + expirationValue: currentDuration.value, + expirationUnit: currentDuration.unit as "m" | "h" | "d" | "w" + } + }); + + if (!currentOrg) return null; + + const handleUserTokenExpirationSubmit = async (formData: TForm) => { + try { + const userTokenExpiration = formatDuration(formData.expirationValue, formData.expirationUnit); + + await updateUserTokenExpiration({ + userTokenExpiration, + orgId: currentOrg.id + }); + + createNotification({ + text: "Successfully updated user token expiration", + type: "success" + }); + } catch { + createNotification({ + text: "Failed updating user token expiration", + type: "error" + }); + } + }; + + // Units for the dropdown with readable labels + const timeUnits = [ + { value: "m", label: "Minutes" }, + { value: "h", label: "Hours" }, + { value: "d", label: "Days" }, + { value: "w", label: "Weeks" } + ]; + + return ( +
+
+

User Token Expiration

+
+

+ This defines the maximum time a user token will be valid. After this time, the user will + need to re-authenticate. +

+ + {(isAllowed) => ( +
+
+
+ ( + + field.onChange(parseInt(e.target.value, 10))} + disabled={!isAllowed} + /> + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ +
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx index 1d12b50dd..d4934ce7e 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { BsMicrosoftTeams } from "react-icons/bs"; import { faSlack } from "@fortawesome/free-brands-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AnimatePresence, motion } from "framer-motion"; @@ -6,6 +7,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { Modal, ModalContent } from "@app/components/v2"; import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; +import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm"; import { SlackIntegrationForm } from "./SlackIntegrationForm"; type Props = { @@ -20,9 +22,14 @@ enum WizardSteps { const PLATFORM_LIST = [ { - icon: faSlack, + icon: , platform: WorkflowIntegrationPlatform.SLACK, title: "Slack" + }, + { + icon: , + platform: WorkflowIntegrationPlatform.MICROSOFT_TEAMS, + title: "Microsoft Teams" } ]; @@ -38,7 +45,10 @@ export const AddWorkflowIntegrationForm = ({ isOpen, onToggle }: Props) => { return ( handleFormReset(state)}> - + char.toUpperCase()) ?? "workflow"} integration`} + className="my-4" + > {wizardStep === WizardSteps.SelectPlatform && ( { } }} > - +
{icon}
{title}
))} @@ -86,6 +96,18 @@ export const AddWorkflowIntegrationForm = ({ isOpen, onToggle }: Props) => { onToggle(false)} /> )} + {wizardStep === WizardSteps.PlatformInputs && + selectedPlatform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && ( + + onToggle(false)} /> + + )} diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx index a9af20247..48cfa2243 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx @@ -1,6 +1,7 @@ import { Modal, ModalContent } from "@app/components/v2"; import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; +import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm"; import { SlackIntegrationForm } from "./SlackIntegrationForm"; type Props = { @@ -20,6 +21,9 @@ export const IntegrationFormDetails = ({ isOpen, id, onOpenChange, workflowPlatf {workflowPlatform === WorkflowIntegrationPlatform.SLACK && ( onOpenChange(false)} /> )} + {workflowPlatform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && ( + onOpenChange(false)} /> + )} ); diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/MicrosoftTeamsIntegrationForm.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/MicrosoftTeamsIntegrationForm.tsx new file mode 100644 index 000000000..09e5d6583 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/MicrosoftTeamsIntegrationForm.tsx @@ -0,0 +1,192 @@ +import crypto from "crypto"; + +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { + useGetMicrosoftTeamsClientId, + useGetMicrosoftTeamsIntegrationById, + useUpdateMicrosoftTeamsIntegration +} from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; + +type Props = { + id?: string; + onClose: () => void; +}; + +const microsoftTeamsFormSchema = z.object({ + slug: slugSchema({ min: 1, field: "Alias" }), + tenantId: z + .string() + .min(1, { message: "Tenant ID is required" }) + .trim() + .uuid("Tenant ID must be a valid UUID"), + description: z.string().optional() +}); + +type TMicrosoftTeamsFormData = z.infer; + +export const MicrosoftTeamsIntegrationForm = ({ id, onClose }: Props) => { + const { + control, + handleSubmit, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(microsoftTeamsFormSchema) + }); + + const { currentOrg } = useOrganization(); + const { data: microsoftTeamsIntegration } = useGetMicrosoftTeamsIntegrationById(id); + const { mutateAsync: updateMicrosoftTeamsIntegration } = useUpdateMicrosoftTeamsIntegration(); + const { data: microsoftTeamsClientId } = useGetMicrosoftTeamsClientId(); + + useEffect(() => { + if (microsoftTeamsIntegration) { + setValue("slug", microsoftTeamsIntegration.slug); + setValue("description", microsoftTeamsIntegration.description ?? ""); + setValue("tenantId", microsoftTeamsIntegration.tenantId); + } + }, [microsoftTeamsIntegration]); + + const handleMicrosoftTeamsFormSubmit = async ({ + slug, + description, + tenantId + }: TMicrosoftTeamsFormData) => { + if (!microsoftTeamsClientId) { + createNotification({ + text: "Microsoft Teams client ID is not set. Please contact your instance administrator.", + type: "error" + }); + return; + } + + if (id && microsoftTeamsIntegration) { + if (!currentOrg) { + return; + } + + if (tenantId !== microsoftTeamsIntegration.tenantId) { + createNotification({ + text: "Tenant ID cannot be changed", + type: "error" + }); + return; + } + + await updateMicrosoftTeamsIntegration({ + id, + orgId: currentOrg.id, + slug, + description + }); + + createNotification({ + text: "Successfully updated Microsoft Teams integration", + type: "success" + }); + + onClose(); + } else { + const csrfToken = crypto.randomBytes(32).toString("hex"); + localStorage.setItem("latestCSRFToken", csrfToken); + + const state = { + redirectUri: `${window.location.origin}/organization/settings/oauth/callback`, + tenantId, + slug, + description, + csrfToken, + clientId: microsoftTeamsClientId.clientId + }; + + const url = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize? + client_id=${microsoftTeamsClientId.clientId} + &redirect_uri=${state.redirectUri} + &response_type=code + &response_mode=query + &scope=https://graph.microsoft.com/.default + &state=${encodeURIComponent(JSON.stringify(state))} + &prompt=consent + &admin_consent=true`; + + window.location.href = url; + } + }; + + return ( +
+
+ For seamless installations, ensure that the Infisical bot is already installed in your + Microsoft Teams tenant. For more information, please refer to the{" "} + + Microsoft Teams Workflow Integration Documentation + + , which will guide you through the download and installation process. +
+ ( + + + + )} + /> + {!microsoftTeamsIntegration && ( + ( + + + + )} + /> + )} + ( + + + + )} + /> + {microsoftTeamsIntegration && ( + + + + )} +
+ + +
+ + ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx index e3b59489c..374c679a1 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx @@ -1,5 +1,5 @@ -import { faSlack } from "@fortawesome/free-brands-svg-icons"; -import { faEllipsis, faGear, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { BsMicrosoftTeams, BsSlack } from "react-icons/bs"; +import { faEllipsis, faGear, faInfoCircle, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import axios from "axios"; import { twMerge } from "tailwind-merge"; @@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { + Badge, Button, DeleteActionModal, DropdownMenu, @@ -20,6 +21,7 @@ import { TBody, Td, THead, + Tooltip, Tr } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; @@ -27,14 +29,31 @@ import { withPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { fetchSlackReinstallUrl, + useCheckMicrosoftTeamsIntegrationInstallationStatus, + useDeleteMicrosoftTeamsIntegration, useDeleteSlackIntegration, useGetWorkflowIntegrations } from "@app/hooks/api"; -import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; +import { + WorkflowIntegrationPlatform, + WorkflowIntegrationStatus +} from "@app/hooks/api/workflowIntegrations/types"; import { AddWorkflowIntegrationForm } from "./AddWorkflowIntegrationForm"; import { IntegrationFormDetails } from "./IntegrationFormDetails"; +const renderStatus = (status: WorkflowIntegrationStatus) => { + if (status === WorkflowIntegrationStatus.Installed) { + return Installed; + } + + if (status === WorkflowIntegrationStatus.Pending) { + return Pending; + } + + return Failed; +}; + export const OrgWorkflowIntegrationTab = withPermission( () => { const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ @@ -48,6 +67,9 @@ export const OrgWorkflowIntegrationTab = withPermission( useGetWorkflowIntegrations(currentOrg?.id); const { mutateAsync: deleteSlackIntegration } = useDeleteSlackIntegration(); + const { mutateAsync: deleteMicrosoftTeamsIntegration } = useDeleteMicrosoftTeamsIntegration(); + const { mutateAsync: checkMicrosoftTeamsInstallationStatus } = + useCheckMicrosoftTeamsIntegrationInstallationStatus(); const handleRemoveIntegration = async () => { if (!currentOrg) { @@ -62,6 +84,13 @@ export const OrgWorkflowIntegrationTab = withPermission( }); } + if (platform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS) { + await deleteMicrosoftTeamsIntegration({ + id, + orgId: currentOrg?.id + }); + } + handlePopUpClose("removeIntegration"); createNotification({ text: "Successfully deleted integration", @@ -69,27 +98,37 @@ export const OrgWorkflowIntegrationTab = withPermission( }); }; - const triggerReinstall = async (platform: WorkflowIntegrationPlatform, id: string) => { - if (platform === WorkflowIntegrationPlatform.SLACK) { - try { - const slackReinstallUrl = await fetchSlackReinstallUrl({ - id - }); + const triggerSlackReinstall = async (id: string) => { + try { + const slackReinstallUrl = await fetchSlackReinstallUrl({ + id + }); - if (slackReinstallUrl) { - window.location.href = slackReinstallUrl; - } - } catch (err) { - if (axios.isAxiosError(err)) { - createNotification({ - text: (err.response?.data as { message: string })?.message, - type: "error" - }); - } + if (slackReinstallUrl) { + window.location.href = slackReinstallUrl; + } + } catch (err) { + if (axios.isAxiosError(err)) { + createNotification({ + text: (err.response?.data as { message: string })?.message, + type: "error" + }); } } }; + const triggerMicrosoftTeamsInstallationStatusCheck = async (id: string) => { + await checkMicrosoftTeamsInstallationStatus({ + workflowIntegrationId: id, + orgId: currentOrg?.id + }); + + createNotification({ + text: "The Microsoft Teams bot is successfully installed in your Microsoft Teams tenant", + type: "success" + }); + }; + return (
@@ -117,6 +156,7 @@ export const OrgWorkflowIntegrationTab = withPermission( Provider Alias + Status @@ -134,11 +174,23 @@ export const OrgWorkflowIntegrationTab = withPermission( )} {workflowIntegrations?.map((workflowIntegration) => ( - - -
{workflowIntegration.integration.toUpperCase()}
+ + {workflowIntegration.integration === WorkflowIntegrationPlatform.SLACK ? ( + + ) : ( + + )} +
+ {workflowIntegration.integration.replaceAll("-", " ")} +
+ {workflowIntegration.description && ( + + + + )} {workflowIntegration.slug} + {renderStatus(workflowIntegration.status)} @@ -159,29 +211,56 @@ export const OrgWorkflowIntegrationTab = withPermission( > More details - - {(isAllowed) => ( - { - e.stopPropagation(); - triggerReinstall( - workflowIntegration.integration, - workflowIntegration.id - ); - }} - > - Reinstall - - )} - + {workflowIntegration.integration === WorkflowIntegrationPlatform.SLACK && ( + + {(isAllowed) => ( + { + e.stopPropagation(); + + await triggerSlackReinstall(workflowIntegration.id); + }} + > + Reinstall + + )} + + )} + + {workflowIntegration.integration === + WorkflowIntegrationPlatform.MICROSOFT_TEAMS && ( + + {(isAllowed) => ( + { + e.stopPropagation(); + + await triggerMicrosoftTeamsInstallationStatusCheck( + workflowIntegration.id + ); + }} + > + Check Installation Status + + )} + + )} + { ProjectPermissionSub.Kms, ProjectPermissionSub.SshCertificateTemplates, ProjectPermissionSub.SshCertificateAuthorities, - ProjectPermissionSub.SshCertificates + ProjectPermissionSub.SshCertificates, + ProjectPermissionSub.SshHostGroups ].includes(subject) ) { // from above statement we are sure it won't be undefined @@ -1092,6 +1094,15 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Issue Host Certificate", value: ProjectPermissionSshHostActions.IssueHostCert } ] }, + [ProjectPermissionSub.SshHostGroups]: { + title: "SSH Host Groups", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, [ProjectPermissionSub.PkiCollections]: { title: "PKI Collections", actions: [ diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/HCVaultSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/HCVaultSyncDestinationCol.tsx new file mode 100644 index 000000000..8011234e1 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/HCVaultSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { THCVaultSync } from "@app/hooks/api/secretSyncs/types/hc-vault-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: THCVaultSync; +}; + +export const HCVaultSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 776f4665e..abfbf100c 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -8,6 +8,7 @@ import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol"; import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol"; import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol"; import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; +import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol"; import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol"; @@ -44,6 +45,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Windmill: return ; + case SecretSync.HCVault: + return ; case SecretSync.TeamCity: return ; default: diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 27dc9d5a1..b1fe387e5 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -94,6 +94,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.workspace; secondaryText = destinationConfig.path; break; + case SecretSync.HCVault: + primaryText = destinationConfig.mount; + secondaryText = destinationConfig.path; + break; case SecretSync.TeamCity: primaryText = destinationConfig.project; secondaryText = destinationConfig.buildConfig; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HCVaultSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HCVaultSyncDestinationSection.tsx new file mode 100644 index 000000000..423e56cf0 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HCVaultSyncDestinationSection.tsx @@ -0,0 +1,19 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { THCVaultSync } from "@app/hooks/api/secretSyncs/types/hc-vault-sync"; + +type Props = { + secretSync: THCVaultSync; +}; + +export const HCVaultSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { path, mount } + } = secretSync; + + return ( + <> + {mount} + {path} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index 505f3ac3b..4ea5798d9 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -18,6 +18,7 @@ import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection"; import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSection"; import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; import { GitHubSyncDestinationSection } from "./GitHubSyncDestinationSection"; +import { HCVaultSyncDestinationSection } from "./HCVaultSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; import { TeamCitySyncDestinationSection } from "./TeamCitySyncDestinationSection"; import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection"; @@ -35,7 +36,7 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: const app = APP_CONNECTION_MAP[connection.app].name; let DestinationComponents: ReactNode; - switch (secretSync.destination) { + switch (destination) { case SecretSync.AWSParameterStore: DestinationComponents = ; break; @@ -74,6 +75,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Windmill: DestinationComponents = ; break; + case SecretSync.HCVault: + DestinationComponents = ; + break; case SecretSync.TeamCity: DestinationComponents = ; break; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index bc6713d3d..5995e7cd1 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -52,6 +52,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.Camunda: case SecretSync.Vercel: case SecretSync.Windmill: + case SecretSync.HCVault: case SecretSync.TeamCity: AdditionalSyncOptionsComponent = null; break; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx index d5510c619..c3e2a20f1 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx @@ -1,351 +1,163 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { BsMicrosoftTeams, BsSlack } from "react-icons/bs"; +import { faGear, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Link } from "@tanstack/react-router"; -import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { ProjectPermissionCan } from "@app/components/permissions"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button, - ContentLoader, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DeleteActionModal, EmptyState, - FormControl, - Input, - Select, - SelectItem, - Switch + Table, + TableContainer, + TBody, + Td, + THead, + Tr } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { OrgPermissionActions, OrgPermissionSubjects, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; import { - useGetSlackIntegrationChannels, - useGetSlackIntegrations, - useGetWorkspaceSlackConfig, - useUpdateProjectSlackConfig + useDeleteProjectWorkflowIntegration, + useGetWorkspaceWorkflowIntegrationConfig } from "@app/hooks/api"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; -const formSchema = z.object({ - slackIntegrationId: z.string(), - isSecretRequestNotificationEnabled: z.boolean(), - secretRequestChannels: z.string().array(), - isAccessRequestNotificationEnabled: z.boolean(), - accessRequestChannels: z.string().array() -}); +import { AddWorkflowIntegrationModal } from "./components/AddWorkflowIntegrationModal"; +import { EditWorkflowIntegrationModal } from "./components/EditWorkflowIntegrationModal"; +import { MicrosoftTeamsConfigRow } from "./components/MicrosoftTeamsConfigRow"; +import { SlackConfigRow } from "./components/SlackConfigRow"; -type TSlackConfigForm = z.infer; +export const renderProvider = (integration: WorkflowIntegrationPlatform) => { + if (integration === WorkflowIntegrationPlatform.SLACK) { + return ; + } + + if (integration === WorkflowIntegrationPlatform.MICROSOFT_TEAMS) { + return ; + } + + return null; +}; export const WorkflowIntegrationTab = () => { + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "addWorkflowIntegration", + "removeIntegration", + "editIntegration" + ] as const); + const { currentWorkspace } = useWorkspace(); - const { data: slackConfig, isPending: isSlackConfigLoading } = useGetWorkspaceSlackConfig({ - workspaceId: currentWorkspace?.id ?? "" - }); - const { data: slackIntegrations } = useGetSlackIntegrations(currentWorkspace?.orgId); - const { mutateAsync: updateProjectSlackConfig } = useUpdateProjectSlackConfig(); + const { data: slackConfig, isPending: isSlackConfigLoading } = + useGetWorkspaceWorkflowIntegrationConfig({ + workspaceId: currentWorkspace?.id ?? "", + integration: WorkflowIntegrationPlatform.SLACK + }); - const { - control, - watch, - handleSubmit, - setValue, - formState: { isDirty, isSubmitting } - } = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - isAccessRequestNotificationEnabled: false, - accessRequestChannels: [], - isSecretRequestNotificationEnabled: false, - secretRequestChannels: [] - } - }); + const { data: microsoftTeamsConfig, isPending: isMicrosoftTeamsConfigLoading } = + useGetWorkspaceWorkflowIntegrationConfig({ + workspaceId: currentWorkspace?.id ?? "", + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS + }); - const secretRequestNotifState = watch("isSecretRequestNotificationEnabled"); - const selectedSlackIntegrationId = watch("slackIntegrationId"); - const accessRequestNotifState = watch("isAccessRequestNotificationEnabled"); + const { mutateAsync: deleteIntegration } = useDeleteProjectWorkflowIntegration(); - const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId); - const slackChannelIdToName = Object.fromEntries( - (slackChannels || []).map((channel) => [channel.id, channel.name]) - ); - const sortedSlackChannels = slackChannels?.sort((a, b) => - a.name.toLowerCase().localeCompare(b.name.toLowerCase()) - ); - - const handleIntegrationSave = async (data: TSlackConfigForm) => { - if (!currentWorkspace) { + const handleRemoveIntegration = async ( + integrationType: WorkflowIntegrationPlatform, + integrationId: string + ) => { + if (!currentWorkspace.id) { return; } - await updateProjectSlackConfig({ - workspaceId: currentWorkspace.id, - ...data, - accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "), - secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ") + await deleteIntegration({ + projectId: currentWorkspace?.id ?? "", + integration: integrationType, + integrationId }); createNotification({ type: "success", - text: "Successfully updated slack integration" + text: `Successfully removed ${integrationType.replace("-", " ").replace(/\b\w/g, (char) => char.toUpperCase())} integration` }); }; - useEffect(() => { - if (slackConfig) { - setValue("slackIntegrationId", slackConfig.slackIntegrationId); - setValue( - "isSecretRequestNotificationEnabled", - slackConfig.isSecretRequestNotificationEnabled - ); - setValue( - "isAccessRequestNotificationEnabled", - slackConfig.isAccessRequestNotificationEnabled - ); - - if (slackChannels) { - setValue( - "secretRequestChannels", - slackConfig.secretRequestChannels - .split(", ") - .filter((channel) => channel in slackChannelIdToName) - ); - setValue( - "accessRequestChannels", - slackConfig.accessRequestChannels - .split(", ") - .filter((channel) => channel in slackChannelIdToName) - ); - } - } - }, [slackConfig, slackChannels]); - - if (isSlackConfigLoading) { - return ; - } - - return !slackIntegrations?.length ? ( - - -
- Create one now -
- -
- ) : ( + return (
-

Slack Integration

+

Workflow Integrations

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

- This integration allows you to send notifications to your Slack workspace in response to - events in your project. + Connect Infisical to other platforms for notification and workflow integrations.

-
-
- - {(isAllowed) => ( - ( - - - - )} - control={control} - name="slackIntegrationId" + + {!!slackConfig || !!microsoftTeamsConfig ? ( + + + + + + + + + + - )} - - - {selectedSlackIntegrationId && ( - <> -

Events

- { - return ( - - field.onChange(value)} - isChecked={field.value} - > -

Secret Approval Requests

-
-
- ); - }} + +
+
ProviderAccess Request Notifications DestinationSecret Request Notifications Destination +
+ ) : ( +
+ - {secretRequestNotifState && ( - ( - - - - slackChannelIdToName[entry]) - .join(", ")} - className="text-left" - /> - - - {sortedSlackChannels?.map((slackChannel) => { - const isChecked = value?.includes(slackChannel.id); - return ( - { - evt.preventDefault(); - onChange( - isChecked - ? value?.filter((el: string) => el !== slackChannel.id) - : [...(value || []), slackChannel.id] - ); - }} - key={`secret-requests-slack-channel-${slackChannel.id}`} - iconPos="right" - icon={isChecked && } - > - {slackChannel.name} - - ); - })} - - - - )} - /> - )} - { - return ( - - field.onChange(value)} - isChecked={field.value} - > -

Access Requests

-
-
- ); - }} - /> - {accessRequestNotifState && ( - ( - - - - slackChannelIdToName[entry]) - .join(", ")} - className="text-left" - /> - - - {sortedSlackChannels?.map((slackChannel) => { - const isChecked = value?.includes(slackChannel.id); - return ( - { - evt.preventDefault(); - onChange( - isChecked - ? value?.filter((el: string) => el !== slackChannel.id) - : [...(value || []), slackChannel.id] - ); - }} - key={`access-requests-slack-channel-${slackChannel.id}`} - iconPos="right" - icon={isChecked && } - > - {slackChannel.name} - - ); - })} - - - - )} - /> - )} - - +
)} - +
+ handlePopUpToggle("addWorkflowIntegration", state)} + /> + handlePopUpToggle("removeIntegration", isOpen)} + deleteKey="confirm" + onDeleteApproved={async () => { + await handleRemoveIntegration( + popUp.removeIntegration.data?.integration, + popUp.removeIntegration.data?.integrationId + ); + handlePopUpClose("removeIntegration"); + }} + /> + handlePopUpClose("editIntegration")} + integration={popUp.editIntegration.data?.integration} + />
); }; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/AddWorkflowIntegrationModal.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/AddWorkflowIntegrationModal.tsx new file mode 100644 index 000000000..8601c1c02 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/AddWorkflowIntegrationModal.tsx @@ -0,0 +1,153 @@ +import { useState } from "react"; +import { BsMicrosoftTeams } from "react-icons/bs"; +import { faSlack } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useGetWorkspaceWorkflowIntegrationConfig } from "@app/hooks/api"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm"; +import { SlackIntegrationForm } from "./SlackIntegrationForm"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; +}; + +enum WizardSteps { + SelectPlatform = "select-platform", + PlatformInputs = "platform-inputs" +} + +const PLATFORM_LIST = [ + { + icon: , + platform: WorkflowIntegrationPlatform.SLACK, + title: "Slack" + }, + { + icon: , + platform: WorkflowIntegrationPlatform.MICROSOFT_TEAMS, + title: "Microsoft Teams" + } +]; + +export const AddWorkflowIntegrationModal = ({ isOpen, onToggle }: Props) => { + const [wizardStep, setWizardStep] = useState(WizardSteps.SelectPlatform); + const [selectedPlatform, setSelectedPlatform] = useState(null); + + const { currentWorkspace } = useWorkspace(); + const { data: microsoftTeamsConfig } = useGetWorkspaceWorkflowIntegrationConfig({ + workspaceId: currentWorkspace?.id ?? "", + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS + }); + + const { data: slackConfig } = useGetWorkspaceWorkflowIntegrationConfig({ + workspaceId: currentWorkspace?.id ?? "", + integration: WorkflowIntegrationPlatform.SLACK + }); + + const microsoftTeamsConfigured = !!microsoftTeamsConfig; + const slackConfigured = !!slackConfig; + + const handleFormReset = (state: boolean = false) => { + onToggle(state); + setWizardStep(WizardSteps.SelectPlatform); + setSelectedPlatform(null); + }; + + return ( + handleFormReset(state)}> + char.toUpperCase()) ?? "workflow"} integration`} + className="my-4" + > + + {wizardStep === WizardSteps.SelectPlatform && ( + +
Select a workflow integration
+
+ {PLATFORM_LIST.map(({ icon, platform, title }) => { + const isConfigured = + (platform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && + microsoftTeamsConfigured) || + (platform === WorkflowIntegrationPlatform.SLACK && slackConfigured); + + return ( +
+
{ + setSelectedPlatform(platform); + setWizardStep(WizardSteps.PlatformInputs); + }} + onKeyDown={(evt) => { + if (evt.key === "Enter") { + setSelectedPlatform(platform); + setWizardStep(WizardSteps.PlatformInputs); + } + }} + > +
{icon}
+
{title}
+
+ {isConfigured && ( +
+
+
+ Already Configured +
+
+
+ )} +
+ ); + })} +
+
+ )} + + {wizardStep === WizardSteps.PlatformInputs && + selectedPlatform === WorkflowIntegrationPlatform.SLACK && ( + + handleFormReset(false)} /> + + )} + {wizardStep === WizardSteps.PlatformInputs && + selectedPlatform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && ( + + handleFormReset(false)} /> + + )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/EditWorkflowIntegrationModal.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/EditWorkflowIntegrationModal.tsx new file mode 100644 index 000000000..1cf727500 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/EditWorkflowIntegrationModal.tsx @@ -0,0 +1,33 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm"; +import { SlackIntegrationForm } from "./SlackIntegrationForm"; + +type Props = { + isOpen?: boolean; + onClose: () => void; + + integration: WorkflowIntegrationPlatform; +}; + +export const EditWorkflowIntegrationModal = ({ isOpen, onClose, integration }: Props) => { + const handleFormReset = () => { + onClose(); + }; + + return ( + + char.toUpperCase()) ?? "workflow"} integration`} + > + {integration === WorkflowIntegrationPlatform.SLACK && ( + + )} + {integration === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && ( + + )} + + + ); +}; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/MicrosoftTeamsConfigRow.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/MicrosoftTeamsConfigRow.tsx new file mode 100644 index 000000000..ef7617d52 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/MicrosoftTeamsConfigRow.tsx @@ -0,0 +1,148 @@ +/* eslint-disable no-nested-ternary */ +import { BsMicrosoftTeams } from "react-icons/bs"; +import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Spinner, + Td, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { useGetMicrosoftTeamsIntegrationTeams } from "@app/hooks/api"; +import { + ProjectWorkflowIntegrationConfig, + WorkflowIntegrationPlatform +} from "@app/hooks/api/workflowIntegrations/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + microsoftTeamsConfig?: ProjectWorkflowIntegrationConfig | null; + isMicrosoftTeamsConfigLoading: boolean; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeIntegration", "editIntegration"]>, + data?: { + integrationId?: string; + integration: WorkflowIntegrationPlatform; + } + ) => void; +}; + +export const MicrosoftTeamsConfigRow = ({ + handlePopUpOpen, + isMicrosoftTeamsConfigLoading, + microsoftTeamsConfig +}: Props) => { + const { data: microsoftTeamsChannels, isPending: isMicrosoftTeamsChannelsLoading } = + useGetMicrosoftTeamsIntegrationTeams(microsoftTeamsConfig?.integrationId); + const microsoftTeamsChannelIdToName = Object.fromEntries( + microsoftTeamsChannels?.flatMap((team) => + team.channels.map((channel) => [channel.channelId, channel.channelName]) + ) ?? [] + ); + + if (microsoftTeamsConfig?.integration !== WorkflowIntegrationPlatform.MICROSOFT_TEAMS) { + return null; + } + + const isLoadingConfig = isMicrosoftTeamsChannelsLoading || isMicrosoftTeamsConfigLoading; + + return ( + + +
+ + Microsoft Teams +
+ + + {microsoftTeamsConfig.isAccessRequestNotificationEnabled && + !isLoadingConfig && + microsoftTeamsConfig.accessRequestChannels?.channelIds?.length > 0 ? ( + + {microsoftTeamsConfig.accessRequestChannels.channelIds + .map((channel) => microsoftTeamsChannelIdToName[channel]) + .join(", ")} + + ) : isLoadingConfig ? ( + + ) : ( + Disabled + )} + + + {microsoftTeamsConfig.isSecretRequestNotificationEnabled && + !isLoadingConfig && + microsoftTeamsConfig.secretRequestChannels?.channelIds?.length > 0 ? ( + + {microsoftTeamsConfig.secretRequestChannels.channelIds + .map((channel) => microsoftTeamsChannelIdToName[channel]) + .join(", ")} + + ) : isLoadingConfig ? ( + + ) : ( + Disabled + )} + + + + + +
+ +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + + handlePopUpOpen("editIntegration", { + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS + }); + }} + > + Edit + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + + handlePopUpOpen("removeIntegration", { + integrationId: microsoftTeamsConfig.integrationId, + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS + }); + }} + > + Delete + + )} + + +
+ + + ); +}; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/MicrosoftTeamsIntegrationForm.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/MicrosoftTeamsIntegrationForm.tsx new file mode 100644 index 000000000..aab932b8d --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/MicrosoftTeamsIntegrationForm.tsx @@ -0,0 +1,556 @@ +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 { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + FormControl, + Input, + Select, + SelectItem, + Switch +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext"; +import { + useGetMicrosoftTeamsIntegrations, + useGetMicrosoftTeamsIntegrationTeams, + useGetWorkspaceWorkflowIntegrationConfig, + useUpdateProjectWorkflowIntegrationConfig +} from "@app/hooks/api"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +const formSchema = z + .object({ + microsoftTeamsIntegrationId: z.string(), + isSecretRequestNotificationEnabled: z.boolean(), + isAccessRequestNotificationEnabled: z.boolean(), + secretRequestChannels: z + .object({ + teamId: z.string(), + channelIds: z.string().array() + }) + .optional(), + accessRequestChannels: z + .object({ + teamId: z.string(), + channelIds: z.string().array() + }) + .optional() + }) + .superRefine((data, ctx) => { + if (data.isSecretRequestNotificationEnabled) { + if (!data?.secretRequestChannels?.teamId) { + ctx.addIssue({ + path: ["secretRequestChannels", "teamId"], + code: z.ZodIssueCode.custom, + message: "Team is required" + }); + } + + if (!data?.secretRequestChannels?.channelIds?.length) { + ctx.addIssue({ + path: ["secretRequestChannels", "channelIds"], + code: z.ZodIssueCode.custom, + message: "At least one channel is required" + }); + } + } + + if (data.isAccessRequestNotificationEnabled) { + if (!data?.accessRequestChannels?.teamId) { + ctx.addIssue({ + path: ["accessRequestChannels", "teamId"], + code: z.ZodIssueCode.custom, + message: "Team is required" + }); + } + + if (!data?.accessRequestChannels?.channelIds?.length) { + ctx.addIssue({ + path: ["accessRequestChannels", "channelIds"], + code: z.ZodIssueCode.custom, + message: "At least one channel is required" + }); + } + } + }); + +type TMicrosoftTeamsConfigForm = z.infer; + +type Props = { + onClose: () => void; +}; + +export const MicrosoftTeamsIntegrationForm = ({ onClose }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data: microsoftTeamsConfig } = useGetWorkspaceWorkflowIntegrationConfig({ + workspaceId: currentWorkspace?.id ?? "", + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS + }); + const { data: microsoftTeamsIntegrations } = useGetMicrosoftTeamsIntegrations( + currentWorkspace?.orgId + ); + + const { mutateAsync: updateProjectMicrosoftTeamsConfig } = + useUpdateProjectWorkflowIntegrationConfig(); + + const { + control, + watch, + handleSubmit, + setValue, + formState: { isDirty, isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + isAccessRequestNotificationEnabled: false, + isSecretRequestNotificationEnabled: false, + accessRequestChannels: { + teamId: "", + channelIds: [] + }, + secretRequestChannels: { + teamId: "", + channelIds: [] + } + } + }); + + const handleIntegrationSave = async (data: TMicrosoftTeamsConfigForm) => { + try { + if (!currentWorkspace) { + return; + } + + await updateProjectMicrosoftTeamsConfig({ + workspaceId: currentWorkspace.id, + isAccessRequestNotificationEnabled: data.isAccessRequestNotificationEnabled, + isSecretRequestNotificationEnabled: data.isSecretRequestNotificationEnabled, + ...(data.isAccessRequestNotificationEnabled && { + accessRequestChannels: data.accessRequestChannels + }), + ...(data.isSecretRequestNotificationEnabled && { + secretRequestChannels: data.secretRequestChannels + }), + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS, + integrationId: data.microsoftTeamsIntegrationId + }); + + createNotification({ + type: "success", + text: "Successfully created microsoft teams integration" + }); + + onClose(); + } catch { + createNotification({ + type: "error", + text: "Failed to create microsoft teams integration" + }); + } + }; + + const selectedAccessRequestTeamId = watch("accessRequestChannels.teamId"); + const selectedSecretRequestTeamId = watch("secretRequestChannels.teamId"); + + const selectedMicrosoftTeamsIntegrationId = watch("microsoftTeamsIntegrationId"); + + const accessRequestNotificationsEnabled = watch("isAccessRequestNotificationEnabled"); + const secretRequestNotificationsEnabled = watch("isSecretRequestNotificationEnabled"); + + const { + data: microsoftTeamsIntegrationTeams, + isPending: isLoadingMicrosoftTeamsIntegrationTeams + } = useGetMicrosoftTeamsIntegrationTeams(selectedMicrosoftTeamsIntegrationId); + + const sortedMicrosoftTeamsIntegrationTeams = microsoftTeamsIntegrationTeams?.sort((a, b) => + a.teamName.toLowerCase().localeCompare(b.teamName.toLowerCase()) + ); + + const selectableAccessRequestChannelIds = sortedMicrosoftTeamsIntegrationTeams + ?.filter((team) => team.teamId === selectedAccessRequestTeamId) + .map((team) => team.channels) + .flat(); + const selectableSecretRequestChannelIds = sortedMicrosoftTeamsIntegrationTeams + ?.filter((team) => team.teamId === selectedSecretRequestTeamId) + .map((team) => team.channels) + .flat(); + + const channelIdToName = [ + ...(selectableAccessRequestChannelIds || []), + ...(selectableSecretRequestChannelIds || []) + ].reduce( + (acc, channel) => { + acc[channel.channelId] = channel.channelName; + return acc; + }, + {} as Record + ); + + useEffect(() => { + if (microsoftTeamsConfig) { + setValue("microsoftTeamsIntegrationId", microsoftTeamsConfig.integrationId); + setValue( + "isSecretRequestNotificationEnabled", + microsoftTeamsConfig.isSecretRequestNotificationEnabled + ); + setValue( + "isAccessRequestNotificationEnabled", + microsoftTeamsConfig.isAccessRequestNotificationEnabled + ); + + if (microsoftTeamsConfig.integration === WorkflowIntegrationPlatform.MICROSOFT_TEAMS) { + if (microsoftTeamsConfig.secretRequestChannels) { + if (Object.entries(microsoftTeamsConfig.accessRequestChannels).length) { + setValue("accessRequestChannels", microsoftTeamsConfig.accessRequestChannels); + } + if (Object.entries(microsoftTeamsConfig.secretRequestChannels).length) { + setValue("secretRequestChannels", microsoftTeamsConfig.secretRequestChannels); + } + } + } + } + }, [microsoftTeamsConfig]); + + return ( +
+
+ + {(isAllowed) => ( + ( + + + + )} + /> + )} + +
+ {selectedMicrosoftTeamsIntegrationId && ( + <> +

Configure Events

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

Secret Approval Requests

+
+
+ ); + }} + /> + {secretRequestNotificationsEnabled && ( + <> + ( + + + + )} + /> + ( + + + + {selectedSecretRequestTeamId ? ( + channelIdToName[entry]) + .join(", ") + } + className="text-left" + /> + ) : ( + + )} + + + {selectableSecretRequestChannelIds?.map((channel) => { + const isChecked = value?.includes(channel.channelId); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== channel.channelId) + : [...(value || []), channel.channelId] + ); + }} + key={`secret-requests-microsoft-teams-channel-${channel.channelId}`} + iconPos="right" + icon={isChecked && } + > + {channel.channelName} + + ); + })} + + + + )} + /> + + )} + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Access Requests

+
+
+ ); + }} + /> + {accessRequestNotificationsEnabled && ( + <> + ( + + + + )} + /> + + ( + + + + {selectedAccessRequestTeamId ? ( + channelIdToName[entry]) + .join(", ") + } + className="text-left" + /> + ) : ( + + )} + + + {selectableAccessRequestChannelIds?.map((channel) => { + const isChecked = value?.includes(channel.channelId); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== channel.channelId) + : [...(value || []), channel.channelId] + ); + }} + key={`access-requests-slack-channel-${channel.channelId}`} + iconPos="right" + icon={isChecked && } + > + {channel.channelName} + + ); + })} + + + + )} + /> + + )} + + + )} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/SlackConfigRow.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/SlackConfigRow.tsx new file mode 100644 index 000000000..df7211f99 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/SlackConfigRow.tsx @@ -0,0 +1,145 @@ +/* eslint-disable no-nested-ternary */ +import { BsSlack } from "react-icons/bs"; +import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Spinner, + Td, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { useGetSlackIntegrationChannels } from "@app/hooks/api"; +import { + ProjectWorkflowIntegrationConfig, + WorkflowIntegrationPlatform +} from "@app/hooks/api/workflowIntegrations/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + slackConfig?: ProjectWorkflowIntegrationConfig | null; + isSlackConfigLoading: boolean; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeIntegration", "editIntegration"]>, + data?: { + integrationId?: string; + integration: WorkflowIntegrationPlatform; + } + ) => void; +}; + +export const SlackConfigRow = ({ handlePopUpOpen, isSlackConfigLoading, slackConfig }: Props) => { + const { data: slackChannels, isPending: isSlackChannelsLoading } = useGetSlackIntegrationChannels( + slackConfig?.integrationId + ); + const slackChannelIdToName = Object.fromEntries( + (slackChannels || []).map((channel) => [channel.id, channel.name]) + ); + + if (slackConfig?.integration !== WorkflowIntegrationPlatform.SLACK) { + return null; + } + + const isLoadingConfig = isSlackChannelsLoading || isSlackConfigLoading; + + return ( + + +
+ + Slack +
+ + + {slackConfig.isAccessRequestNotificationEnabled && + !isLoadingConfig && + slackConfig.accessRequestChannels.length > 0 ? ( + + {slackConfig.accessRequestChannels + .split(", ") + .map((channel) => slackChannelIdToName[channel]) + .join(", ")} + + ) : isLoadingConfig ? ( + + ) : ( + Disabled + )} + + + {slackConfig.isSecretRequestNotificationEnabled && + !isLoadingConfig && + slackConfig.secretRequestChannels.length > 0 ? ( + + {slackConfig.secretRequestChannels + .split(", ") + .map((channel) => slackChannelIdToName[channel]) + .join(", ")} + + ) : isLoadingConfig ? ( + + ) : ( + Disabled + )} + + + + + +
+ +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + + handlePopUpOpen("editIntegration", { + integration: WorkflowIntegrationPlatform.SLACK + }); + }} + > + Edit + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + + handlePopUpOpen("removeIntegration", { + integrationId: slackConfig.integrationId, + integration: WorkflowIntegrationPlatform.SLACK + }); + }} + > + Delete + + )} + + +
+ + + ); +}; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/SlackIntegrationForm.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/SlackIntegrationForm.tsx new file mode 100644 index 000000000..6bcaa6ac1 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/SlackIntegrationForm.tsx @@ -0,0 +1,355 @@ +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 { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + FormControl, + Input, + Select, + SelectItem, + Switch +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext"; +import { + useGetSlackIntegrationChannels, + useGetWorkflowIntegrations, + useGetWorkspaceWorkflowIntegrationConfig, + useUpdateProjectWorkflowIntegrationConfig +} from "@app/hooks/api"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +const formSchema = z.object({ + slackIntegrationId: z.string(), + isSecretRequestNotificationEnabled: z.boolean(), + secretRequestChannels: z.string().array(), + isAccessRequestNotificationEnabled: z.boolean(), + accessRequestChannels: z.string().array() +}); + +type TSlackConfigForm = z.infer; + +type Props = { + onClose: () => void; +}; + +export const SlackIntegrationForm = ({ onClose }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data: slackConfig } = useGetWorkspaceWorkflowIntegrationConfig({ + workspaceId: currentWorkspace?.id ?? "", + integration: WorkflowIntegrationPlatform.SLACK + }); + const { data: workflowIntegrations } = useGetWorkflowIntegrations(currentWorkspace?.orgId); + const { mutateAsync: updateProjectSlackConfig } = useUpdateProjectWorkflowIntegrationConfig(); + + const slackIntegrations = workflowIntegrations?.filter( + (integration) => integration.integration === WorkflowIntegrationPlatform.SLACK + ); + + const { + control, + watch, + handleSubmit, + setValue, + formState: { isDirty, isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + isAccessRequestNotificationEnabled: false, + accessRequestChannels: [], + isSecretRequestNotificationEnabled: false, + secretRequestChannels: [] + } + }); + + const handleIntegrationSave = async (data: TSlackConfigForm) => { + try { + if (!currentWorkspace) { + return; + } + + await updateProjectSlackConfig({ + ...data, + workspaceId: currentWorkspace.id, + integration: WorkflowIntegrationPlatform.SLACK, + integrationId: data.slackIntegrationId, + accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "), + secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ") + }); + + createNotification({ + type: "success", + text: "Successfully created slack integration" + }); + + onClose(); + } catch { + createNotification({ + type: "error", + text: "Failed to create slack integration" + }); + } + }; + + const secretRequestNotifState = watch("isSecretRequestNotificationEnabled"); + const selectedSlackIntegrationId = watch("slackIntegrationId"); + const accessRequestNotifState = watch("isAccessRequestNotificationEnabled"); + + const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId); + const slackChannelIdToName = Object.fromEntries( + (slackChannels || []).map((channel) => [channel.id, channel.name]) + ); + const sortedSlackChannels = slackChannels?.sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + ); + + useEffect(() => { + if (slackConfig) { + setValue("slackIntegrationId", slackConfig.integrationId); + setValue( + "isSecretRequestNotificationEnabled", + slackConfig.isSecretRequestNotificationEnabled + ); + setValue( + "isAccessRequestNotificationEnabled", + slackConfig.isAccessRequestNotificationEnabled + ); + + if (slackConfig.integration === WorkflowIntegrationPlatform.SLACK) { + if (slackChannels) { + setValue( + "secretRequestChannels", + slackConfig.secretRequestChannels + .split(", ") + .filter((channel) => channel in slackChannelIdToName) + ); + setValue( + "accessRequestChannels", + slackConfig.accessRequestChannels + .split(", ") + .filter((channel) => channel in slackChannelIdToName) + ); + } + } + } + }, [slackConfig, slackChannels]); + + return ( +
+
+ + {(isAllowed) => ( + ( + + + + )} + /> + )} + +
+ {selectedSlackIntegrationId && ( + <> +

Configure Events

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

Secret Approval Requests

+
+
+ ); + }} + /> + {secretRequestNotifState && ( + ( + + + + slackChannelIdToName[entry]) + .join(", ")} + className="text-left" + /> + + + {sortedSlackChannels?.map((slackChannel) => { + const isChecked = value?.includes(slackChannel.id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== slackChannel.id) + : [...(value || []), slackChannel.id] + ); + }} + key={`secret-requests-slack-channel-${slackChannel.id}`} + iconPos="right" + icon={isChecked && } + > + {slackChannel.name} + + ); + })} + + + + )} + /> + )} + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Access Requests

+
+
+ ); + }} + /> + {accessRequestNotifState && ( + ( + + + + slackChannelIdToName[entry]) + .join(", ")} + className="text-left" + /> + + + {sortedSlackChannels?.map((slackChannel) => { + const isChecked = value?.includes(slackChannel.id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== slackChannel.id) + : [...(value || []), slackChannel.id] + ); + }} + key={`access-requests-slack-channel-${slackChannel.id}`} + iconPos="right" + icon={isChecked && } + > + {slackChannel.name} + + ); + })} + + + + )} + /> + )} + + + )} + + ); +}; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx new file mode 100644 index 000000000..c5f13d792 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx @@ -0,0 +1,157 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + PageHeader, + Tooltip +} from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useDeleteSshHostGroup, useGetSshHostGroupById } from "@app/hooks/api"; +import { ProjectType } from "@app/hooks/api/workspace/types"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { SshHostGroupModal } from "../SshHostsPage/components/SshHostGroupModal"; +import { SshHostGroupDetailsSection, SshHostGroupHostsSection } from "./components"; + +const Page = () => { + const { currentWorkspace } = useWorkspace(); + const navigate = useNavigate(); + const projectId = currentWorkspace?.id || ""; + const sshHostGroupId = useParams({ + from: ROUTE_PATHS.Ssh.SshHostGroupDetailsByIDPage.id, + select: (el) => el.sshHostGroupId + }); + const { data } = useGetSshHostGroupById(sshHostGroupId); + + const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "sshHostGroup", + "deleteSshHostGroup" + ] as const); + + const onRemoveSshGroupSubmit = async (groupIdToDelete: string) => { + try { + if (!projectId) return; + + await deleteSshHostGroup({ sshHostGroupId: groupIdToDelete }); + + createNotification({ + text: "Successfully deleted SSH group", + type: "success" + }); + + handlePopUpClose("deleteSshHostGroup"); + navigate({ + to: `/${ProjectType.SSH}/$projectId/overview` as const, + params: { + projectId + } + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete SSH group", + type: "error" + }); + } + }; + + return ( +
+ {data && ( +
+ + + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("deleteSshHostGroup", { + groupId: data.id, + name: data.name + }) + } + disabled={!isAllowed} + > + Delete SSH Group + + )} + + +
+
+
+
+ +
+
+ +
+
+
+ )} + + handlePopUpToggle("deleteSshHostGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveSshGroupSubmit((popUp?.deleteSshHostGroup?.data as { groupId: string })?.groupId) + } + /> +
+ ); +}; + +export const SshHostGroupDetailsByIDPage = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: "SSH Group" })} + + + + + + ); +}; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/AddHostGroupMemberModal.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/AddHostGroupMemberModal.tsx new file mode 100644 index 000000000..2374492a5 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/AddHostGroupMemberModal.tsx @@ -0,0 +1,127 @@ +import { faServer } from "@fortawesome/free-solid-svg-icons"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + Modal, + ModalContent, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { useAddHostToSshHostGroup, useListSshHostGroupHosts } from "@app/hooks/api"; +import { EHostGroupMembershipFilter } from "@app/hooks/api/sshHostGroup/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["addHostGroupMembers"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["addHostGroupMembers"]>, + state?: boolean + ) => void; +}; + +export const AddHostGroupMemberModal = ({ popUp, handlePopUpToggle }: Props) => { + const popUpData = popUp?.addHostGroupMembers?.data as { + sshHostGroupId: string; + }; + + const { data, isPending } = useListSshHostGroupHosts({ + sshHostGroupId: popUpData?.sshHostGroupId, + filter: EHostGroupMembershipFilter.NON_GROUP_MEMBERS + }); + const { mutateAsync: addHostToSshHostGroup, isPending: isAddingHostToSshHostGroup } = + useAddHostToSshHostGroup(); + + const handleAddHost = async (sshHostId: string) => { + try { + if (!popUpData?.sshHostGroupId) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" + }); + return; + } + + await addHostToSshHostGroup({ + sshHostGroupId: popUpData.sshHostGroupId, + sshHostId + }); + + createNotification({ + text: "Successfully added host to the group", + type: "success" + }); + } catch { + createNotification({ + text: "Failed to add host to the group", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("addHostGroupMembers", isOpen); + }} + > + + + + + + + + + + + {isPending && } + {!isPending && + data?.hosts?.map((host) => { + return ( + + + + + + ); + })} + +
AliasHostname +
{host.alias ?? "-"}{host.hostname} + + {(isAllowed) => ( + + )} + +
+ {!isPending && !data?.hosts?.length && ( + + )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupDetailsSection.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupDetailsSection.tsx new file mode 100644 index 000000000..80f1f8d46 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupDetailsSection.tsx @@ -0,0 +1,84 @@ +import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { useGetSshHostGroupById } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + sshHostGroupId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["sshHostGroup"]>, data?: object) => void; +}; + +export const SshHostGroupDetailsSection = ({ sshHostGroupId, handlePopUpOpen }: Props) => { + const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + const { data: sshHostGroup } = useGetSshHostGroupById(sshHostGroupId); + + return sshHostGroup ? ( +
+
+

SSH Host Group Details

+ + {(isAllowed) => { + return ( + + { + e.stopPropagation(); + handlePopUpOpen("sshHostGroup", { + sshHostGroupId: sshHostGroup.id + }); + }} + > + + + + ); + }} + +
+
+
+

SSH Host Group ID

+
+

{sshHostGroup.id}

+
+ + { + navigator.clipboard.writeText(sshHostGroup.id); + setCopyTextId("Copied"); + }} + > + + + +
+
+
+
+

Name

+

{sshHostGroup.name}

+
+
+
+ ) : ( +
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx new file mode 100644 index 000000000..88a7eebae --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx @@ -0,0 +1,113 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useRemoveHostFromSshHostGroup } from "@app/hooks/api"; + +import { AddHostGroupMemberModal } from "./AddHostGroupMemberModal"; +import { SshHostGroupHostsTable } from "./SshHostGroupHostsTable"; + +type Props = { + sshHostGroupId: string; +}; + +export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => { + const { subscription } = useSubscription(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "removeHostFromSshHostGroup", + "addHostGroupMembers", + "upgradePlan" + ] as const); + + const { mutateAsync: removeHostFromGroup } = useRemoveHostFromSshHostGroup(); + + const handleAddSshHostModal = () => { + if (!subscription?.sshHostGroups) { + handlePopUpOpen("upgradePlan", { + description: + "You can manage hosts more efficiently with SSH host groups if you upgrade your Infisical plan to an Enterprise license." + }); + } else { + handlePopUpOpen("addHostGroupMembers", { + sshHostGroupId + }); + } + }; + + const onRemoveSshHostSubmit = async (sshHostId: string) => { + try { + await removeHostFromGroup({ + sshHostId, + sshHostGroupId + }); + + await createNotification({ + text: "Successfully removed host from SSH group", + type: "success" + }); + + handlePopUpClose("removeHostFromSshHostGroup"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to remove host from SSH group", + type: "error" + }); + } + }; + + return ( +
+
+

SSH Hosts

+ + {(isAllowed) => ( + handleAddSshHostModal()} + isDisabled={!isAllowed} + > + + + )} + +
+
+ +
+ + handlePopUpToggle("removeHostFromSshHostGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveSshHostSubmit( + (popUp?.removeHostFromSshHostGroup?.data as { sshHostId: string })?.sshHostId + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx new file mode 100644 index 000000000..7866d2125 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx @@ -0,0 +1,97 @@ +import { faServer, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { useListSshHostGroupHosts } from "@app/hooks/api"; +import { EHostGroupMembershipFilter } from "@app/hooks/api/sshHostGroup/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + sshHostGroupId: string; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeHostFromSshHostGroup"]>, + data?: object + ) => void; +}; + +export const SshHostGroupHostsTable = ({ sshHostGroupId, handlePopUpOpen }: Props) => { + const { data, isPending } = useListSshHostGroupHosts({ + sshHostGroupId, + filter: EHostGroupMembershipFilter.GROUP_MEMBERS + }); + + return ( +
+ + + + + + + + + + + {isPending && } + {!isPending && + data?.hosts.map((host) => { + return ( + + + + + + + ); + })} + +
AliasHostnameAdded On +
{host.alias ?? "-"}{host.hostname}{new Date(host.joinedGroupAt).toLocaleDateString()} + + {(isAllowed) => ( + + + handlePopUpOpen("removeHostFromSshHostGroup", { + sshHostId: host.id, + alias: host.alias, + hostname: host.hostname + }) + } + variant="plain" + colorSchema="danger" + > + + + + )} + +
+ {!isPending && !data?.hosts?.length && ( + + )} +
+
+ ); +}; + +export default SshHostGroupHostsTable; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/index.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/index.tsx new file mode 100644 index 000000000..ff25edf77 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/index.tsx @@ -0,0 +1,3 @@ +export { SshHostGroupDetailsSection } from "./SshHostGroupDetailsSection"; +export { SshHostGroupHostsSection } from "./SshHostGroupHostsSection"; +export { SshHostGroupHostsTable } from "./SshHostGroupHostsTable"; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/route.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/route.tsx new file mode 100644 index 000000000..2a7c1cde6 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/route.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SshHostGroupDetailsByIDPage } from "./SshHostGroupDetailsByIDPage"; + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId" +)({ + component: SshHostGroupDetailsByIDPage +}); diff --git a/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx b/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx index aca76bb73..c6efa9670 100644 --- a/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/SshHostsPage.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { PageHeader } from "@app/components/v2"; -import { SshHostsSection } from "./components"; +import { SshHostGroupsSection, SshHostsSection } from "./components"; export const SshHostsPage = () => { const { t } = useTranslation(); @@ -19,6 +19,7 @@ export const SshHostsPage = () => { title="Hosts" description="Manage your SSH hosts, configure access policies, and define login behavior for secure connections." /> +
diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx new file mode 100644 index 000000000..de5d82311 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx @@ -0,0 +1,396 @@ +import { useEffect, useState } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faChevronDown, faChevronRight, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + FormLabel, + IconButton, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useCreateSshHostGroup, + useGetSshHostGroupById, + useGetWorkspaceUsers, + useListWorkspaceSshHostGroups, + useUpdateSshHostGroup +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["sshHostGroup"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshHostGroup"]>, state?: boolean) => void; +}; + +const schema = z + .object({ + name: z.string().trim().min(1).max(64), + loginMappings: z + .object({ + loginUser: z.string().trim().min(1), + allowedPrincipals: z.array(z.string().trim()).default([]) + }) + .array() + .default([]) + }) + .required(); + +export type FormData = z.infer; + +export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + const { data: sshHostGroups } = useListWorkspaceSshHostGroups(currentWorkspace.id); + const { data: members = [] } = useGetWorkspaceUsers(projectId); + const [expandedMappings, setExpandedMappings] = useState>({}); + + const { data: sshHostGroup } = useGetSshHostGroupById( + (popUp?.sshHostGroup?.data as { sshHostGroupId: string })?.sshHostGroupId || "" + ); + + const { mutateAsync: createMutateAsync } = useCreateSshHostGroup(); + const { mutateAsync: updateMutateAsync } = useUpdateSshHostGroup(); + + const { + control, + handleSubmit, + reset, + getValues, + setValue, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + loginMappings: [] + } + }); + + const loginMappingsFormFields = useFieldArray({ + control, + name: "loginMappings" + }); + + useEffect(() => { + if (sshHostGroup) { + reset({ + name: sshHostGroup.name, + loginMappings: sshHostGroup.loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginUser, + allowedPrincipals: allowedPrincipals.usernames + })) + }); + + setExpandedMappings( + Object.fromEntries(sshHostGroup.loginMappings.map((_, index) => [index, false])) + ); + } else { + reset({ + name: "", + loginMappings: [] + }); + } + }, [sshHostGroup]); + + const onFormSubmit = async ({ name, loginMappings }: FormData) => { + try { + if (!projectId) return; + + // check if there is already a different host group with the same name + const existingNames = + sshHostGroups?.filter((h) => h.id !== sshHostGroup?.id).map((h) => h.name) || []; + + if (existingNames.includes(name.trim())) { + createNotification({ + text: "A host group with this name already exists.", + type: "error" + }); + return; + } + + if (sshHostGroup) { + await updateMutateAsync({ + sshHostGroupId: sshHostGroup.id, + name, + loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginUser, + allowedPrincipals: { + usernames: allowedPrincipals + } + })) + }); + } else { + await createMutateAsync({ + projectId, + name, + loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginUser, + allowedPrincipals: { + usernames: allowedPrincipals + } + })) + }); + } + + reset(); + handlePopUpToggle("sshHostGroup", false); + + createNotification({ + text: `Successfully ${sshHostGroup ? "updated" : "created"} SSH host group`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${sshHostGroup ? "update" : "create"} SSH host group`, + type: "error" + }); + } + }; + + const toggleMapping = (index: number) => { + setExpandedMappings((prev) => ({ + ...prev, + [index]: !prev[index] + })); + }; + + return ( + { + reset(); + handlePopUpToggle("sshHostGroup", isOpen); + }} + > + +
+ {sshHostGroup && ( + + + + )} + ( + + + + )} + /> +
+ + +
+
+ {loginMappingsFormFields.fields.map(({ id: metadataFieldId }, i) => ( +
+
+ + loginMappingsFormFields.remove(i)} + > + + +
+ + {expandedMappings[i] && ( + <> +
+ Login User + ( + + { + const newValue = e.target.value; + const loginMappings = getValues("loginMappings"); + const isDuplicate = loginMappings.some( + (mapping, index) => index !== i && mapping.loginUser === newValue + ); + + if (isDuplicate) { + createNotification({ + text: "This login user already exists", + type: "error" + }); + return; + } + + field.onChange(e); + }} + /> + + )} + /> +
+
+
+ + +
+ ( +
+ {(value.length === 0 ? [""] : value).map( + (principal: string, principalIndex: number) => ( +
+
+ +
+ { + const newPrincipals = value.filter( + (_, idx) => idx !== principalIndex + ); + onChange(newPrincipals); + }} + > + + +
+ ) + )} + {error && {error.message}} +
+ )} + /> +
+ + )} +
+ ))} +
+
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx new file mode 100644 index 000000000..70036d88a --- /dev/null +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx @@ -0,0 +1,96 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context"; +import { useDeleteSshHostGroup } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { SshHostGroupModal } from "./SshHostGroupModal"; +import { SshHostGroupsTable } from "./SshHostGroupsTable"; + +export const SshHostGroupsSection = () => { + const { subscription } = useSubscription(); + const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "sshHostGroup", + "deleteSshHostGroup", + "upgradePlan" + ] as const); + + const handleAddSshHostGroupModal = () => { + if (!subscription?.sshHostGroups) { + handlePopUpOpen("upgradePlan", { + description: + "You can manage hosts more efficiently with SSH host groups if you upgrade your Infisical plan to an Enterprise license." + }); + } else { + handlePopUpOpen("sshHostGroup"); + } + }; + + const onRemoveSshHostGroupSubmit = async (sshHostGroupId: string) => { + try { + const hostGroup = await deleteSshHostGroup({ sshHostGroupId }); + + createNotification({ + text: `Successfully deleted SSH host group: ${hostGroup.name}`, + type: "success" + }); + + handlePopUpClose("deleteSshHostGroup"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete SSH host group", + type: "error" + }); + } + }; + + return ( +
+
+

Host Groups

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteSshHostGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveSshHostGroupSubmit( + (popUp?.deleteSshHostGroup?.data as { sshHostGroupId: string })?.sshHostGroupId + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx new file mode 100644 index 000000000..194d9e90e --- /dev/null +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx @@ -0,0 +1,184 @@ +import { faEllipsis, faPencil, faServer, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useListWorkspaceSshHostGroups } from "@app/hooks/api"; +import { ProjectType } from "@app/hooks/api/workspace/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteSshHostGroup", "sshHostGroup"]>, + data?: object + ) => void; +}; + +export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => { + const navigate = useNavigate(); + const { currentWorkspace } = useWorkspace(); + const { data, isPending } = useListWorkspaceSshHostGroups(currentWorkspace?.id || ""); + return ( +
+ + + + + + + + + + + {isPending && } + {!isPending && + data && + data.length > 0 && + data.map((group) => { + return ( + + navigate({ + to: `/${ProjectType.SSH}/$projectId/ssh-host-groups/$sshHostGroupId` as const, + params: { + projectId: currentWorkspace.id, + sshHostGroupId: group.id + } + }) + } + > + + + + + + ); + })} + +
Name# Hosts in GroupLogin User - Authorized Principals Mapping +
{group.name}{group.hostCount} + {group.loginMappings.length === 0 ? ( + None + ) : ( + group.loginMappings.map(({ loginUser, allowedPrincipals }) => ( +
+
{loginUser}
+ {allowedPrincipals.usernames.map((username) => ( +
+ └─ {username} +
+ ))} +
+ )) + )} +
+ + +
+ + + +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("sshHostGroup", { + sshHostGroupId: group.id + }); + }} + disabled={!isAllowed} + icon={} + > + Edit Host Group + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + navigate({ + to: `/${ProjectType.SSH}/$projectId/ssh-host-groups/$sshHostGroupId` as const, + params: { + projectId: currentWorkspace.id, + sshHostGroupId: group.id + } + }); + }} + disabled={!isAllowed} + icon={} + > + Manage Hosts + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteSshHostGroup", { + sshHostGroupId: group.id, + name: group.name + }); + }} + disabled={!isAllowed} + icon={} + > + Delete Host Group + + )} + + +
+
+ {!isPending && data?.length === 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx index ede0a35a0..5ac53a16e 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx @@ -26,6 +26,7 @@ import { useListWorkspaceSshHosts, useUpdateSshHost } from "@app/hooks/api"; +import { LoginMappingSource } from "@app/hooks/api/sshHost/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -48,7 +49,8 @@ const schema = z loginMappings: z .object({ loginUser: z.string().trim().min(1), - allowedPrincipals: z.array(z.string().trim()).default([]) + allowedPrincipals: z.array(z.string().trim()).default([]), + source: z.nativeEnum(LoginMappingSource) }) .array() .default([]) @@ -99,9 +101,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { hostname: sshHost.hostname, alias: sshHost.alias ?? "", userCertTtl: sshHost.userCertTtl, - loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals, source }) => ({ loginUser, - allowedPrincipals: allowedPrincipals.usernames + allowedPrincipals: allowedPrincipals.usernames, + source })) }); @@ -122,6 +125,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { try { if (!projectId) return; + // Filter out login mappings that are from host groups + const hostLoginMappings = loginMappings.filter( + (mapping) => mapping.source === LoginMappingSource.HOST + ); + // check if there is already a different host with the same hostname const existingHostnames = sshHosts?.filter((h) => h.id !== sshHost?.id).map((h) => h.hostname) || []; @@ -157,7 +165,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { hostname, alias: trimmedAlias, userCertTtl, - loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginUser, allowedPrincipals: { usernames: allowedPrincipals @@ -170,7 +178,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { hostname, alias: trimmedAlias, userCertTtl, - loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginUser, allowedPrincipals: { usernames: allowedPrincipals @@ -265,7 +273,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { variant="outline_bg" onClick={() => { const newIndex = loginMappingsFormFields.fields.length; - loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] }); + loginMappingsFormFields.append({ + loginUser: "", + allowedPrincipals: [""], + source: LoginMappingSource.HOST + }); setExpandedMappings((prev) => ({ ...prev, [newIndex]: true @@ -298,6 +310,12 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { render={({ field }) => ( {field.value || "New Login Mapping"} + {loginMappingsFormFields.fields[i].source === + LoginMappingSource.HOST_GROUP && ( + + (inherited from host group) + + )} )} /> @@ -306,6 +324,9 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { ariaLabel="delete login mapping" variant="plain" onClick={() => loginMappingsFormFields.remove(i)} + isDisabled={ + loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST_GROUP + } > @@ -327,11 +348,24 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { { + if ( + loginMappingsFormFields.fields[i].source === + LoginMappingSource.HOST_GROUP + ) + return; + const newValue = e.target.value; const loginMappings = getValues("loginMappings"); const isDuplicate = loginMappings.some( - (mapping, index) => index !== i && mapping.loginUser === newValue + (mapping, index) => + index !== i && + mapping.loginUser === newValue && + mapping.source === LoginMappingSource.HOST ); if (isDuplicate) { @@ -355,17 +389,20 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { label="Allowed Principals" className="text-xs text-mineshaft-400" /> - + {loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST && ( + + )}
{