diff --git a/backend/package-lock.json b/backend/package-lock.json index b6954a862..19e10942e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -33,6 +33,7 @@ "@infisical/quic": "^1.0.8", "@node-saml/passport-saml": "^5.0.1", "@octokit/auth-app": "^7.1.1", + "@octokit/plugin-paginate-graphql": "^5.2.4", "@octokit/plugin-retry": "^5.0.5", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", @@ -92,10 +93,10 @@ "ora": "^7.0.1", "oracledb": "^6.4.0", "otplib": "^12.0.1", - "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", + "passport-oauth2": "^1.8.0", "pg": "^8.11.3", "pg-boss": "^10.1.5", "pg-query-stream": "^4.5.3", @@ -136,7 +137,6 @@ "@types/lodash.isequal": "^4.5.8", "@types/node": "^20.17.30", "@types/nodemailer": "^6.4.14", - "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.10.9", "@types/picomatch": "^2.3.3", @@ -7275,47 +7275,247 @@ } }, "node_modules/@octokit/core": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.0.2.tgz", - "integrity": "sha512-cZUy1gUvd4vttMic7C0lwPed8IYXWYp8kHIMatyhY8t8n3Cpw2ILczkV5pGMPqef7v0bLo0pOHrEHarsau2Ydg==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.5.tgz", + "integrity": "sha512-vvmsN0r7rguA+FySiCsbaTTobSftpIDIpPW81trAmsv9TGxg3YCujAxRYp/Uy8xmDgYCzzgulG62H7KYUFmeIg==", + "license": "MIT", + "peer": true, "dependencies": { - "@octokit/auth-token": "^4.0.0", - "@octokit/graphql": "^7.0.0", - "@octokit/request": "^8.0.2", - "@octokit/request-error": "^5.0.0", - "@octokit/types": "^12.0.0", - "before-after-hook": "^2.2.0", + "@octokit/auth-token": "^5.0.0", + "@octokit/graphql": "^8.2.2", + "@octokit/request": "^9.2.3", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "before-after-hook": "^3.0.2", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/auth-token": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", + "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/endpoint": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", + "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/openapi-types": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.0.0.tgz", + "integrity": "sha512-FZvktFu7HfOIJf2BScLKIEYjDsw6RKc7rBJCdvCTfKsVnx2GEB/Nbzjr29DUdb7vQhlzS/j8qDzdditP0OC6aw==", + "license": "MIT", + "peer": true + }, + "node_modules/@octokit/core/node_modules/@octokit/request": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.3.tgz", + "integrity": "sha512-Ma+pZU8PXLOEYzsWf0cn/gY+ME57Wq8f49WTXA8FMHp2Ps9djKw//xYJ1je8Hm0pR2lU9FUGeJRWOtxq6olt4w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/endpoint": "^10.1.4", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^2.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/request-error": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", + "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/types": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.0.0.tgz", + "integrity": "sha512-VVmZP0lEhbo2O1pdq63gZFiGCKkm8PPp8AUOijlwPO6hojEVjspA0MWKP7E4hbvGxzFKNqKr6p0IYtOH/Wf/zA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/openapi-types": "^25.0.0" + } + }, + "node_modules/@octokit/core/node_modules/fast-content-type-parse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", + "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@octokit/core/node_modules/universal-user-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", + "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", + "license": "ISC", + "peer": true + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", + "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" }, "engines": { "node": ">= 18" } }, - "node_modules/@octokit/endpoint": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.4.tgz", - "integrity": "sha512-DWPLtr1Kz3tv8L0UvXTDP1fNwM0S+z6EJpRcvH66orY6Eld4XBMCSYsaWp4xIm61jTWxK68BrR7ibO+vSDnZqw==", + "node_modules/@octokit/endpoint/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/endpoint/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", "dependencies": { - "@octokit/types": "^12.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" + "@octokit/openapi-types": "^24.2.0" } }, "node_modules/@octokit/graphql": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.0.2.tgz", - "integrity": "sha512-OJ2iGMtj5Tg3s6RaXH22cJcxXRi7Y3EBqbHTBRq+PQAqfaS8f/236fUrWhfSn8P4jovyzqucxme7/vWSSZBX2Q==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.2.tgz", + "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", + "license": "MIT", + "peer": true, "dependencies": { - "@octokit/request": "^8.0.1", - "@octokit/types": "^12.0.0", - "universal-user-agent": "^6.0.0" + "@octokit/request": "^9.2.3", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" }, "engines": { "node": ">= 18" } }, + "node_modules/@octokit/graphql/node_modules/@octokit/endpoint": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", + "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql/node_modules/@octokit/openapi-types": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.0.0.tgz", + "integrity": "sha512-FZvktFu7HfOIJf2BScLKIEYjDsw6RKc7rBJCdvCTfKsVnx2GEB/Nbzjr29DUdb7vQhlzS/j8qDzdditP0OC6aw==", + "license": "MIT", + "peer": true + }, + "node_modules/@octokit/graphql/node_modules/@octokit/request": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.3.tgz", + "integrity": "sha512-Ma+pZU8PXLOEYzsWf0cn/gY+ME57Wq8f49WTXA8FMHp2Ps9djKw//xYJ1je8Hm0pR2lU9FUGeJRWOtxq6olt4w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/endpoint": "^10.1.4", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^2.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql/node_modules/@octokit/request-error": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", + "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql/node_modules/@octokit/types": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.0.0.tgz", + "integrity": "sha512-VVmZP0lEhbo2O1pdq63gZFiGCKkm8PPp8AUOijlwPO6hojEVjspA0MWKP7E4hbvGxzFKNqKr6p0IYtOH/Wf/zA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/openapi-types": "^25.0.0" + } + }, + "node_modules/@octokit/graphql/node_modules/fast-content-type-parse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", + "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@octokit/graphql/node_modules/universal-user-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", + "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", + "license": "ISC", + "peer": true + }, "node_modules/@octokit/oauth-authorization-url": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-7.1.1.tgz", @@ -7410,6 +7610,18 @@ "node": ">= 18" } }, + "node_modules/@octokit/plugin-paginate-graphql": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-5.2.4.tgz", + "integrity": "sha512-pLZES1jWaOynXKHOqdnwZ5ULeVR6tVVCMm+AUbp0htdcyXDU95WbkYdU4R2ej1wKj5Tu94Mee2Ne0PjPO9cCyA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, "node_modules/@octokit/plugin-paginate-rest": { "version": "9.1.5", "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.1.5.tgz", @@ -7491,28 +7703,14 @@ "@octokit/openapi-types": "^18.0.0" } }, - "node_modules/@octokit/plugin-throttling": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-8.1.3.tgz", - "integrity": "sha512-pfyqaqpc0EXh5Cn4HX9lWYsZ4gGbjnSmUILeu4u2gnuM50K/wIk9s1Pxt3lVeVwekmITgN/nJdoh43Ka+vye8A==", - "dependencies": { - "@octokit/types": "^12.2.0", - "bottleneck": "^2.15.3" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": "^5.0.0" - } - }, "node_modules/@octokit/request": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.0.tgz", - "integrity": "sha512-9Bb014e+m2TgBeEJGEbdplMVWwPmL1FPtggHQRkV+WVsMggPtEkLKPlcVYm/o8xKLkpJ7B+6N8WfQMtDLX2Dpw==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", + "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", + "license": "MIT", "dependencies": { - "@octokit/endpoint": "^9.0.1", - "@octokit/request-error": "^5.1.0", + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" }, @@ -7521,9 +7719,10 @@ } }, "node_modules/@octokit/request-error": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.0.tgz", - "integrity": "sha512-GETXfE05J0+7H2STzekpKObFe765O5dlAKUTLNGeH+x47z7JjXHfsHKo5z21D/o/IOZTUEI6nyWyR+bZVP/n5Q==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", + "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", + "license": "MIT", "dependencies": { "@octokit/types": "^13.1.0", "deprecation": "^2.0.0", @@ -7573,6 +7772,59 @@ "node": ">= 18" } }, + "node_modules/@octokit/rest/node_modules/@octokit/core": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz", + "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/rest/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/rest/node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "license": "Apache-2.0" + }, "node_modules/@octokit/types": { "version": "12.4.0", "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.4.0.tgz", @@ -9902,17 +10154,6 @@ "@types/express": "*" } }, - "node_modules/@types/passport-github": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@types/passport-github/-/passport-github-1.1.12.tgz", - "integrity": "sha512-VJpMEIH+cOoXB694QgcxuvWy2wPd1Oq3gqrg2Y9DMVBYs9TmH9L14qnqPDZsNMZKBDH+SvqRsGZj9SgHYeDgcA==", - "dev": true, - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-oauth2": "*" - } - }, "node_modules/@types/passport-google-oauth20": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.14.tgz", @@ -11700,9 +11941,11 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" }, "node_modules/before-after-hook": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", - "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", + "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", + "license": "Apache-2.0", + "peer": true }, "node_modules/big-integer": { "version": "1.6.52", @@ -18631,9 +18874,10 @@ "integrity": "sha512-p1TRH/edngVEHVbwqWnxUViEmq5znDvyB+Sik5cmuLpGOIfDf/39zLiq3swPF8Vakqn+gvNiOQAZu8djYlQILA==" }, "node_modules/oauth": { - "version": "0.9.15", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", - "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", @@ -19339,17 +19583,6 @@ "url": "https://github.com/sponsors/jaredhanson" } }, - "node_modules/passport-github": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/passport-github/-/passport-github-1.1.0.tgz", - "integrity": "sha512-XARXJycE6fFh/dxF+Uut8OjlwbFEXgbPVj/+V+K7cvriRK7VcAOm+NgBmbiLM9Qv3SSxEAV+V6fIk89nYHXa8A==", - "dependencies": { - "passport-oauth2": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/passport-gitlab2": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/passport-gitlab2/-/passport-gitlab2-5.0.0.tgz", @@ -19385,12 +19618,13 @@ } }, "node_modules/passport-oauth2": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", - "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", "dependencies": { "base64url": "3.x.x", - "oauth": "0.9.x", + "oauth": "0.10.x", "passport-strategy": "1.x.x", "uid2": "0.0.x", "utils-merge": "1.x.x" @@ -20179,6 +20413,62 @@ "node": ">=18" } }, + "node_modules/probot/node_modules/@octokit/core": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz", + "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/probot/node_modules/@octokit/core/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/probot/node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/probot/node_modules/@octokit/graphql/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/probot/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, "node_modules/probot/node_modules/@octokit/plugin-retry": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-6.0.1.tgz", @@ -20195,6 +20485,28 @@ "@octokit/core": ">=5" } }, + "node_modules/probot/node_modules/@octokit/plugin-throttling": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-8.2.0.tgz", + "integrity": "sha512-nOpWtLayKFpgqmgD0y3GqXafMFuKcA4tRPZIfu7BArd2lEZeb1988nhWhwx4aZWmjDmUfdgVf7W+Tt4AmvRmMQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^12.2.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5.0.0" + } + }, + "node_modules/probot/node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "license": "Apache-2.0" + }, "node_modules/probot/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 0e01ad129..58bc6a32d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -91,7 +91,6 @@ "@types/lodash.isequal": "^4.5.8", "@types/node": "^20.17.30", "@types/nodemailer": "^6.4.14", - "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.10.9", "@types/picomatch": "^2.3.3", @@ -150,6 +149,7 @@ "@infisical/quic": "^1.0.8", "@node-saml/passport-saml": "^5.0.1", "@octokit/auth-app": "^7.1.1", + "@octokit/plugin-paginate-graphql": "^5.2.4", "@octokit/plugin-retry": "^5.0.5", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", @@ -209,10 +209,10 @@ "ora": "^7.0.1", "oracledb": "^6.4.0", "otplib": "^12.0.1", - "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", + "passport-oauth2": "^1.8.0", "pg": "^8.11.3", "pg-boss": "^10.1.5", "pg-query-stream": "^4.5.3", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 296753915..92c22874e 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -5,6 +5,7 @@ import { Redis } from "ioredis"; import { TUsers } from "@app/db/schemas"; import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; +import { TAssumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-service"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; @@ -14,6 +15,7 @@ import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dy import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; import { TExternalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGithubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TIdentityProjectAdditionalPrivilegeV2ServiceFactory } from "@app/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service"; @@ -110,12 +112,14 @@ declare module "@fastify/request-context" { }; }; identityPermissionMetadata?: Record; // filled by permission service + assumedPrivilegeDetails?: { requesterId: string; actorId: string; actorType: ActorType; projectId: string }; } } declare module "fastify" { interface Session { callbackPort: string; + isAdminLogin: boolean; } interface FastifyRequest { @@ -139,6 +143,7 @@ declare module "fastify" { passportUser: { isUserCompleted: boolean; providerAuthToken: string; + externalProviderAccessToken?: string; }; kmipUser: { projectId: string; @@ -243,6 +248,8 @@ declare module "fastify" { gateway: TGatewayServiceFactory; secretRotationV2: TSecretRotationV2ServiceFactory; microsoftTeams: TMicrosoftTeamsServiceFactory; + assumePrivileges: TAssumePrivilegeServiceFactory; + githubOrgSync: TGithubOrgSyncServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index c52dd4725..7ac57afb2 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -83,6 +83,9 @@ import { TGitAppOrg, TGitAppOrgInsert, TGitAppOrgUpdate, + TGithubOrgSyncConfigs, + TGithubOrgSyncConfigsInsert, + TGithubOrgSyncConfigsUpdate, TGroupProjectMembershipRoles, TGroupProjectMembershipRolesInsert, TGroupProjectMembershipRolesUpdate, @@ -433,6 +436,11 @@ import { TProjectMicrosoftTeamsConfigsInsert, TProjectMicrosoftTeamsConfigsUpdate } from "@app/db/schemas/project-microsoft-teams-configs"; +import { + TSecretReminderRecipients, + TSecretReminderRecipientsInsert, + TSecretReminderRecipientsUpdate +} from "@app/db/schemas/secret-reminder-recipients"; declare module "knex" { namespace Knex { @@ -1014,5 +1022,15 @@ declare module "knex/types/tables" { TProjectMicrosoftTeamsConfigsInsert, TProjectMicrosoftTeamsConfigsUpdate >; + [TableName.SecretReminderRecipients]: KnexOriginal.CompositeTableType< + TSecretReminderRecipients, + TSecretReminderRecipientsInsert, + TSecretReminderRecipientsUpdate + >; + [TableName.GithubOrgSyncConfig]: KnexOriginal.CompositeTableType< + TGithubOrgSyncConfigs, + TGithubOrgSyncConfigsInsert, + TGithubOrgSyncConfigsUpdate + >; } } diff --git a/backend/src/db/migrations/20250419004044_secret-reminder-recipients.ts b/backend/src/db/migrations/20250419004044_secret-reminder-recipients.ts new file mode 100644 index 000000000..8bf5af5c7 --- /dev/null +++ b/backend/src/db/migrations/20250419004044_secret-reminder-recipients.ts @@ -0,0 +1,34 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasSecretReminderRecipientsTable = await knex.schema.hasTable(TableName.SecretReminderRecipients); + + if (!hasSecretReminderRecipientsTable) { + await knex.schema.createTable(TableName.SecretReminderRecipients, (table) => { + table.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + table.timestamps(true, true, true); + table.uuid("secretId").notNullable(); + table.uuid("userId").notNullable(); + table.string("projectId").notNullable(); + + // Based on userId rather than project membership ID so we can easily extend group support in the future if need be. + // This does however mean we need to manually clean up once a user is removed from a project. + table.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + table.foreign("secretId").references("id").inTable(TableName.SecretV2).onDelete("CASCADE"); + table.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + + table.index("secretId"); + table.unique(["secretId", "userId"]); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasSecretReminderRecipientsTable = await knex.schema.hasTable(TableName.SecretReminderRecipients); + + if (hasSecretReminderRecipientsTable) { + await knex.schema.dropTableIfExists(TableName.SecretReminderRecipients); + } +} diff --git a/backend/src/db/migrations/20250426044605_ssh-host-alias.ts b/backend/src/db/migrations/20250426044605_ssh-host-alias.ts new file mode 100644 index 000000000..a6b1f1e4d --- /dev/null +++ b/backend/src/db/migrations/20250426044605_ssh-host-alias.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasAliasColumn = await knex.schema.hasColumn(TableName.SshHost, "alias"); + if (!hasAliasColumn) { + await knex.schema.alterTable(TableName.SshHost, (t) => { + t.string("alias").nullable(); + t.unique(["projectId", "alias"]); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasAliasColumn = await knex.schema.hasColumn(TableName.SshHost, "alias"); + if (hasAliasColumn) { + await knex.schema.alterTable(TableName.SshHost, (t) => { + t.dropUnique(["projectId", "alias"]); + t.dropColumn("alias"); + }); + } +} diff --git a/backend/src/db/migrations/20250426075943_github-org-sync-config.ts b/backend/src/db/migrations/20250426075943_github-org-sync-config.ts new file mode 100644 index 000000000..9b0c936b3 --- /dev/null +++ b/backend/src/db/migrations/20250426075943_github-org-sync-config.ts @@ -0,0 +1,26 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const hasTable = await knex.schema.hasTable(TableName.GithubOrgSyncConfig); + if (!hasTable) { + await knex.schema.createTable(TableName.GithubOrgSyncConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("githubOrgName").notNullable(); + t.boolean("isActive").defaultTo(false); + t.binary("encryptedGithubOrgAccessToken"); + t.uuid("orgId").notNullable().unique(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.GithubOrgSyncConfig); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.GithubOrgSyncConfig); + await dropOnUpdateTrigger(knex, TableName.GithubOrgSyncConfig); +} diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index bde35002f..533f9b898 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -20,7 +20,7 @@ export const CertificatesSchema = z.object({ notAfter: z.date(), revokedAt: z.date().nullable().optional(), revocationReason: z.number().nullable().optional(), - altNames: z.string().default("").nullable().optional(), + altNames: z.string().nullable().optional(), caCertId: z.string().uuid(), certificateTemplateId: z.string().uuid().nullable().optional(), keyUsages: z.string().array().nullable().optional(), diff --git a/backend/src/db/schemas/github-org-sync-configs.ts b/backend/src/db/schemas/github-org-sync-configs.ts new file mode 100644 index 000000000..9e57b8a30 --- /dev/null +++ b/backend/src/db/schemas/github-org-sync-configs.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const GithubOrgSyncConfigsSchema = z.object({ + id: z.string().uuid(), + githubOrgName: z.string(), + isActive: z.boolean().default(false).nullable().optional(), + encryptedGithubOrgAccessToken: zodBuffer.nullable().optional(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TGithubOrgSyncConfigs = z.infer; +export type TGithubOrgSyncConfigsInsert = Omit, TImmutableDBKeys>; +export type TGithubOrgSyncConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index d5f69e2fa..a69a6463c 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -25,6 +25,7 @@ export * from "./external-kms"; export * from "./gateways"; export * from "./git-app-install-sessions"; export * from "./git-app-org"; +export * from "./github-org-sync-configs"; export * from "./group-project-membership-roles"; export * from "./group-project-memberships"; export * from "./groups"; diff --git a/backend/src/db/schemas/kmip-org-server-certificates.ts b/backend/src/db/schemas/kmip-org-server-certificates.ts index 66e5dcbd6..c23da626b 100644 --- a/backend/src/db/schemas/kmip-org-server-certificates.ts +++ b/backend/src/db/schemas/kmip-org-server-certificates.ts @@ -13,7 +13,7 @@ export const KmipOrgServerCertificatesSchema = z.object({ id: z.string().uuid(), orgId: z.string().uuid(), commonName: z.string(), - altNames: z.string(), + altNames: z.string().nullable().optional(), serialNumber: z.string(), keyAlgorithm: z.string(), issuedAt: z.date(), diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index b0992f368..e5af10cd6 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -148,7 +148,9 @@ export enum TableName { SecretRotationV2 = "secret_rotations_v2", SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings", MicrosoftTeamsIntegrations = "microsoft_teams_integrations", - ProjectMicrosoftTeamsConfigs = "project_microsoft_teams_configs" + ProjectMicrosoftTeamsConfigs = "project_microsoft_teams_configs", + SecretReminderRecipients = "secret_reminder_recipients", + GithubOrgSyncConfig = "github_org_sync_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts index 633d7f8c7..216b50847 100644 --- a/backend/src/db/schemas/oidc-configs.ts +++ b/backend/src/db/schemas/oidc-configs.ts @@ -30,6 +30,7 @@ export const OidcConfigsSchema = z.object({ updatedAt: z.date(), orgId: z.string().uuid(), lastUsed: z.date().nullable().optional(), + manageGroupMemberships: z.boolean().default(false), encryptedOidcClientId: zodBuffer, encryptedOidcClientSecret: zodBuffer, jwtSignatureAlgorithm: z.string().default("RS256") diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index eea1808e0..902c564a7 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,6 +23,7 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), + secretShareSendToAnyone: z.boolean().default(true).nullable().optional(), allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), diff --git a/backend/src/db/schemas/ssh-hosts.ts b/backend/src/db/schemas/ssh-hosts.ts index 7577e065b..54b36a6bd 100644 --- a/backend/src/db/schemas/ssh-hosts.ts +++ b/backend/src/db/schemas/ssh-hosts.ts @@ -16,7 +16,8 @@ export const SshHostsSchema = z.object({ userCertTtl: z.string(), hostCertTtl: z.string(), userSshCaId: z.string().uuid(), - hostSshCaId: z.string().uuid() + hostSshCaId: z.string().uuid(), + alias: z.string().nullable().optional() }); export type TSshHosts = z.infer; diff --git a/backend/src/ee/routes/v1/assume-privilege-router.ts b/backend/src/ee/routes/v1/assume-privilege-router.ts new file mode 100644 index 000000000..5ee5723fd --- /dev/null +++ b/backend/src/ee/routes/v1/assume-privilege-router.ts @@ -0,0 +1,124 @@ +import { requestContext } from "@fastify/request-context"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; + +export const registerAssumePrivilegeRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectId/assume-privileges", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string() + }), + body: z.object({ + actorType: z.enum([ActorType.USER, ActorType.IDENTITY]), + actorId: z.string() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req, res) => { + if (req.auth.authMode === AuthMode.JWT) { + const payload = await server.services.assumePrivileges.assumeProjectPrivileges({ + targetActorType: req.body.actorType, + targetActorId: req.body.actorId, + projectId: req.params.projectId, + actorPermissionDetails: req.permission, + tokenVersionId: req.auth.tokenVersionId + }); + + const appCfg = getConfig(); + void res.setCookie("infisical-project-assume-privileges", payload.assumePrivilegesToken, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED, + maxAge: 3600 // 1 hour in seconds + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.PROJECT_ASSUME_PRIVILEGE_SESSION_START, + metadata: { + projectId: req.params.projectId, + requesterEmail: req.auth.user.username, + requesterId: req.auth.user.id, + targetActorType: req.body.actorType, + targetActorId: req.body.actorId, + duration: "1hr" + } + } + }); + + return { message: "Successfully assumed role" }; + } + + throw new BadRequestError({ message: "Invalid auth mode" }); + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/assume-privileges", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req, res) => { + const assumedPrivilegeDetails = requestContext.get("assumedPrivilegeDetails"); + if (req.auth.authMode === AuthMode.JWT && assumedPrivilegeDetails) { + const appCfg = getConfig(); + void res.setCookie("infisical-project-assume-privileges", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED, + expires: new Date(0) + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.PROJECT_ASSUME_PRIVILEGE_SESSION_END, + metadata: { + projectId: req.params.projectId, + requesterEmail: req.auth.user.username, + requesterId: req.auth.user.id, + targetActorId: assumedPrivilegeDetails.actorId, + targetActorType: assumedPrivilegeDetails.actorType + } + } + }); + return { message: "Successfully exited assumed role" }; + } + + throw new BadRequestError({ message: "Invalid auth mode" }); + } + }); +}; diff --git a/backend/src/ee/routes/v1/github-org-sync-router.ts b/backend/src/ee/routes/v1/github-org-sync-router.ts new file mode 100644 index 000000000..3f33a5d8f --- /dev/null +++ b/backend/src/ee/routes/v1/github-org-sync-router.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +import { GithubOrgSyncConfigsSchema } from "@app/db/schemas"; +import { CharacterType, zodValidateCharacters } from "@app/lib/validator/validate-string"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const SanitizedGithubOrgSyncSchema = GithubOrgSyncConfigsSchema.pick({ + isActive: true, + id: true, + createdAt: true, + updatedAt: true, + orgId: true, + githubOrgName: true +}); + +const githubOrgNameValidator = zodValidateCharacters([CharacterType.AlphaNumeric, CharacterType.Hyphen]); +export const registerGithubOrgSyncRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z.object({ + githubOrgName: githubOrgNameValidator(z.string().trim(), "GitHub Org Name"), + githubOrgAccessToken: z.string().trim().max(1000).optional(), + isActive: z.boolean().default(false) + }), + response: { + 200: z.object({ + githubOrgSyncConfig: SanitizedGithubOrgSyncSchema + }) + } + }, + handler: async (req) => { + const githubOrgSyncConfig = await server.services.githubOrgSync.createGithubOrgSync({ + orgPermission: req.permission, + githubOrgName: req.body.githubOrgName, + githubOrgAccessToken: req.body.githubOrgAccessToken, + isActive: req.body.isActive + }); + + return { githubOrgSyncConfig }; + } + }); + + server.route({ + url: "/", + method: "PATCH", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z + .object({ + githubOrgName: githubOrgNameValidator(z.string().trim(), "GitHub Org Name"), + githubOrgAccessToken: z.string().trim().max(1000), + isActive: z.boolean() + }) + .partial(), + response: { + 200: z.object({ + githubOrgSyncConfig: SanitizedGithubOrgSyncSchema + }) + } + }, + handler: async (req) => { + const githubOrgSyncConfig = await server.services.githubOrgSync.updateGithubOrgSync({ + orgPermission: req.permission, + githubOrgName: req.body.githubOrgName, + githubOrgAccessToken: req.body.githubOrgAccessToken, + isActive: req.body.isActive + }); + + return { githubOrgSyncConfig }; + } + }); + + server.route({ + url: "/", + method: "DELETE", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + response: { + 200: z.object({ + githubOrgSyncConfig: SanitizedGithubOrgSyncSchema + }) + } + }, + handler: async (req) => { + const githubOrgSyncConfig = await server.services.githubOrgSync.deleteGithubOrgSync({ + orgPermission: req.permission + }); + + return { githubOrgSyncConfig }; + } + }); + + server.route({ + url: "/", + method: "GET", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + response: { + 200: z.object({ + githubOrgSyncConfig: SanitizedGithubOrgSyncSchema + }) + } + }, + handler: async (req) => { + const githubOrgSyncConfig = await server.services.githubOrgSync.getGithubOrgSync({ + orgPermission: req.permission + }); + + return { githubOrgSyncConfig }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 2bf85e9c4..a88ebf258 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -2,12 +2,14 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-templat import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; +import { registerAssumePrivilegeRouter } from "./assume-privilege-router"; import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerExternalKmsRouter } from "./external-kms-router"; import { registerGatewayRouter } from "./gateway-router"; +import { registerGithubOrgSyncRouter } from "./github-org-sync-router"; import { registerGroupRouter } from "./group-router"; import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerKmipRouter } from "./kmip-router"; @@ -45,6 +47,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await projectRouter.register(registerProjectRoleRouter); await projectRouter.register(registerProjectRouter); await projectRouter.register(registerTrustedIpRouter); + await projectRouter.register(registerAssumePrivilegeRouter); }, { prefix: "/workspace" } ); @@ -70,6 +73,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { ); await server.register(registerGatewayRouter, { prefix: "/gateways" }); + await server.register(registerGithubOrgSyncRouter, { prefix: "/github-org-sync-config" }); await server.register( async (pkiRouter) => { diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index 469460491..949d4cf7e 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -1,7 +1,7 @@ import { packRules } from "@casl/ability/extra"; import { z } from "zod"; -import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; import { backfillPermissionV1SchemaToV2Schema, ProjectPermissionV1Schema @@ -245,13 +245,22 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ data: z.object({ - membership: ProjectMembershipsSchema.extend({ + membership: z.object({ + id: z.string(), roles: z .object({ role: z.string() }) .array() }), + assumedPrivilegeDetails: z + .object({ + actorId: z.string(), + actorType: z.string(), + actorName: z.string(), + actorEmail: z.string().optional() + }) + .optional(), permissions: z.any().array() }) }) @@ -259,14 +268,20 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const { permissions, membership } = await server.services.projectRole.getUserPermission( + const { permissions, membership, assumedPrivilegeDetails } = await server.services.projectRole.getUserPermission( req.permission.id, req.params.projectId, req.permission.authMethod, req.permission.orgId ); - return { data: { permissions, membership } }; + return { + data: { + permissions, + membership, + assumedPrivilegeDetails + } + }; } }); }; diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts index 1dab5dd2f..9db642d4d 100644 --- a/backend/src/ee/routes/v1/ssh-host-router.ts +++ b/backend/src/ee/routes/v1/ssh-host-router.ts @@ -7,6 +7,7 @@ import { isValidHostname } from "@app/ee/services/ssh-host/ssh-host-validators"; import { 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"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -96,10 +97,12 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { hostname: z .string() .min(1) + .trim() .refine((v) => isValidHostname(v), { message: "Hostname must be a valid hostname" }) .describe(SSH_HOSTS.CREATE.hostname), + alias: slugSchema({ min: 0, max: 64, field: "alias" }).describe(SSH_HOSTS.CREATE.alias).default(""), userCertTtl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") @@ -138,6 +141,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { metadata: { sshHostId: host.id, hostname: host.hostname, + alias: host.alias ?? null, userCertTtl: host.userCertTtl, hostCertTtl: host.hostCertTtl, loginMappings: host.loginMappings, @@ -166,12 +170,14 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { body: z.object({ hostname: z .string() + .trim() .min(1) .refine((v) => isValidHostname(v), { message: "Hostname must be a valid hostname" }) .optional() .describe(SSH_HOSTS.UPDATE.hostname), + alias: slugSchema({ min: 0, max: 64, field: "alias" }).describe(SSH_HOSTS.UPDATE.alias).optional(), userCertTtl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") @@ -208,6 +214,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { metadata: { sshHostId: host.id, hostname: host.hostname, + alias: host.alias, userCertTtl: host.userCertTtl, hostCertTtl: host.hostCertTtl, loginMappings: host.loginMappings, diff --git a/backend/src/ee/services/assume-privilege/assume-privilege-service.ts b/backend/src/ee/services/assume-privilege/assume-privilege-service.ts new file mode 100644 index 000000000..709ce44b6 --- /dev/null +++ b/backend/src/ee/services/assume-privilege/assume-privilege-service.ts @@ -0,0 +1,101 @@ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { ActionProjectType } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { + ProjectPermissionIdentityActions, + ProjectPermissionMemberActions, + ProjectPermissionSub +} from "../permission/project-permission"; +import { TAssumeProjectPrivilegeDTO } from "./assume-privilege-types"; + +type TAssumePrivilegeServiceFactoryDep = { + projectDAL: Pick; + permissionService: Pick; +}; + +export type TAssumePrivilegeServiceFactory = ReturnType; + +export const assumePrivilegeServiceFactory = ({ projectDAL, permissionService }: TAssumePrivilegeServiceFactoryDep) => { + const assumeProjectPrivileges = async ({ + targetActorType, + targetActorId, + projectId, + actorPermissionDetails, + tokenVersionId + }: TAssumeProjectPrivilegeDTO) => { + const project = await projectDAL.findById(projectId); + if (!project) throw new NotFoundError({ message: `Project with ID '${projectId}' not found` }); + const { permission } = await permissionService.getProjectPermission({ + actor: actorPermissionDetails.type, + actorId: actorPermissionDetails.id, + projectId, + actorAuthMethod: actorPermissionDetails.authMethod, + actorOrgId: actorPermissionDetails.orgId, + actionProjectType: ActionProjectType.Any + }); + + if (targetActorType === ActorType.USER) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionMemberActions.AssumePrivileges, + ProjectPermissionSub.Member + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.AssumePrivileges, + ProjectPermissionSub.Identity + ); + } + + // check entity is part of project + await permissionService.getProjectPermission({ + actor: targetActorType, + actorId: targetActorId, + projectId, + actorAuthMethod: actorPermissionDetails.authMethod, + actorOrgId: actorPermissionDetails.orgId, + actionProjectType: ActionProjectType.Any + }); + + const appCfg = getConfig(); + const assumePrivilegesToken = jwt.sign( + { + tokenVersionId, + actorType: targetActorType, + actorId: targetActorId, + projectId, + requesterId: actorPermissionDetails.id + }, + appCfg.AUTH_SECRET, + { expiresIn: "1hr" } + ); + + return { actorType: targetActorType, actorId: targetActorId, projectId, assumePrivilegesToken }; + }; + + const verifyAssumePrivilegeToken = (token: string, tokenVersionId: string) => { + const appCfg = getConfig(); + const decodedToken = jwt.verify(token, appCfg.AUTH_SECRET) as { + tokenVersionId: string; + projectId: string; + requesterId: string; + actorType: ActorType; + actorId: string; + }; + if (decodedToken.tokenVersionId !== tokenVersionId) { + throw new ForbiddenRequestError({ message: "Invalid token version" }); + } + return decodedToken; + }; + + return { + assumeProjectPrivileges, + verifyAssumePrivilegeToken + }; +}; diff --git a/backend/src/ee/services/assume-privilege/assume-privilege-types.ts b/backend/src/ee/services/assume-privilege/assume-privilege-types.ts new file mode 100644 index 000000000..55b6c8449 --- /dev/null +++ b/backend/src/ee/services/assume-privilege/assume-privilege-types.ts @@ -0,0 +1,10 @@ +import { OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; + +export type TAssumeProjectPrivilegeDTO = { + targetActorType: ActorType.USER | ActorType.IDENTITY; + targetActorId: string; + projectId: string; + tokenVersionId: string; + actorPermissionDetails: OrgServiceActor; +}; 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 fe3408e8a..9142212a4 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -332,7 +332,10 @@ export enum EventType { 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" + 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" } export const filterableSecretEvents: EventType[] = [ @@ -1506,6 +1509,7 @@ interface CreateSshHost { metadata: { sshHostId: string; hostname: string; + alias: string | null; userCertTtl: string; hostCertTtl: string; loginMappings: { @@ -1524,6 +1528,7 @@ interface UpdateSshHost { metadata: { sshHostId: string; hostname?: string; + alias?: string | null; userCertTtl?: string; hostCertTtl?: string; loginMappings?: { @@ -2466,6 +2471,29 @@ interface ProjectAccessRequestEvent { }; } +interface ProjectAssumePrivilegesEvent { + type: EventType.PROJECT_ASSUME_PRIVILEGE_SESSION_START; + metadata: { + projectId: string; + requesterId: string; + requesterEmail: string; + targetActorType: ActorType; + targetActorId: string; + duration: string; + }; +} + +interface ProjectAssumePrivilegesExitEvent { + type: EventType.PROJECT_ASSUME_PRIVILEGE_SESSION_END; + metadata: { + projectId: string; + requesterId: string; + requesterEmail: string; + targetActorType: ActorType; + targetActorId: string; + }; +} + interface SetupKmipEvent { type: EventType.SETUP_KMIP; metadata: { @@ -2831,6 +2859,8 @@ export type Event = | KmipOperationLocateEvent | KmipOperationRegisterEvent | ProjectAccessRequestEvent + | ProjectAssumePrivilegesEvent + | ProjectAssumePrivilegesExitEvent | CreateSecretRequestEvent | SecretApprovalRequestReview | GetSecretRotationsEvent diff --git a/backend/src/ee/services/external-kms/external-kms-service.ts b/backend/src/ee/services/external-kms/external-kms-service.ts index 49ac293ed..4d7b1a5b5 100644 --- a/backend/src/ee/services/external-kms/external-kms-service.ts +++ b/backend/src/ee/services/external-kms/external-kms-service.ts @@ -83,18 +83,26 @@ export const externalKmsServiceFactory = ({ throw error; }); - // if missing kms key this generate a new kms key id and returns new provider input - const newProviderInput = await externalKms.generateInputKmsKey(); - sanitizedProviderInput = JSON.stringify(newProviderInput); + try { + // if missing kms key this generate a new kms key id and returns new provider input + const newProviderInput = await externalKms.generateInputKmsKey(); + sanitizedProviderInput = JSON.stringify(newProviderInput); - await externalKms.validateConnection(); + await externalKms.validateConnection(); + } finally { + await externalKms.cleanup(); + } } break; case KmsProviders.Gcp: { const externalKms = await GcpKmsProviderFactory({ inputs: provider.inputs }); - await externalKms.validateConnection(); - sanitizedProviderInput = JSON.stringify(provider.inputs); + try { + await externalKms.validateConnection(); + sanitizedProviderInput = JSON.stringify(provider.inputs); + } finally { + await externalKms.cleanup(); + } } break; default: @@ -186,8 +194,12 @@ export const externalKmsServiceFactory = ({ ); const updatedProviderInput = { ...decryptedProviderInput, ...provider.inputs }; const externalKms = await AwsKmsProviderFactory({ inputs: updatedProviderInput }); - await externalKms.validateConnection(); - sanitizedProviderInput = JSON.stringify(updatedProviderInput); + try { + await externalKms.validateConnection(); + sanitizedProviderInput = JSON.stringify(updatedProviderInput); + } finally { + await externalKms.cleanup(); + } } break; case KmsProviders.Gcp: @@ -197,8 +209,12 @@ export const externalKmsServiceFactory = ({ ); const updatedProviderInput = { ...decryptedProviderInput, ...provider.inputs }; const externalKms = await GcpKmsProviderFactory({ inputs: updatedProviderInput }); - await externalKms.validateConnection(); - sanitizedProviderInput = JSON.stringify(updatedProviderInput); + try { + await externalKms.validateConnection(); + sanitizedProviderInput = JSON.stringify(updatedProviderInput); + } finally { + await externalKms.cleanup(); + } } break; default: @@ -368,7 +384,11 @@ export const externalKmsServiceFactory = ({ const fetchGcpKeys = async ({ credential, gcpRegion }: Pick) => { const externalKms = await GcpKmsProviderFactory({ inputs: { credential, gcpRegion, keyName: "" } }); - return externalKms.getKeysList(); + try { + return await externalKms.getKeysList(); + } finally { + await externalKms.cleanup(); + } }; return { diff --git a/backend/src/ee/services/external-kms/providers/aws-kms.ts b/backend/src/ee/services/external-kms/providers/aws-kms.ts index 6d9166a3a..2bda9c75e 100644 --- a/backend/src/ee/services/external-kms/providers/aws-kms.ts +++ b/backend/src/ee/services/external-kms/providers/aws-kms.ts @@ -102,10 +102,19 @@ export const AwsKmsProviderFactory = async ({ inputs }: AwsKmsProviderArgs): Pro return { data: Buffer.from(decryptionCommand.Plaintext) }; }; + const cleanup = async () => { + try { + awsClient.destroy(); + } catch (error) { + throw new Error("Failed to cleanup AWS KMS client", { cause: error }); + } + }; + return { generateInputKmsKey, validateConnection, encrypt, - decrypt + decrypt, + cleanup }; }; diff --git a/backend/src/ee/services/external-kms/providers/gcp-kms.ts b/backend/src/ee/services/external-kms/providers/gcp-kms.ts index bee1eb24b..ff2820fe8 100644 --- a/backend/src/ee/services/external-kms/providers/gcp-kms.ts +++ b/backend/src/ee/services/external-kms/providers/gcp-kms.ts @@ -45,6 +45,14 @@ export const GcpKmsProviderFactory = async ({ inputs }: GcpKmsProviderArgs): Pro } }; + const cleanup = async () => { + try { + await gcpKmsClient.close(); + } catch (error) { + throw new Error("Failed to cleanup GCP KMS client", { cause: error }); + } + }; + // Used when adding the KMS to fetch the list of keys in specified region const getKeysList = async () => { try { @@ -108,6 +116,7 @@ export const GcpKmsProviderFactory = async ({ inputs }: GcpKmsProviderArgs): Pro validateConnection, getKeysList, encrypt, - decrypt + decrypt, + cleanup }; }; diff --git a/backend/src/ee/services/external-kms/providers/model.ts b/backend/src/ee/services/external-kms/providers/model.ts index 436b39423..6cb78a34e 100644 --- a/backend/src/ee/services/external-kms/providers/model.ts +++ b/backend/src/ee/services/external-kms/providers/model.ts @@ -98,4 +98,5 @@ export type TExternalKmsProviderFns = { validateConnection: () => Promise; encrypt: (data: Buffer) => Promise<{ encryptedBlob: Buffer }>; decrypt: (encryptedBlob: Buffer) => Promise<{ data: Buffer }>; + cleanup: () => Promise; }; diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-dal.ts b/backend/src/ee/services/github-org-sync/github-org-sync-dal.ts new file mode 100644 index 000000000..cda843b57 --- /dev/null +++ b/backend/src/ee/services/github-org-sync/github-org-sync-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TGithubOrgSyncDALFactory = ReturnType; + +export const githubOrgSyncDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.GithubOrgSyncConfig); + return orm; +}; diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts new file mode 100644 index 000000000..22a078399 --- /dev/null +++ b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts @@ -0,0 +1,354 @@ +import { ForbiddenError } from "@casl/ability"; +import { Octokit } from "@octokit/core"; +import { paginateGraphQL } from "@octokit/plugin-paginate-graphql"; +import { Octokit as OctokitRest } from "@octokit/rest"; + +import { OrgMembershipRole } from "@app/db/schemas"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TGroupDALFactory } from "../group/group-dal"; +import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { TGithubOrgSyncDALFactory } from "./github-org-sync-dal"; +import { TCreateGithubOrgSyncDTO, TDeleteGithubOrgSyncDTO, TUpdateGithubOrgSyncDTO } from "./github-org-sync-types"; + +const OctokitWithPlugin = Octokit.plugin(paginateGraphQL); + +type TGithubOrgSyncServiceFactoryDep = { + githubOrgSyncDAL: TGithubOrgSyncDALFactory; + permissionService: Pick; + kmsService: Pick; + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "findGroupMembershipsByUserIdInOrg" | "insertMany" | "delete" + >; + groupDAL: Pick; + licenseService: Pick; +}; + +export type TGithubOrgSyncServiceFactory = ReturnType; + +export const githubOrgSyncServiceFactory = ({ + githubOrgSyncDAL, + permissionService, + kmsService, + userGroupMembershipDAL, + groupDAL, + licenseService +}: TGithubOrgSyncServiceFactoryDep) => { + const createGithubOrgSync = async ({ + githubOrgName, + orgPermission, + githubOrgAccessToken, + isActive + }: TCreateGithubOrgSyncDTO) => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.GithubOrgSync); + const plan = await licenseService.getPlan(orgPermission.orgId); + if (!plan.githubOrgSync) { + throw new BadRequestError({ + message: + "Failed to create github organization team sync due to plan restriction. Upgrade plan to create github organization sync." + }); + } + + const existingConfig = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId }); + if (existingConfig) + throw new BadRequestError({ + message: `Organization ${orgPermission.orgId} already has GitHub Organization sync config.` + }); + + const octokit = new OctokitRest({ + auth: githubOrgAccessToken, + request: { + signal: AbortSignal.timeout(5000) + } + }); + const { data } = await octokit.rest.orgs.get({ + org: githubOrgName + }); + if (data.login.toLowerCase() !== githubOrgName.toLowerCase()) + throw new BadRequestError({ message: "Invalid GitHub organisation" }); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: orgPermission.orgId + }); + + const config = await githubOrgSyncDAL.create({ + orgId: orgPermission.orgId, + githubOrgName, + isActive, + encryptedGithubOrgAccessToken: githubOrgAccessToken + ? encryptor({ plainText: Buffer.from(githubOrgAccessToken) }).cipherTextBlob + : null + }); + + return config; + }; + + const updateGithubOrgSync = async ({ + githubOrgName, + orgPermission, + githubOrgAccessToken, + isActive + }: TUpdateGithubOrgSyncDTO) => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSync); + const plan = await licenseService.getPlan(orgPermission.orgId); + if (!plan.githubOrgSync) { + throw new BadRequestError({ + message: + "Failed to update github organization team sync due to plan restriction. Upgrade plan to update github organization sync." + }); + } + + const existingConfig = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId }); + if (!existingConfig) + throw new BadRequestError({ + message: `Organization ${orgPermission.orgId} GitHub organization sync config missing.` + }); + + const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: orgPermission.orgId + }); + const newData = { + githubOrgName: githubOrgName || existingConfig.githubOrgName, + githubOrgAccessToken: + githubOrgAccessToken || + (existingConfig.encryptedGithubOrgAccessToken + ? decryptor({ cipherTextBlob: existingConfig.encryptedGithubOrgAccessToken }).toString() + : null) + }; + + if (githubOrgName || githubOrgAccessToken) { + const octokit = new OctokitRest({ + auth: newData.githubOrgAccessToken, + request: { + signal: AbortSignal.timeout(5000) + } + }); + const { data } = await octokit.rest.orgs.get({ + org: newData.githubOrgName + }); + + if (data.login.toLowerCase() !== newData.githubOrgName.toLowerCase()) + throw new BadRequestError({ message: "Invalid GitHub organisation" }); + } + + const config = await githubOrgSyncDAL.updateById(existingConfig.id, { + orgId: orgPermission.orgId, + githubOrgName: newData.githubOrgName, + isActive, + encryptedGithubOrgAccessToken: newData.githubOrgAccessToken + ? encryptor({ plainText: Buffer.from(newData.githubOrgAccessToken) }).cipherTextBlob + : null + }); + + return config; + }; + + const deleteGithubOrgSync = async ({ orgPermission }: TDeleteGithubOrgSyncDTO) => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSync); + + const plan = await licenseService.getPlan(orgPermission.orgId); + if (!plan.githubOrgSync) { + throw new BadRequestError({ + message: + "Failed to delete github organization team sync due to plan restriction. Upgrade plan to delete github organization sync." + }); + } + + const existingConfig = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId }); + if (!existingConfig) + throw new BadRequestError({ + message: `Organization ${orgPermission.orgId} GitHub organization sync config missing.` + }); + + const config = await githubOrgSyncDAL.deleteById(existingConfig.id); + + return config; + }; + + const getGithubOrgSync = async ({ orgPermission }: TDeleteGithubOrgSyncDTO) => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync); + + const existingConfig = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId }); + if (!existingConfig) + throw new NotFoundError({ + message: `Organization ${orgPermission.orgId} GitHub organization sync config missing.` + }); + + return existingConfig; + }; + + const syncUserGroups = async (orgId: string, userId: string, accessToken: string) => { + const config = await githubOrgSyncDAL.findOne({ orgId }); + if (!config || !config?.isActive) return; + + const infisicalUserGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(userId, orgId); + const infisicalUserGroupSet = new Set(infisicalUserGroups.map((el) => el.groupName)); + + const octoRest = new OctokitRest({ + auth: accessToken, + request: { + signal: AbortSignal.timeout(5000) + } + }); + const { data: userOrgMembershipDetails } = await octoRest.rest.orgs + .getMembershipForAuthenticatedUser({ + org: config.githubOrgName + }) + .catch((err) => { + logger.error(err, "User not part of GitHub synced organization"); + throw new BadRequestError({ message: "User not part of GitHub synced organization" }); + }); + const username = userOrgMembershipDetails?.user?.login; + if (!username) throw new BadRequestError({ message: "User not part of GitHub synced organization" }); + + const octokit = new OctokitWithPlugin({ + auth: accessToken, + request: { + signal: AbortSignal.timeout(5000) + } + }); + const data = await octokit.graphql + .paginate<{ + organization: { teams: { totalCount: number; edges: { node: { name: string; description: string } }[] } }; + }>( + ` + query orgTeams($cursor: String,$org: String!, $username: String!){ + organization(login: $org) { + teams(first: 100, userLogins: [$username], after: $cursor) { + totalCount + edges { + node { + name + description + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `, + { + org: config.githubOrgName, + username + } + ) + .catch((err) => { + if ((err as Error)?.message?.includes("Although you appear to have the correct authorization credential")) { + throw new BadRequestError({ + message: + "Please check your organization have approved Infisical Oauth application. For more info: https://infisical.com/docs/documentation/platform/github-org-sync#troubleshooting" + }); + } + throw new BadRequestError({ message: (err as Error)?.message }); + }); + + const { + organization: { teams } + } = data; + const githubUserTeams = teams?.edges?.map((el) => el.node.name.toLowerCase()) || []; + const githubUserTeamSet = new Set(githubUserTeams); + const githubUserTeamOnInfisical = await groupDAL.find({ orgId, $in: { name: githubUserTeams } }); + const githubUserTeamOnInfisicalGroupByName = groupBy(githubUserTeamOnInfisical, (i) => i.name); + + const newTeams = githubUserTeams.filter( + (el) => !infisicalUserGroupSet.has(el) && !Object.hasOwn(githubUserTeamOnInfisicalGroupByName, el) + ); + const updateTeams = githubUserTeams.filter( + (el) => !infisicalUserGroupSet.has(el) && Object.hasOwn(githubUserTeamOnInfisicalGroupByName, el) + ); + const removeFromTeams = infisicalUserGroups.filter((el) => !githubUserTeamSet.has(el.groupName)); + + if (newTeams.length || updateTeams.length || removeFromTeams.length) { + await groupDAL.transaction(async (tx) => { + if (newTeams.length) { + const newGroups = await groupDAL.insertMany( + newTeams.map((newGroupName) => ({ + name: newGroupName, + role: OrgMembershipRole.Member, + slug: newGroupName, + orgId + })), + tx + ); + await userGroupMembershipDAL.insertMany( + newGroups.map((el) => ({ + groupId: el.id, + userId + })), + tx + ); + } + + if (updateTeams.length) { + await userGroupMembershipDAL.insertMany( + updateTeams.map((el) => ({ + groupId: githubUserTeamOnInfisicalGroupByName[el][0].id, + userId + })), + tx + ); + } + + if (removeFromTeams.length) { + await userGroupMembershipDAL.delete( + { userId, $in: { groupId: removeFromTeams.map((el) => el.groupId) } }, + tx + ); + } + }); + } + }; + + return { + createGithubOrgSync, + updateGithubOrgSync, + deleteGithubOrgSync, + getGithubOrgSync, + syncUserGroups + }; +}; diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-types.ts b/backend/src/ee/services/github-org-sync/github-org-sync-types.ts new file mode 100644 index 000000000..e1df71e82 --- /dev/null +++ b/backend/src/ee/services/github-org-sync/github-org-sync-types.ts @@ -0,0 +1,23 @@ +import { OrgServiceActor } from "@app/lib/types"; + +export interface TCreateGithubOrgSyncDTO { + orgPermission: OrgServiceActor; + githubOrgName: string; + githubOrgAccessToken?: string; + isActive?: boolean; +} + +export interface TUpdateGithubOrgSyncDTO { + orgPermission: OrgServiceActor; + githubOrgName?: string; + githubOrgAccessToken?: string; + isActive?: boolean; +} + +export interface TDeleteGithubOrgSyncDTO { + orgPermission: OrgServiceActor; +} + +export interface TGetGithubOrgSyncDTO { + orgPermission: OrgServiceActor; +} diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 3f4af174b..548f6e82b 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -22,6 +22,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ pitRecovery: false, ipAllowlisting: false, rbac: false, + githubOrgSync: false, customRateLimits: false, customAlerts: false, secretAccessInsights: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index c2bf42e2e..6f0d82344 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -45,6 +45,7 @@ export type TFeatureSet = { auditLogsRetentionDays: 0; auditLogStreams: false; auditLogStreamLimit: 3; + githubOrgSync: false; samlSSO: false; hsm: false; oidcSSO: false; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index adfe92341..bc60dff25 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -685,10 +685,16 @@ export const oidcConfigServiceFactory = ({ id_token_signed_response_alg: oidcCfg.jwtSignatureAlgorithm }); + // Check if the OIDC provider supports PKCE + const codeChallengeMethods = client.issuer.metadata.code_challenge_methods_supported; + const supportsPKCE = Array.isArray(codeChallengeMethods) && codeChallengeMethods.includes("S256"); + const strategy = new OpenIdStrategy( { client, - passReqToCallback: true + passReqToCallback: true, + usePKCE: supportsPKCE, + params: supportsPKCE ? { code_challenge_method: "S256" } : undefined }, // eslint-disable-next-line @typescript-eslint/no-explicit-any (_req: any, tokenSet: TokenSet, cb: any) => { diff --git a/backend/src/ee/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts index 3b2194375..c56427e63 100644 --- a/backend/src/ee/services/oidc/oidc-config-types.ts +++ b/backend/src/ee/services/oidc/oidc-config-types.ts @@ -8,7 +8,8 @@ export enum OIDCConfigurationType { export enum OIDCJWTSignatureAlgorithm { RS256 = "RS256", HS256 = "HS256", - RS512 = "RS512" + RS512 = "RS512", + EDDSA = "EdDSA" } export type TOidcLoginDTO = { diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 17b4e7f6c..7026899c7 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -74,6 +74,7 @@ export enum OrgPermissionSubjects { IncidentAccount = "incident-contact", Sso = "sso", Scim = "scim", + GithubOrgSync = "github-org-sync", Ldap = "ldap", Groups = "groups", Billing = "billing", @@ -101,6 +102,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Scim] + | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] @@ -165,6 +167,10 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ subject: z.literal(OrgPermissionSubjects.Scim).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") }), + z.object({ + subject: z.literal(OrgPermissionSubjects.GithubOrgSync).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), z.object({ subject: z.literal(OrgPermissionSubjects.Ldap).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") @@ -273,6 +279,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim); + can(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync); + can(OrgPermissionActions.Create, OrgPermissionSubjects.GithubOrgSync); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSync); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSync); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 0082c3d17..3d2f96f82 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -551,13 +551,26 @@ export const permissionServiceFactory = ({ }; const getProjectPermission = async ({ - actor, - actorId, + actor: inputActor, + actorId: inputActorId, projectId, actorAuthMethod, actorOrgId, actionProjectType }: TGetProjectPermissionArg): Promise> => { + let actor = inputActor; + let actorId = inputActorId; + const assumedPrivilegeDetailsCtx = requestContext.get("assumedPrivilegeDetails"); + if ( + assumedPrivilegeDetailsCtx && + actor === ActorType.USER && + actorId === assumedPrivilegeDetailsCtx.requesterId && + projectId === assumedPrivilegeDetailsCtx.projectId + ) { + actor = assumedPrivilegeDetailsCtx.actorType; + actorId = assumedPrivilegeDetailsCtx.actorId; + } + switch (actor) { case ActorType.USER: return getUserProjectPermission({ diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index d171fb3d8..8e6645073 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -50,7 +50,8 @@ export enum ProjectPermissionIdentityActions { Create = "create", Edit = "edit", Delete = "delete", - GrantPrivileges = "grant-privileges" + GrantPrivileges = "grant-privileges", + AssumePrivileges = "assume-privileges" } export enum ProjectPermissionMemberActions { @@ -58,7 +59,8 @@ export enum ProjectPermissionMemberActions { Create = "create", Edit = "edit", Delete = "delete", - GrantPrivileges = "grant-privileges" + GrantPrivileges = "grant-privileges", + AssumePrivileges = "assume-privileges" } export enum ProjectPermissionGroupActions { @@ -714,7 +716,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionMemberActions.Edit, ProjectPermissionMemberActions.Delete, ProjectPermissionMemberActions.Read, - ProjectPermissionMemberActions.GrantPrivileges + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionMemberActions.AssumePrivileges ], ProjectPermissionSub.Member ); @@ -736,7 +739,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionIdentityActions.Edit, ProjectPermissionIdentityActions.Delete, ProjectPermissionIdentityActions.Read, - ProjectPermissionIdentityActions.GrantPrivileges + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionIdentityActions.AssumePrivileges ], ProjectPermissionSub.Identity ); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts index 5d6358072..839833a9c 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts @@ -33,6 +33,7 @@ export type TApprovalCreateSecretV2Bridge = { secretComment?: string; reminderNote?: string | null; reminderRepeatDays?: number | null; + secretReminderRecipients?: string[] | null; skipMultilineEncoding?: boolean; metadata?: Record; secretMetadata?: ResourceMetadataDTO; 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 4baeca503..3c9755e65 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-dal.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-dal.ts @@ -33,6 +33,7 @@ export const sshHostDALFactory = (db: TDbClient) => { 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), @@ -45,7 +46,8 @@ export const sshHostDALFactory = (db: TDbClient) => { const grouped = groupBy(rows, (r) => r.sshHostId); return Object.values(grouped).map((hostRows) => { - const { sshHostId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } = hostRows[0]; + const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } = + hostRows[0]; const loginMappingGrouped = groupBy(hostRows, (r) => r.loginUser); @@ -59,6 +61,7 @@ export const sshHostDALFactory = (db: TDbClient) => { return { id: sshHostId, hostname, + alias, projectId, userCertTtl, hostCertTtl, @@ -87,6 +90,7 @@ export const sshHostDALFactory = (db: TDbClient) => { 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), @@ -99,7 +103,7 @@ export const sshHostDALFactory = (db: TDbClient) => { const hostsGrouped = groupBy(rows, (r) => r.sshHostId); return Object.values(hostsGrouped).map((hostRows) => { - const { sshHostId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = hostRows[0]; + const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = hostRows[0]; const loginMappingGrouped = groupBy( hostRows.filter((r) => r.loginUser), @@ -116,6 +120,7 @@ export const sshHostDALFactory = (db: TDbClient) => { return { id: sshHostId, hostname, + alias, projectId, userCertTtl, hostCertTtl, @@ -144,6 +149,7 @@ export const sshHostDALFactory = (db: TDbClient) => { 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), @@ -155,7 +161,7 @@ export const sshHostDALFactory = (db: TDbClient) => { if (rows.length === 0) return null; - const { sshHostId: id, projectId, hostname, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = rows[0]; + const { sshHostId: id, projectId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = rows[0]; const loginMappingGrouped = groupBy( rows.filter((r) => r.loginUser), @@ -173,6 +179,7 @@ export const sshHostDALFactory = (db: TDbClient) => { id, projectId, hostname, + alias, userCertTtl, hostCertTtl, loginMappings, diff --git a/backend/src/ee/services/ssh-host/ssh-host-schema.ts b/backend/src/ee/services/ssh-host/ssh-host-schema.ts index 4eeb90881..a9b674991 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-schema.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-schema.ts @@ -6,6 +6,7 @@ export const sanitizedSshHost = SshHostsSchema.pick({ id: true, projectId: true, hostname: true, + alias: true, userCertTtl: true, hostCertTtl: true, userSshCaId: true, 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 69807431a..92f1f5236 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-service.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts @@ -119,6 +119,7 @@ export const sshHostServiceFactory = ({ const createSshHost = async ({ projectId, hostname, + alias, userCertTtl, hostCertTtl, loginMappings, @@ -192,6 +193,7 @@ export const sshHostServiceFactory = ({ { projectId, hostname, + alias: alias === "" ? null : alias, userCertTtl, hostCertTtl, userSshCaId, @@ -265,6 +267,7 @@ export const sshHostServiceFactory = ({ const updateSshHost = async ({ sshHostId, hostname, + alias, userCertTtl, hostCertTtl, loginMappings, @@ -297,6 +300,7 @@ export const sshHostServiceFactory = ({ sshHostId, { hostname, + alias: alias === "" ? null : alias, userCertTtl, hostCertTtl }, 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 0c7cb25e1..a4826cd72 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-types.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts @@ -4,6 +4,7 @@ export type TListSshHostsDTO = Omit; export type TCreateSshHostDTO = { hostname: string; + alias?: string; userCertTtl: string; hostCertTtl: string; loginMappings: { @@ -19,6 +20,7 @@ export type TCreateSshHostDTO = { export type TUpdateSshHostDTO = { sshHostId: string; hostname?: string; + alias?: string; userCertTtl?: string; hostCertTtl?: string; loginMappings?: { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 18157d979..19ee7e331 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -807,6 +807,8 @@ export const RAW_SECRETS = { tagIds: "The ID of the tags to be attached to the updated secret.", secretReminderRepeatDays: "Interval for secret rotation notifications, measured in days.", secretReminderNote: "Note to be attached in notification email.", + secretReminderRecipients: + "An array of user IDs that will receive the reminder email. If not specified, all project members will receive the reminder email.", newSecretName: "The new name for the secret." }, DELETE: { @@ -1387,6 +1389,7 @@ export const SSH_HOSTS = { CREATE: { projectId: "The ID of the project to create the SSH host in.", hostname: "The hostname of the SSH host.", + alias: "The alias for the SSH host.", userCertTtl: "The time to live for user certificates issued under this host.", hostCertTtl: "The time to live for host certificates issued under this host.", loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')", @@ -1401,6 +1404,7 @@ export const SSH_HOSTS = { UPDATE: { sshHostId: "The ID of the SSH host to update.", hostname: "The hostname of the SSH host to update to.", + alias: "The alias for the SSH host to update to.", userCertTtl: "The time to live for user certificates issued under this host to update to.", hostCertTtl: "The time to live for host certificates issued under this host to update to.", loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')", diff --git a/backend/src/lib/config/const.ts b/backend/src/lib/config/const.ts new file mode 100644 index 000000000..41038112d --- /dev/null +++ b/backend/src/lib/config/const.ts @@ -0,0 +1 @@ +export const INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN = "x-infisical-github-auth-access-token"; diff --git a/backend/src/lib/dates/index.ts b/backend/src/lib/dates/index.ts index 1b6e5dec0..369e289cd 100644 --- a/backend/src/lib/dates/index.ts +++ b/backend/src/lib/dates/index.ts @@ -2,7 +2,7 @@ export const daysToMillisecond = (days: number) => days * 24 * 60 * 60 * 1000; export const secondsToMillis = (seconds: number) => seconds * 1000; -export const applyJitter = (delayMs: number, jitterMs: number) => { - const jitter = Math.floor(Math.random() * (2 * jitterMs)) - jitterMs; - return delayMs + jitter; +export const applyJitter = (delay: number, jitter: number) => { + const jitterTime = Math.floor(Math.random() * (2 * jitter)) - jitter; + return delay + jitterTime; }; diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index d43d2af8e..55d4bf399 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -2,6 +2,8 @@ import { Knex } from "knex"; import { Tables } from "knex/types/tables"; +import { TableName } from "@app/db/schemas"; + import { DatabaseError } from "../errors"; import { buildDynamicKnexQuery, TKnexDynamicOperator } from "./dynamic"; @@ -25,28 +27,41 @@ export type TFindFilter = Partial & { $search?: Partial<{ [k in keyof R]: R[k] }>; $complex?: TKnexDynamicOperator; }; + export const buildFindFilter = - ({ $in, $notNull, $search, $complex, ...filter }: TFindFilter) => + ( + { $in, $notNull, $search, $complex, ...filter }: TFindFilter, + tableName?: TableName, + excludeKeys?: Array + ) => (bd: Knex.QueryBuilder) => { - void bd.where(filter); + const processedFilter = tableName + ? Object.fromEntries( + Object.entries(filter) + .filter(([key]) => !excludeKeys || !excludeKeys.includes(key as keyof R)) + .map(([key, value]) => [`${tableName}.${key}`, value]) + ) + : filter; + + void bd.where(processedFilter); if ($in) { Object.entries($in).forEach(([key, val]) => { if (val) { - void bd.whereIn(key as never, val as never); + void bd.whereIn([`${tableName ? `${tableName}.` : ""}${key}`] as never, val as never); } }); } if ($notNull?.length) { $notNull.forEach((key) => { - void bd.whereNotNull(key as never); + void bd.whereNotNull([`${tableName ? `${tableName}.` : ""}${key as string}`] as never); }); } if ($search) { Object.entries($search).forEach(([key, val]) => { if (val) { - void bd.whereILike(key as never, val as never); + void bd.whereILike([`${tableName ? `${tableName}.` : ""}${key}`] as never, val as never); } }); } diff --git a/backend/src/lib/requests/github.ts b/backend/src/lib/requests/github.ts index 723e4957a..f25e46af5 100644 --- a/backend/src/lib/requests/github.ts +++ b/backend/src/lib/requests/github.ts @@ -16,3 +16,17 @@ export const fetchGithubEmails = async (accessToken: string) => { }); return data; }; + +type TGithubUser = { + name?: string; + login: string; +}; + +export const fetchGithubUser = async (accessToken: string) => { + const { data } = await request.get(`${INTEGRATION_GITHUB_API_URL}/user`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + return data; +}; diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts index b555869d7..8f195e0b5 100644 --- a/backend/src/lib/validator/validate-url.ts +++ b/backend/src/lib/validator/validate-url.ts @@ -15,13 +15,13 @@ export const blockLocalAndPrivateIpAddresses = async (url: string) => { const validUrl = new URL(url); const inputHostIps: string[] = []; - if (isIPv4(validUrl.host)) { - inputHostIps.push(validUrl.host); + if (isIPv4(validUrl.hostname)) { + inputHostIps.push(validUrl.hostname); } else { - if (validUrl.host === "localhost" || validUrl.host === "host.docker.internal") { + if (validUrl.hostname === "localhost" || validUrl.hostname === "host.docker.internal") { throw new BadRequestError({ message: "Local IPs not allowed as URL" }); } - const resolvedIps = await dns.resolve4(validUrl.host); + const resolvedIps = await dns.resolve4(validUrl.hostname); inputHostIps.push(...resolvedIps); } const isInternalIp = inputHostIps.some((el) => isPrivateIp(el)); diff --git a/backend/src/server/plugins/auth/inject-assume-privilege.ts b/backend/src/server/plugins/auth/inject-assume-privilege.ts new file mode 100644 index 000000000..58eb5c99c --- /dev/null +++ b/backend/src/server/plugins/auth/inject-assume-privilege.ts @@ -0,0 +1,24 @@ +import { requestContext } from "@fastify/request-context"; +import fp from "fastify-plugin"; + +import { AuthMode } from "@app/services/auth/auth-type"; + +export const injectAssumePrivilege = fp(async (server: FastifyZodProvider) => { + server.addHook("onRequest", async (req, res) => { + const assumeRoleCookie = req.cookies["infisical-project-assume-privileges"]; + try { + if (req?.auth?.authMode === AuthMode.JWT && assumeRoleCookie) { + const decodedToken = server.services.assumePrivileges.verifyAssumePrivilegeToken( + assumeRoleCookie, + req.auth.tokenVersionId + ); + if (decodedToken) { + requestContext.set("assumedPrivilegeDetails", decodedToken); + } + } + } catch (error) { + req.log.error({ error }, "Failed to verify assume privilege token"); + void res.clearCookie("infisical-project-assume-privileges"); + } + }); +}); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a195ee5c2..2b841babd 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -12,6 +12,7 @@ import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-appr import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal"; import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal"; import { accessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; +import { assumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-service"; import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue"; import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; @@ -32,6 +33,8 @@ import { gatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; import { gatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { orgGatewayConfigDALFactory } from "@app/ee/services/gateway/org-gateway-config-dal"; import { projectGatewayDALFactory } from "@app/ee/services/gateway/project-gateway-dal"; +import { githubOrgSyncDALFactory } from "@app/ee/services/github-org-sync/github-org-sync-dal"; +import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { groupDALFactory } from "@app/ee/services/group/group-dal"; import { groupServiceFactory } from "@app/ee/services/group/group-service"; import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; @@ -217,6 +220,7 @@ import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-f import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal"; import { secretImportDALFactory } from "@app/services/secret-import/secret-import-dal"; import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; +import { secretReminderRecipientsDALFactory } from "@app/services/secret-reminder-recipients/secret-reminder-recipients-dal"; import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal"; import { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; import { secretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal"; @@ -251,6 +255,7 @@ import { workflowIntegrationDALFactory } from "@app/services/workflow-integratio import { workflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; import { injectAuditLogInfo } from "../plugins/audit-log"; +import { injectAssumePrivilege } from "../plugins/auth/inject-assume-privilege"; import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectPermission } from "../plugins/auth/inject-permission"; import { injectRateLimits } from "../plugins/inject-rate-limits"; @@ -420,6 +425,8 @@ export const registerRoutes = async ( const orgGatewayConfigDAL = orgGatewayConfigDALFactory(db); const gatewayDAL = gatewayDALFactory(db); const projectGatewayDAL = projectGatewayDALFactory(db); + const secretReminderRecipientsDAL = secretReminderRecipientsDALFactory(db); + const githubOrgSyncDAL = githubOrgSyncDALFactory(db); const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL); const microsoftTeamsIntegrationDAL = microsoftTeamsIntegrationDALFactory(db); @@ -432,6 +439,11 @@ export const registerRoutes = async ( serviceTokenDAL, projectDAL }); + const assumePrivilegeService = assumePrivilegeServiceFactory({ + projectDAL, + permissionService + }); + const licenseService = licenseServiceFactory({ permissionService, orgDAL, @@ -554,6 +566,15 @@ export const registerRoutes = async ( externalGroupOrgRoleMappingDAL }); + const githubOrgSyncConfigService = githubOrgSyncServiceFactory({ + licenseService, + githubOrgSyncDAL, + kmsService, + permissionService, + groupDAL, + userGroupMembershipDAL + }); + const ldapService = ldapConfigServiceFactory({ ldapConfigDAL, ldapGroupMapDAL, @@ -743,6 +764,7 @@ export const registerRoutes = async ( projectKeyDAL, projectRoleDAL, groupProjectDAL, + secretReminderRecipientsDAL, licenseService }); const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ @@ -976,6 +998,7 @@ export const registerRoutes = async ( secretApprovalRequestDAL, projectKeyDAL, projectUserMembershipRoleDAL, + secretReminderRecipientsDAL, orgService, resourceMetadataDAL, secretSyncQueue @@ -1039,7 +1062,9 @@ export const registerRoutes = async ( projectRoleDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, - projectDAL + projectDAL, + identityDAL, + userDAL }); const snapshotService = secretSnapshotServiceFactory({ @@ -1698,7 +1723,9 @@ export const registerRoutes = async ( kmipOperation: kmipOperationService, gateway: gatewayService, secretRotationV2: secretRotationV2Service, - microsoftTeams: microsoftTeamsService + microsoftTeams: microsoftTeamsService, + assumePrivileges: assumePrivilegeService, + githubOrgSync: githubOrgSyncConfigService }); const cronJobs: CronJob[] = []; @@ -1719,6 +1746,7 @@ export const registerRoutes = async ( }); await server.register(injectIdentity, { userDAL, serviceTokenDAL }); + await server.register(injectAssumePrivilege); await server.register(injectPermission); await server.register(injectRateLimits); await server.register(injectAuditLogInfo); @@ -1758,30 +1786,6 @@ export const registerRoutes = async ( logger.info(`Raw event loop stats: ${JSON.stringify(histogram, null, 2)}`); - // try { - // await db.raw("SELECT NOW()"); - // } catch (err) { - // logger.error("Health check: database connection failed", err); - // return reply.code(503).send({ - // date: new Date(), - // message: "Service unavailable" - // }); - // } - - // if (cfg.isRedisConfigured) { - // const redis = new Redis(cfg.REDIS_URL); - // try { - // await redis.ping(); - // redis.disconnect(); - // } catch (err) { - // logger.error("Health check: redis connection failed", err); - // return reply.code(503).send({ - // date: new Date(), - // message: "Service unavailable" - // }); - // } - // } - return { date: new Date(), message: "Ok", diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index 04ca958c6..717c6f1b6 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -33,6 +33,14 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { secure: appCfg.HTTPS_ENABLED }); + void res.cookie("infisical-project-assume-privileges", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED, + maxAge: 0 + }); + return { message: "Successfully logged out" }; } }); diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 5a52d4748..54da97682 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { z } from "zod"; -import { SecretFoldersSchema, SecretImportsSchema } from "@app/db/schemas"; +import { SecretFoldersSchema, SecretImportsSchema, UsersSchema } from "@app/db/schemas"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; @@ -594,6 +594,12 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .optional(), secrets: secretRawSchema .extend({ + secretReminderRecipients: z + .object({ + user: UsersSchema.pick({ id: true, email: true, username: true }), + id: z.string() + }) + .array(), secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index a222ab172..f7a1b973a 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -9,15 +9,17 @@ import { Authenticator } from "@fastify/passport"; import fastifySession from "@fastify/session"; import RedisStore from "connect-redis"; -import { Strategy as GitHubStrategy } from "passport-github"; import { Strategy as GitLabStrategy } from "passport-gitlab2"; import { Strategy as GoogleStrategy } from "passport-google-oauth20"; +import { Strategy as OAuth2Strategy } from "passport-oauth2"; import { z } from "zod"; +import { INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN } from "@app/lib/config/const"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; -import { fetchGithubEmails } from "@app/lib/requests/github"; +import { ms } from "@app/lib/ms"; +import { fetchGithubEmails, fetchGithubUser } from "@app/lib/requests/github"; import { authRateLimit } from "@app/server/config/rateLimiter"; import { AuthMethod } from "@app/services/auth/auth-type"; import { OrgAuthMethod } from "@app/services/org/org-types"; @@ -42,6 +44,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { }); await server.register(passport.initialize()); await server.register(passport.secureSession()); + // passport oauth strategy for Google const isGoogleOauthActive = Boolean(appCfg.CLIENT_ID_GOOGLE_LOGIN && appCfg.CLIENT_SECRET_GOOGLE_LOGIN); if (isGoogleOauthActive) { @@ -52,8 +55,9 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientID: appCfg.CLIENT_ID_GOOGLE_LOGIN as string, clientSecret: appCfg.CLIENT_SECRET_GOOGLE_LOGIN as string, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/google`, - scope: ["profile", " email"], - state: true + scope: ["profile", "email"], + state: true, + pkce: true }, // eslint-disable-next-line async (req, _accessToken, _refreshToken, profile, cb) => { @@ -89,34 +93,44 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { const isGithubOauthActive = Boolean(appCfg.CLIENT_SECRET_GITHUB_LOGIN && appCfg.CLIENT_ID_GITHUB_LOGIN); if (isGithubOauthActive) { passport.use( - new GitHubStrategy( + "github", + new OAuth2Strategy( { - passReqToCallback: true, - clientID: appCfg.CLIENT_ID_GITHUB_LOGIN as string, - clientSecret: appCfg.CLIENT_SECRET_GITHUB_LOGIN as string, + authorizationURL: "https://github.com/login/oauth/authorize", + tokenURL: "https://github.com/login/oauth/access_token", + clientID: appCfg.CLIENT_ID_GITHUB_LOGIN!, + clientSecret: appCfg.CLIENT_SECRET_GITHUB_LOGIN!, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/github`, - scope: ["user:email"], - // akhilmhdh: because the ts type for this is outdated by the maintainer - state: true as unknown as string + scope: ["user:email", "read:org"], + state: true, + pkce: true, + passReqToCallback: true }, // eslint-disable-next-line - async (req, accessToken, _refreshToken, profile, cb) => { - // @ts-expect-error this is because this is express type and not fastify - const callbackPort = req.session.get("callbackPort"); + async (req: any, accessToken: string, _refreshToken: string, _profile: any, done: Function) => { try { const ghEmails = await fetchGithubEmails(accessToken); const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; + + if (!email) throw new Error("No primary email found"); + + // profile does not get automatically populated so we need to manually fetch user info + const user = await fetchGithubUser(accessToken); + + const callbackPort = req.session.get("callbackPort"); + const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, - firstName: profile.displayName || profile.username || "", + firstName: user.name || user.login, lastName: "", authMethod: AuthMethod.GITHUB, callbackPort }); - return cb(null, { isUserCompleted, providerAuthToken }); - } catch (error) { - logger.error(error); - cb(error as Error, false); + + done(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken }); + } catch (err) { + logger.error(err); + done(err as Error, false); } } ) @@ -136,7 +150,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientSecret: appCfg.CLIENT_SECRET_GITLAB_LOGIN, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/gitlab`, baseURL: appCfg.CLIENT_GITLAB_LOGIN_URL, - state: true + state: true, + pkce: true }, async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => { try { @@ -166,17 +181,24 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { method: "GET", schema: { querystring: z.object({ - callback_port: z.string().optional() + callback_port: z.string().optional(), + is_admin_login: z + .string() + .optional() + .transform((val) => val === "true") }) }, preValidation: [ async (req, res) => { - const { callback_port: callbackPort } = req.query; + const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query; // ensure fresh session state per login attempt await req.session.regenerate(); if (callbackPort) { req.session.set("callbackPort", callbackPort); } + if (isAdminLogin) { + req.session.set("isAdminLogin", isAdminLogin); + } return ( passport.authenticate("google", { scope: ["profile", "email"], @@ -200,10 +222,13 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { // this is due to zod type difference }) as never, handler: async (req, res) => { + const isAdminLogin = req.session.get("isAdminLogin"); await req.session.destroy(); if (req.passportUser.isUserCompleted) { return res.redirect( - `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}${ + isAdminLogin ? `&isAdminLogin=${isAdminLogin}` : "" + }` ); } return res.redirect( @@ -217,18 +242,26 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { method: "GET", schema: { querystring: z.object({ - callback_port: z.string().optional() + callback_port: z.string().optional(), + is_admin_login: z + .string() + .optional() + .transform((val) => val === "true") }) }, preValidation: [ async (req, res) => { - const { callback_port: callbackPort } = req.query; + const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query; // ensure fresh session state per login attempt await req.session.regenerate(); if (callbackPort) { req.session.set("callbackPort", callbackPort); } + if (isAdminLogin) { + req.session.set("isAdminLogin", isAdminLogin); + } + return ( passport.authenticate("github", { session: false, @@ -289,10 +322,24 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { // this is due to zod type difference }) as any, handler: async (req, res) => { + const isAdminLogin = req.session.get("isAdminLogin"); await req.session.destroy(); + + if (req.passportUser.externalProviderAccessToken) { + void res.cookie(INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN, req.passportUser.externalProviderAccessToken, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED, + expires: new Date(Date.now() + ms(appCfg.JWT_PROVIDER_AUTH_LIFETIME)) + }); + } + if (req.passportUser.isUserCompleted) { return res.redirect( - `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}${ + isAdminLogin ? `&isAdminLogin=${isAdminLogin}` : "" + }` ); } return res.redirect( @@ -306,18 +353,26 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { method: "GET", schema: { querystring: z.object({ - callback_port: z.string().optional() + callback_port: z.string().optional(), + is_admin_login: z + .string() + .optional() + .transform((val) => val === "true") }) }, preValidation: [ async (req, res) => { - const { callback_port: callbackPort } = req.query; + const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query; // ensure fresh session state per login attempt await req.session.regenerate(); if (callbackPort) { req.session.set("callbackPort", callbackPort); } + if (isAdminLogin) { + req.session.set("isAdminLogin", isAdminLogin); + } + return ( passport.authenticate("gitlab", { session: false, @@ -342,10 +397,13 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as any, handler: async (req, res) => { + const isAdminLogin = req.session.get("isAdminLogin"); await req.session.destroy(); if (req.passportUser.isUserCompleted) { return res.redirect( - `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}${ + isAdminLogin ? `&isAdminLogin=${isAdminLogin}` : "" + }` ); } return res.redirect( diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index cddfc1c2b..91df68e16 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN } from "@app/lib/config/const"; import { getConfig } from "@app/lib/config/env"; import { authRateLimit } from "@app/server/config/rateLimiter"; @@ -70,6 +71,21 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }; } + const githubOauthAccessToken = req.cookies[INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN]; + if (githubOauthAccessToken) { + await server.services.githubOrgSync + .syncUserGroups(req.body.organizationId, tokens.user.userId, githubOauthAccessToken) + .finally(() => { + void res.setCookie(INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN, "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: cfg.HTTPS_ENABLED, + maxAge: 0 + }); + }); + } + void res.setCookie("jid", tokens.refresh, { httpOnly: true, path: "/", @@ -77,6 +93,14 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { secure: cfg.HTTPS_ENABLED }); + void res.cookie("infisical-project-assume-privileges", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: cfg.HTTPS_ENABLED, + maxAge: 0 + }); + return { token: tokens.access, isMfaEnabled: false }; } }); @@ -131,6 +155,14 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { secure: appCfg.HTTPS_ENABLED }); + void res.cookie("infisical-project-assume-privileges", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED, + maxAge: 0 + }); + return { encryptionVersion: data.user.encryptionVersion, token: data.token.access, diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 40aed624b..c986e40b4 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -662,6 +662,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { .optional() .nullable() .describe(RAW_SECRETS.UPDATE.secretReminderRepeatDays), + secretReminderRecipients: z.string().array().optional().describe(RAW_SECRETS.UPDATE.secretReminderRecipients), newSecretName: SecretNameSchema.optional().describe(RAW_SECRETS.UPDATE.newSecretName), secretComment: z.string().optional().describe(RAW_SECRETS.UPDATE.secretComment) }), @@ -692,6 +693,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { skipMultilineEncoding: req.body.skipMultilineEncoding, tagIds: req.body.tagIds, secretReminderRepeatDays: req.body.secretReminderRepeatDays, + secretReminderRecipients: req.body.secretReminderRecipients, secretReminderNote: req.body.secretReminderNote, metadata: req.body.metadata, newSecretName: req.body.newSecretName, diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index bc9c4afa3..0f8ba5176 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -476,6 +476,7 @@ export const authLoginServiceFactory = ({ return { ...tokens, + user, isMfaEnabled: false }; }; @@ -784,7 +785,7 @@ export const authLoginServiceFactory = ({ organizationId }); - return { token, isMfaEnabled: false, user: userEnc } as const; + return { token, isMfaEnabled: false, user: userEnc, decodedProviderToken } as const; }; /* diff --git a/backend/src/services/integration-auth/integration-delete-secret.ts b/backend/src/services/integration-auth/integration-delete-secret.ts index f77becb02..46c5ed2bd 100644 --- a/backend/src/services/integration-auth/integration-delete-secret.ts +++ b/backend/src/services/integration-auth/integration-delete-secret.ts @@ -177,6 +177,7 @@ export const deleteGithubSecrets = async ({ selected_repositories_url?: string | undefined; } + // @ts-expect-error just octokit ts compatiability issue const OctokitWithRetry = Octokit.plugin(retry); let octokit: Octokit; const appCfg = getConfig(); diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 8bfa50b64..196c18356 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -342,9 +342,12 @@ export const kmsServiceFactory = ({ } return async ({ cipherTextBlob }: Pick) => { - const { data } = await externalKms.decrypt(cipherTextBlob); - - return data; + try { + const { data } = await externalKms.decrypt(cipherTextBlob); + return data; + } finally { + await externalKms.cleanup(); + } }; } @@ -557,9 +560,12 @@ export const kmsServiceFactory = ({ } return async ({ plainText }: Pick) => { - const { encryptedBlob } = await externalKms.encrypt(plainText); - - return { cipherTextBlob: encryptedBlob }; + try { + const { encryptedBlob } = await externalKms.encrypt(plainText); + return { cipherTextBlob: encryptedBlob }; + } finally { + await externalKms.cleanup(); + } }; } diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 1fe4961d2..a68a690d3 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -23,6 +23,7 @@ import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; +import { TSecretReminderRecipientsDALFactory } from "../secret-reminder-recipients/secret-reminder-recipients-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; @@ -53,6 +54,7 @@ type TProjectMembershipServiceFactoryDep = { projectKeyDAL: Pick; licenseService: Pick; projectUserAdditionalPrivilegeDAL: Pick; + secretReminderRecipientsDAL: Pick; groupProjectDAL: TGroupProjectDALFactory; }; @@ -71,6 +73,7 @@ export const projectMembershipServiceFactory = ({ groupProjectDAL, projectDAL, projectKeyDAL, + secretReminderRecipientsDAL, licenseService }: TProjectMembershipServiceFactoryDep) => { const getProjectMemberships = async ({ @@ -389,6 +392,13 @@ export const projectMembershipServiceFactory = ({ const membership = await projectMembershipDAL.transaction(async (tx) => { const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx); await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx); + await secretReminderRecipientsDAL.delete( + { + projectId, + userId: deletedMembership.userId + }, + tx + ); return deletedMembership; }); return membership; @@ -466,6 +476,16 @@ export const projectMembershipServiceFactory = ({ tx ); + await secretReminderRecipientsDAL.delete( + { + projectId, + $in: { + userId: projectMembers.map(({ user }) => user.id) + } + }, + tx + ); + // delete project keys belonging to users that are not part of any other groups in the project await projectKeyDAL.delete( { @@ -526,6 +546,15 @@ export const projectMembershipServiceFactory = ({ }, tx ); + + await secretReminderRecipientsDAL.delete( + { + projectId, + userId: actorId + }, + tx + ); + const membership = ( await projectMembershipDAL.delete( { diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index fc2fb9319..211dcff4f 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; +import { requestContext } from "@fastify/request-context"; import { ActionProjectType, ProjectMembershipRole, TableName } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -12,10 +13,12 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; -import { ActorAuthMethod } from "../auth/auth-type"; +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { TUserDALFactory } from "../user/user-dal"; import { TProjectRoleDALFactory } from "./project-role-dal"; import { getPredefinedRoles } from "./project-role-fns"; import { @@ -29,6 +32,8 @@ import { type TProjectRoleServiceFactoryDep = { projectRoleDAL: TProjectRoleDALFactory; + identityDAL: Pick; + userDAL: Pick; projectDAL: Pick; permissionService: Pick; identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory; @@ -47,7 +52,9 @@ export const projectRoleServiceFactory = ({ permissionService, identityProjectMembershipRoleDAL, projectUserMembershipRoleDAL, - projectDAL + projectDAL, + identityDAL, + userDAL }: TProjectRoleServiceFactoryDep) => { const createRole = async ({ data, actor, actorId, actorAuthMethod, actorOrgId, filter }: TCreateRoleDTO) => { let projectId = ""; @@ -220,14 +227,42 @@ export const projectRoleServiceFactory = ({ actorAuthMethod: ActorAuthMethod, actorOrgId: string | undefined ) => { - const { permission, membership } = await permissionService.getUserProjectPermission({ - userId, + const { permission, membership } = await permissionService.getProjectPermission({ + actor: ActorType.USER, + actorId: userId, projectId, - authMethod: actorAuthMethod, - userOrgId: actorOrgId, + actorAuthMethod, + actorOrgId, actionProjectType: ActionProjectType.Any }); - return { permissions: packRules(permission.rules), membership }; + // just to satisfy ts + if (!("roles" in membership)) throw new BadRequestError({ message: "Service token not allowed" }); + + const assumedPrivilegeDetailsCtx = requestContext.get("assumedPrivilegeDetails"); + const isAssumingPrivilege = assumedPrivilegeDetailsCtx?.projectId === projectId; + const assumedPrivilegeDetails = isAssumingPrivilege + ? { + actorId: assumedPrivilegeDetailsCtx?.actorId, + actorType: assumedPrivilegeDetailsCtx?.actorType, + actorName: "", + actorEmail: "" + } + : undefined; + + if (assumedPrivilegeDetails?.actorType === ActorType.IDENTITY) { + const identityDetails = await identityDAL.findById(assumedPrivilegeDetails.actorId); + if (!identityDetails) + throw new NotFoundError({ message: `Identity with ID ${assumedPrivilegeDetails.actorId} not found` }); + assumedPrivilegeDetails.actorName = identityDetails.name; + } else if (assumedPrivilegeDetails?.actorType === ActorType.USER) { + const userDetails = await userDAL.findById(assumedPrivilegeDetails?.actorId); + if (!userDetails) + throw new NotFoundError({ message: `User with ID ${assumedPrivilegeDetails.actorId} not found` }); + assumedPrivilegeDetails.actorName = `${userDetails?.firstName} ${userDetails?.lastName || ""}`; + assumedPrivilegeDetails.actorEmail = userDetails?.email || ""; + } + + return { permissions: packRules(permission.rules), membership, assumedPrivilegeDetails }; }; return { createRole, updateRole, deleteRole, listRoles, getUserPermission, getRoleBySlug }; diff --git a/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts new file mode 100644 index 000000000..ec4a3f807 --- /dev/null +++ b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts @@ -0,0 +1,36 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TSecretReminderRecipientsDALFactory = ReturnType; + +export const secretReminderRecipientsDALFactory = (db: TDbClient) => { + const secretReminderRecipientsOrm = ormify(db, TableName.SecretReminderRecipients); + + const findUsersBySecretId = async (secretId: string, tx?: Knex) => { + const res = await (tx || db.replicaNode())(TableName.SecretReminderRecipients) + .where({ secretId }) + .leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Project, `${TableName.SecretReminderRecipients}.projectId`, `${TableName.Project}.id`) + .leftJoin(TableName.OrgMembership, (bd) => { + void bd + .on(`${TableName.OrgMembership}.userId`, "=", `${TableName.SecretReminderRecipients}.userId`) + .andOn(`${TableName.OrgMembership}.orgId`, "=", `${TableName.Project}.orgId`); + }) + + .where(`${TableName.OrgMembership}.isActive`, true) + .select(selectAllTableCols(TableName.SecretReminderRecipients)) + .select( + db.ref("email").withSchema(TableName.Users).as("email"), + db.ref("username").withSchema(TableName.Users).as("username"), + db.ref("firstName").withSchema(TableName.Users).as("firstName"), + db.ref("lastName").withSchema(TableName.Users).as("lastName") + ); + + return res; + }; + + return { ...secretReminderRecipientsOrm, findUsersBySecretId }; +}; diff --git a/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-types.ts b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-types.ts new file mode 100644 index 000000000..49eb9bf0f --- /dev/null +++ b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-types.ts @@ -0,0 +1,8 @@ +export type TSecretReminderRecipient = { + user: { + id: string; + username: string; + email?: string | null; + }; + id: string; +}; diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts index 323f59851..6dbd9bdd7 100644 --- a/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts @@ -10,7 +10,7 @@ import { TTeamCitySyncWithCredentials } from "@app/services/secret-sync/teamcity/teamcity-sync-types"; -// Note: Most variables won't be returned with a value due to them being a "password" type (starting with "env."). +// Note: Most variables won't be returned with a value due to them being a "password" type. // TeamCity API returns empty string for password-type variables for security reasons. const listTeamCityVariables = async ({ instanceUrl, accessToken, project, buildConfig }: TTeamCityListVariables) => { const { data } = await request.get( @@ -25,12 +25,16 @@ const listTeamCityVariables = async ({ instanceUrl, accessToken, project, buildC } ); + // Filters for only non-inherited environment variables // Strips out "env." from map key, but the "name" field still has the original unaltered key. return Object.fromEntries( - data.property.map((variable) => [ - variable.name.startsWith("env.") ? variable.name.substring(4) : variable.name, - { ...variable, value: variable.value || "" } // Password values will be empty strings from the API for security - ]) + data.property + .filter((variable) => !variable.inherited) + .filter((variable) => variable.name.startsWith("env.")) + .map((variable) => [ + variable.name.substring(4), + { ...variable, value: variable.value || "" } // Password values will be empty strings from the API for security + ]) ); }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index 05fc7cd35..6ab348520 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -22,6 +22,7 @@ import type { TFindSecretsByFolderIdsFilter, TGetSecretsDTO } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; +import { applyJitter } from "@app/lib/dates"; export const SecretServiceCacheKeys = { get productKey() { @@ -48,7 +49,7 @@ interface TSecretV2DalArg { keyStore: TKeyStoreFactory; } -export const SECRET_DAL_TTL = 5 * 60; +export const SECRET_DAL_TTL = () => applyJitter(10 * 60, 2 * 60); export const SECRET_DAL_VERSION_TTL = 15 * 60; export const MAX_SECRET_CACHE_BYTES = 25 * 1024 * 1024; export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { @@ -63,7 +64,8 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { const findOne = async (filter: Partial, tx?: Knex) => { try { const docs = await (tx || db)(TableName.SecretV2) - .where(filter) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(filter, TableName.SecretV2)) .leftJoin( TableName.SecretV2JnTag, `${TableName.SecretV2}.id`, @@ -79,7 +81,17 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { `${TableName.SecretV2}.id`, `${TableName.SecretRotationV2SecretMapping}.secretId` ) + .leftJoin( + TableName.SecretReminderRecipients, + `${TableName.SecretV2}.id`, + `${TableName.SecretReminderRecipients}.secretId` + ) + .leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`) .select(selectAllTableCols(TableName.SecretV2)) + .select(db.ref("id").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientId")) + .select(db.ref("username").withSchema(TableName.Users).as("reminderRecipientUsername")) + .select(db.ref("email").withSchema(TableName.Users).as("reminderRecipientEmail")) + .select(db.ref("id").withSchema(TableName.Users).as("reminderRecipientUserId")) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) @@ -103,6 +115,23 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { slug, name: slug }) + }, + { + key: "reminderRecipientId", + label: "secretReminderRecipients" as const, + mapper: ({ + reminderRecipientId, + reminderRecipientUsername, + reminderRecipientEmail, + reminderRecipientUserId + }) => ({ + user: { + id: reminderRecipientUserId, + username: reminderRecipientUsername, + email: reminderRecipientEmail + }, + id: reminderRecipientId + }) } ] }); @@ -484,6 +513,12 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) + .leftJoin( + TableName.SecretReminderRecipients, + `${TableName.SecretV2}.id`, + `${TableName.SecretReminderRecipients}.secretId` + ) + .leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) .leftJoin( TableName.SecretRotationV2SecretMapping, @@ -512,6 +547,10 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { }) as rank` ) ) + .select(db.ref("id").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientId")) + .select(db.ref("username").withSchema(TableName.Users).as("reminderRecipientUsername")) + .select(db.ref("email").withSchema(TableName.Users).as("reminderRecipientEmail")) + .select(db.ref("id").withSchema(TableName.Users).as("reminderRecipientUserId")) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) @@ -556,6 +595,23 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { isRotatedSecret: Boolean(el.rotationId) }), childrenMapper: [ + { + key: "reminderRecipientId", + label: "secretReminderRecipients" as const, + mapper: ({ + reminderRecipientId, + reminderRecipientUsername, + reminderRecipientEmail, + reminderRecipientUserId + }) => ({ + user: { + id: reminderRecipientUserId, + username: reminderRecipientUsername, + email: reminderRecipientEmail + }, + id: reminderRecipientId + }) + }, { key: "tagId", label: "tags" as const, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index f42deb8ff..6fdcadeff 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -2,7 +2,7 @@ import path from "node:path"; import RE2 from "re2"; -import { TableName, TSecretFolders, TSecretsV2 } from "@app/db/schemas"; +import { SecretType, TableName, TSecretFolders, TSecretsV2 } from "@app/db/schemas"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; @@ -12,6 +12,7 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "../secret/secret-fns"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretReminderRecipient } from "../secret-reminder-recipients/secret-reminder-recipients-types"; import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; import { TFnSecretBulkDelete, TFnSecretBulkInsert, TFnSecretBulkUpdate } from "./secret-v2-bridge-types"; @@ -353,7 +354,7 @@ export const fnSecretBulkDelete = async ({ deletedSecrets .filter(({ reminderRepeatDays }) => Boolean(reminderRepeatDays)) .map(({ id, reminderRepeatDays }) => - secretQueueService.removeSecretReminder({ secretId: id, repeatDays: reminderRepeatDays as number }) + secretQueueService.removeSecretReminder({ secretId: id, repeatDays: reminderRepeatDays as number }, tx) ) ); @@ -684,6 +685,7 @@ export const reshapeBridgeSecret = ( secretMetadata?: ResourceMetadataDTO; isRotatedSecret?: boolean; rotationId?: string; + secretReminderRecipients?: TSecretReminderRecipient[]; }, secretValueHidden: boolean ) => ({ @@ -715,9 +717,10 @@ export const reshapeBridgeSecret = ( updatedAt: secret.updatedAt, isRotatedSecret: secret.isRotatedSecret, rotationId: secret.rotationId, + secretReminderRecipients: secret.secretReminderRecipients || [], ...(secretValueHidden ? { - secretValue: INFISICAL_SECRET_VALUE_HIDDEN_MASK, + secretValue: secret.type === SecretType.Personal ? secret.value : INFISICAL_SECRET_VALUE_HIDDEN_MASK, secretValueHidden: true } : { diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index ca815c6e1..1ef4a2d41 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -544,7 +544,12 @@ export const secretV2BridgeServiceFactory = ({ id: updatedSecret[0].id, ...inputSecret }, - oldSecret: secret, + oldSecret: { + id: secret.id, + secretReminderNote: secret.reminderNote, + secretReminderRepeatDays: secret.reminderRepeatDays, + secretReminderRecipients: secret.secretReminderRecipients?.map((el) => el.user.id) + }, projectId }); @@ -957,7 +962,7 @@ export const secretV2BridgeServiceFactory = ({ const encryptedCachedSecrets = await keyStore.getItem(cacheKey); if (encryptedCachedSecrets) { try { - await keyStore.setExpiry(cacheKey, SECRET_DAL_TTL); + await keyStore.setExpiry(cacheKey, SECRET_DAL_TTL()); const cachedSecrets = secretManagerDecryptor({ cipherTextBlob: Buffer.from(encryptedCachedSecrets, "base64") }); const { secrets, imports = [] } = JSON.parse(cachedSecrets.toString("utf8")) as { secrets: typeof decryptedSecrets; @@ -1127,7 +1132,7 @@ export const secretV2BridgeServiceFactory = ({ plainText: Buffer.from(JSON.stringify(payload)) }).cipherTextBlob; if (encryptedUpdatedCachedSecrets.byteLength < MAX_SECRET_CACHE_BYTES) { - await keyStore.setItemWithExpiry(cacheKey, SECRET_DAL_TTL, encryptedUpdatedCachedSecrets.toString("base64")); + await keyStore.setItemWithExpiry(cacheKey, SECRET_DAL_TTL(), encryptedUpdatedCachedSecrets.toString("base64")); } return payload; } @@ -1174,7 +1179,7 @@ export const secretV2BridgeServiceFactory = ({ plainText: Buffer.from(JSON.stringify(payload)) }).cipherTextBlob; if (encryptedUpdatedCachedSecrets.byteLength < MAX_SECRET_CACHE_BYTES) { - await keyStore.setItemWithExpiry(cacheKey, SECRET_DAL_TTL, encryptedUpdatedCachedSecrets.toString("base64")); + await keyStore.setItemWithExpiry(cacheKey, SECRET_DAL_TTL(), encryptedUpdatedCachedSecrets.toString("base64")); } return payload; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 11149c605..f4a27d4c5 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -94,6 +94,7 @@ export type TUpdateSecretDTO = TProjectPermission & { skipMultilineEncoding?: boolean; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; + secretReminderRecipients?: string[] | null; metadata?: { source?: string; }; @@ -220,7 +221,7 @@ export type TFnSecretBulkDelete = { tx?: Knex; secretDAL: Pick; secretQueueService: { - removeSecretReminder: (data: TRemoveSecretReminderDTO) => Promise; + removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise; }; }; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index f08a5a04c..e5f3acdea 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -407,6 +407,7 @@ export const decryptSecretRaw = ( id: secret.id, user: secret.userId, tags: secret.tags?.map((el) => ({ ...el, name: el.slug })), + secretReminderRecipients: [], skipMultilineEncoding: secret.skipMultilineEncoding, secretReminderRepeatDays: secret.secretReminderRepeatDays, secretReminderNote: secret.secretReminderNote, @@ -758,7 +759,7 @@ export const fnSecretBulkDelete = async ({ deletedSecrets .filter(({ secretReminderRepeatDays }) => Boolean(secretReminderRepeatDays)) .map(({ id, secretReminderRepeatDays }) => - secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }) + secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }, tx) ) ); diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 5791c415d..6a0868741 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -1,11 +1,13 @@ /* eslint-disable no-await-in-loop */ import opentelemetry from "@opentelemetry/api"; import { AxiosError } from "axios"; +import { Knex } from "knex"; import { ProjectMembershipRole, ProjectUpgradeStatus, ProjectVersion, + SecretType, TSecretSnapshotSecretsV2, TSecretVersionsV2 } from "@app/db/schemas"; @@ -53,6 +55,7 @@ import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-sche import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns"; +import { TSecretReminderRecipientsDALFactory } from "../secret-reminder-recipients/secret-reminder-recipients-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { expandSecretReferencesFactory, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; @@ -109,6 +112,10 @@ type TSecretQueueFactoryDep = { orgService: Pick; projectUserMembershipRoleDAL: Pick; resourceMetadataDAL: Pick; + secretReminderRecipientsDAL: Pick< + TSecretReminderRecipientsDALFactory, + "delete" | "findUsersBySecretId" | "insertMany" | "transaction" + >; secretSyncQueue: Pick; }; @@ -170,6 +177,7 @@ export const secretQueueFactory = ({ projectUserMembershipRoleDAL, projectKeyDAL, resourceMetadataDAL, + secretReminderRecipientsDAL, secretSyncQueue }: TSecretQueueFactoryDep) => { const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); @@ -178,7 +186,11 @@ export const secretQueueFactory = ({ unit: "1" }); - const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { + const removeSecretReminder = async ({ deleteRecipients = true, ...dto }: TRemoveSecretReminderDTO, tx?: Knex) => { + if (deleteRecipients) { + await secretReminderRecipientsDAL.delete({ secretId: dto.secretId }, tx); + } + const appCfg = getConfig(); await queueService.stopRepeatableJob( QueueName.SecretReminder, @@ -224,7 +236,12 @@ export const secretQueueFactory = ({ .replace(":", "-"); }; - const addSecretReminder = async ({ oldSecret, newSecret, projectId }: TCreateSecretReminderDTO) => { + const addSecretReminder = async ({ + oldSecret, + newSecret, + projectId, + deleteRecipients = true + }: TCreateSecretReminderDTO) => { try { const appCfg = getConfig(); @@ -246,7 +263,8 @@ export const secretQueueFactory = ({ if (oldSecret.secretReminderRepeatDays) { await removeSecretReminder({ repeatDays: oldSecret.secretReminderRepeatDays, - secretId: oldSecret.id + secretId: oldSecret.id, + deleteRecipients }); } @@ -283,29 +301,57 @@ export const secretQueueFactory = ({ }; const handleSecretReminder = async ({ newSecret, oldSecret, projectId }: THandleReminderDTO) => { - const { secretReminderRepeatDays, secretReminderNote } = newSecret; + const { secretReminderRepeatDays, secretReminderNote, secretReminderRecipients } = newSecret; - if (newSecret.type !== "personal" && secretReminderRepeatDays !== undefined) { - if ( - (secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) || - (secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote) - ) { - await addSecretReminder({ - oldSecret, - newSecret, - projectId - }); - } else if ( - secretReminderRepeatDays === null && - secretReminderNote === null && - oldSecret.secretReminderRepeatDays - ) { - await removeSecretReminder({ - secretId: oldSecret.id, - repeatDays: oldSecret.secretReminderRepeatDays - }); + const recipientsUpdated = + secretReminderRecipients?.some( + (newId) => !oldSecret.secretReminderRecipients?.find((oldId) => newId === oldId) + ) || secretReminderRecipients?.length !== oldSecret.secretReminderRecipients?.length; + + await secretReminderRecipientsDAL.transaction(async (tx) => { + if (newSecret.type !== SecretType.Personal && secretReminderRepeatDays !== undefined) { + if ( + (secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) || + (secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote) + ) { + await addSecretReminder({ + oldSecret, + newSecret, + projectId, + deleteRecipients: false + }); + } else if ( + secretReminderRepeatDays === null && + secretReminderNote === null && + oldSecret.secretReminderRepeatDays + ) { + await removeSecretReminder({ + secretId: oldSecret.id, + repeatDays: oldSecret.secretReminderRepeatDays + }); + } } - } + + if (recipientsUpdated) { + // if no recipients, delete all existing recipients + if (!secretReminderRecipients?.length) { + const existingRecipients = await secretReminderRecipientsDAL.findUsersBySecretId(newSecret.id, tx); + if (existingRecipients) { + await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx); + } + } else { + await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx); + await secretReminderRecipientsDAL.insertMany( + secretReminderRecipients.map((r) => ({ + secretId: newSecret.id, + userId: r, + projectId + })), + tx + ); + } + } + }); }; const createManySecretsRawFn = createManySecretsRawFnFactory({ projectDAL, @@ -1071,6 +1117,8 @@ export const secretQueueFactory = ({ const secret = await secretV2BridgeDAL.findById(data.secretId); const [folder] = await folderDAL.findSecretPathByFolderIds(project.id, [secret.folderId]); + const recipients = await secretReminderRecipientsDAL.findUsersBySecretId(data.secretId); + if (!organization) { logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`); return; @@ -1088,10 +1136,14 @@ export const secretQueueFactory = ({ return; } + const selectedRecipients = recipients?.length + ? recipients.map((r) => r.email as string) + : projectMembers.map((m) => m.user.email as string); + await smtpService.sendMail({ template: SmtpTemplates.SecretReminder, subjectLine: "Infisical secret reminder", - recipients: [...projectMembers.map((m) => m.user.email)].filter((email) => email).map((email) => email as string), + recipients: selectedRecipients, substitutions: { reminderNote: data.note, // May not be present. projectName: project.name, diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index a82b04833..46ed7ef83 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -546,10 +546,13 @@ export const secretServiceFactory = ({ for await (const secret of secrets) { if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) { - await secretQueueService.removeSecretReminder({ - repeatDays: secret.secretReminderRepeatDays, - secretId: secret.id - }); + await secretQueueService.removeSecretReminder( + { + repeatDays: secret.secretReminderRepeatDays, + secretId: secret.id + }, + tx + ); } } @@ -685,6 +688,7 @@ export const secretServiceFactory = ({ ...secret, workspace: projectId, environment, + secretReminderRecipients: [], secretPath: groupedPaths[secret.folderId][0].path })) }; @@ -1073,10 +1077,13 @@ export const secretServiceFactory = ({ for await (const secret of secrets) { if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) { - await secretQueueService.removeSecretReminder({ - repeatDays: secret.secretReminderRepeatDays, - secretId: secret.id - }); + await secretQueueService.removeSecretReminder( + { + repeatDays: secret.secretReminderRepeatDays, + secretId: secret.id + }, + tx + ); } } const secretValueHidden = !hasSecretReadValueOrDescribePermission( @@ -1786,6 +1793,7 @@ export const secretServiceFactory = ({ tagIds, secretReminderNote, secretReminderRepeatDays, + secretReminderRecipients, metadata, secretComment, newSecretName, @@ -1828,6 +1836,7 @@ export const secretServiceFactory = ({ tagIds, reminderNote: secretReminderNote, reminderRepeatDays: secretReminderRepeatDays, + secretReminderRecipients, secretMetadata } ] @@ -1837,8 +1846,9 @@ export const secretServiceFactory = ({ } const secret = await secretV2BridgeService.updateSecret({ secretReminderRepeatDays, - skipMultilineEncoding, secretReminderNote, + secretReminderRecipients, + skipMultilineEncoding, tagIds, secretComment, secretPath, diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index be036cab8..30e3dfafa 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -22,9 +22,13 @@ import { SecretUpdateMode } from "../secret-v2-bridge/secret-v2-bridge-types"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; -type TPartialSecret = Pick; +type TPartialSecret = Pick & { + secretReminderRecipients?: string[] | null; +}; -type TPartialInputSecret = Pick; +type TPartialInputSecret = Pick & { + secretReminderRecipients?: string[] | null; +}; export const FailedIntegrationSyncEmailsPayloadSchema = z.object({ projectId: z.string(), @@ -258,6 +262,7 @@ export type TUpdateSecretRawDTO = TProjectPermission & { skipMultilineEncoding?: boolean; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; + secretReminderRecipients?: string[] | null; metadata?: { source?: string; }; @@ -374,7 +379,7 @@ export type TFnSecretBulkDelete = { tx?: Knex; secretDAL: Pick; secretQueueService: { - removeSecretReminder: (data: TRemoveSecretReminderDTO) => Promise; + removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise; }; }; @@ -405,11 +410,14 @@ export type TCreateSecretReminderDTO = { oldSecret: TPartialSecret; newSecret: TPartialSecret; projectId: string; + + deleteRecipients?: boolean; }; export type TRemoveSecretReminderDTO = { secretId: string; repeatDays: number; + deleteRecipients?: boolean; }; export type TBackFillSecretReferencesDTO = TProjectPermission; diff --git a/cli/go.mod b/cli/go.mod index c713417e2..52cb79f38 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -12,7 +12,7 @@ require ( github.com/fatih/semgroup v1.2.0 github.com/gitleaks/go-gitdiff v0.8.0 github.com/h2non/filetype v1.1.3 - github.com/infisical/go-sdk v0.5.8 + github.com/infisical/go-sdk v0.5.92 github.com/infisical/infisical-kmip v0.3.5 github.com/mattn/go-isatty v0.0.20 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a diff --git a/cli/go.sum b/cli/go.sum index 68bce9cd3..49566f1cc 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -277,8 +277,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/infisical/go-sdk v0.5.8 h1:bCetYLp7HWt8DnU9KPh1n8n3z5pjmunkGDB4bA3lEFs= -github.com/infisical/go-sdk v0.5.8/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= +github.com/infisical/go-sdk v0.5.92 h1:PoCnVndrd6Dbkipuxl9fFiwlD5vCKsabtQo09mo8lUE= +github.com/infisical/go-sdk v0.5.92/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= github.com/infisical/infisical-kmip v0.3.5 h1:QM3s0e18B+mYv3a9HQNjNAlbwZJBzXq5BAJM2scIeiE= github.com/infisical/infisical-kmip v0.3.5/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index a11e4da4c..7f74d8ee6 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -631,18 +631,18 @@ func sshConnect(cmd *cobra.Command, args []string) { infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } - writeHostCaToFile, err := cmd.Flags().GetBool("writeHostCaToFile") + writeHostCaToFile, err := cmd.Flags().GetBool("write-host-ca-to-file") if err != nil { - util.HandleError(err, "Unable to parse --writeHostCaToFile flag") + util.HandleError(err, "Unable to parse --write-host-ca-to-file flag") } - outFilePath, err := cmd.Flags().GetString("outFilePath") + outFilePath, err := cmd.Flags().GetString("out-file-path") if err != nil { util.HandleError(err, "Unable to parse flag") } hostname, _ := cmd.Flags().GetString("hostname") - loginUser, _ := cmd.Flags().GetString("loginUser") + loginUser, _ := cmd.Flags().GetString("login-user") var outputDir, privateKeyPath, publicKeyPath, signedKeyPath string if outFilePath != "" { @@ -722,17 +722,24 @@ func sshConnect(cmd *cobra.Command, args []string) { } else { hostNames := make([]string, len(hosts)) for i, h := range hosts { - hostNames[i] = h.Hostname + if h.Alias != "" { + hostNames[i] = h.Alias + } else { + hostNames[i] = h.Hostname + } } + hostPrompt := promptui.Select{ Label: "Select an SSH Host", Items: hostNames, Size: 10, } + hostIdx, _, err := hostPrompt.Run() if err != nil { util.HandleError(err, "Prompt failed") } + selectedHost = hosts[hostIdx] } @@ -893,24 +900,33 @@ func sshAddHost(cmd *cobra.Command, args []string) { util.PrintErrorMessageAndExit("You must provide --hostname") } - writeUserCaToFile, err := cmd.Flags().GetBool("writeUserCaToFile") + alias, err := cmd.Flags().GetString("alias") if err != nil { - util.HandleError(err, "Unable to parse --writeUserCaToFile flag") + util.HandleError(err, "Unable to parse --alias flag") + } + + // if alias == "" { + // util.PrintErrorMessageAndExit("You must provide --alias") + // } + + writeUserCaToFile, err := cmd.Flags().GetBool("write-user-ca-to-file") + if err != nil { + util.HandleError(err, "Unable to parse --write-user-ca-to-file flag") } - userCaOutFilePath, err := cmd.Flags().GetString("userCaOutFilePath") + userCaOutFilePath, err := cmd.Flags().GetString("user-ca-out-file-path") if err != nil { - util.HandleError(err, "Unable to parse --userCaOutFilePath flag") + util.HandleError(err, "Unable to parse --user-ca-out-file-path flag") } - writeHostCertToFile, err := cmd.Flags().GetBool("writeHostCertToFile") + writeHostCertToFile, err := cmd.Flags().GetBool("write-host-cert-to-file") if err != nil { - util.HandleError(err, "Unable to parse --writeHostCertToFile flag") + util.HandleError(err, "Unable to parse --write-host-cert-to-file flag") } - configureSshd, err := cmd.Flags().GetBool("configureSshd") + configureSshd, err := cmd.Flags().GetBool("configure-sshd") if err != nil { - util.HandleError(err, "Unable to parse --configureSshd flag") + util.HandleError(err, "Unable to parse --configure-sshd flag") } forceOverwrite, err := cmd.Flags().GetBool("force") @@ -919,7 +935,7 @@ func sshAddHost(cmd *cobra.Command, args []string) { } if configureSshd && (!writeUserCaToFile || !writeHostCertToFile) { - util.PrintErrorMessageAndExit("--configureSshd requires both --writeUserCaToFile and --writeHostCertToFile to also be set") + util.PrintErrorMessageAndExit("--configure-sshd requires both --write-user-ca-to-file and --write-host-cert-to-file to also be set") } // Pre-check for file overwrites before proceeding @@ -927,7 +943,7 @@ func sshAddHost(cmd *cobra.Command, args []string) { if strings.HasPrefix(userCaOutFilePath, "~") { homeDir, err := os.UserHomeDir() if err != nil { - util.HandleError(err, "Unable to resolve ~ in userCaOutFilePath") + util.HandleError(err, "Unable to resolve ~ in user-ca-out-file-path") } userCaOutFilePath = strings.Replace(userCaOutFilePath, "~", homeDir, 1) } @@ -998,6 +1014,7 @@ func sshAddHost(cmd *cobra.Command, args []string) { host, err := client.Ssh().AddSshHost(infisicalSdk.AddSshHostOptions{ ProjectID: projectId, Hostname: hostname, + Alias: alias, }) if err != nil { util.HandleError(err, "Failed to register SSH host") @@ -1112,11 +1129,12 @@ func init() { sshAddHostCmd.Flags().String("token", "", "Use a machine identity access token") sshAddHostCmd.Flags().String("projectId", "", "Project ID the host belongs to (required)") sshAddHostCmd.Flags().String("hostname", "", "Hostname of the SSH host (required)") + sshAddHostCmd.Flags().String("alias", "", "Alias for the SSH host") sshAddHostCmd.Flags().Bool("write-user-ca-to-file", false, "Write User CA public key to /etc/ssh/infisical_user_ca.pub") sshAddHostCmd.Flags().String("user-ca-out-file-path", "/etc/ssh/infisical_user_ca.pub", "Custom file path to write the User CA public key") sshAddHostCmd.Flags().Bool("write-host-cert-to-file", false, "Write SSH host certificate to /etc/ssh/ssh_host__key-cert.pub") - sshAddHostCmd.Flags().Bool("configure-sshd", false, "Update TrustedUserCAKeys, HostKey, and HostCertificate in the sshd_config file") - sshAddHostCmd.Flags().Bool("force", false, "Force overwrite of existing certificate files as part of writeUserCaToFile and writeHostCertToFile") + sshAddHostCmd.Flags().Bool("configure-sshd", false, "Update `TrustedUserCAKeys`, `HostKey`, and `HostCertificate` in the `/etc/ssh/sshd_config` file") + sshAddHostCmd.Flags().Bool("force", false, "Force overwrite of existing certificate files as part of `--write-user-ca-to-file` and `--write-host-cert-to-file`") sshCmd.AddCommand(sshAddHostCmd) diff --git a/docs/cli/commands/ssh.mdx b/docs/cli/commands/ssh.mdx index d99a69dda..bb7017135 100644 --- a/docs/cli/commands/ssh.mdx +++ b/docs/cli/commands/ssh.mdx @@ -22,125 +22,72 @@ This command enables you to obtain SSH credentials used to access a remote host. The hostname of the SSH host to connect to. If not provided, you will be prompted to select from available hosts. - + The login user for the SSH connection. If not provided, you will be prompted to select from available login users. - + Whether to write the Host CA public key to `~/.ssh/known_hosts` if it doesn't already exist. Default value: `true` - + The path to write the SSH credentials to such as `~/.ssh`, `./some_folder`, `./some_folder/id_rsa-cert.pub`. If not provided, the credentials will be added to the SSH agent and used to establish an interactive SSH connection. - An authenticated token to use to authenticate with Infisical. + Use a machine identity access token - - This command is used to issue SSH credentials (SSH certificate, public key, and private key) against a certificate template. - - We recommend using the `--addToAgent` flag to automatically load issued SSH credentials to the SSH agent. + + This command is used to register a new SSH host with Infisical. + This command can be used with the `--write-user-ca-to-file`, `--write-host-cert-to-file`, and `--configure-sshd` flags + to also configure the host's SSH daemon with the necessary certificate authority and host certificate settings. + ```bash - $ infisical ssh issue-credentials --certificateTemplateId= --principals= --addToAgent + $ infisical ssh add-host --projectId= --hostname= ``` ### Flags - - The ID of the SSH certificate template to issue SSH credentials for. + + Project ID the host belongs to (required) - - A comma-separated list of principals (i.e. usernames like `ec2-user` or hostnames) to issue SSH credentials for. + + Hostname of the SSH host (required) - - Whether to add issued SSH credentials to the SSH agent. + + Alias for the SSH host (optional) + + + Write User CA public key to `/etc/ssh/infisical_user_ca.pub` + + Default value: `false` + + + Custom file path to write the User CA public key + + Default value: `/etc/ssh/infisical_user_ca.pub` + + + Write SSH host certificate to `/etc/ssh/ssh_host__key-cert.pub` + + Default value: `false` + + + Update `TrustedUserCAKeys`, `HostKey`, and `HostCertificate` in the `/etc/ssh/sshd_config` file Default value: `false` - Note that either the `--outFilePath` or `--addToAgent` flag must be set for the sub-command to execute successfully. + Note: This flag requires both --write-user-ca-to-file and --write-host-cert-to-file to be set - - The path to write the SSH credentials to such as `~/.ssh`, `./some_folder`, `./some_folder/id_rsa-cert.pub`. If not provided, the credentials will be saved to the current working directory where the command is run. + + Force overwrite of existing certificate files as part of `--write-user-ca-to-file` and `--write-host-cert-to-file` - Note that either the `--outFilePath` or `--addToAgent` flag must be set for the sub-command to execute successfully. - - - The key algorithm to issue SSH credentials for. - - Default value: `RSA_2048` - - Available options: `RSA_2048`, `RSA_4096`, `EC_prime256v1`, `EC_secp384r1`. - - - The certificate type to issue SSH credentials for. - - Default value: `user` - - Available options: `user` or `host` - - - The time-to-live (TTL) for the issued SSH certificate (e.g. `2 days`, `1d`, `2h`, `1y`). - - Defaults to the Default TTL value set in the certificate template. - - - A custom Key ID to issue SSH credentials for. - - Defaults to the autogenerated Key ID by Infisical. + Default value: `false` - An authenticated token to use to issue SSH credentials. - - - - - - This command is used to sign an existing SSH public key against a certificate template; the command outputs the corresponding signed SSH certificate. - - ```bash - $ infisical ssh sign-key --certificateTemplateId= --publicKey= --principals= --outFilePath= - ``` - - The ID of the SSH certificate template to issue the SSH certificate for. - - - The public key to sign. - - Note that either the `--publicKey` or `--publicKeyFilePath` flag must be set for the sub-command to execute successfully. - - - The path to the public key file to sign. - - Note that either the `--publicKey` or `--publicKeyFilePath` flag must be set for the sub-command to execute successfully. - - - A comma-separated list of principals (i.e. usernames like `ec2-user` or hostnames) to issue SSH credentials for. - - - The path to write the SSH certificate to such as `~/.ssh/id_rsa-cert.pub`; the specified file must have the `.pub` extension. If not provided, the credentials will be saved to the directory of the specified `--publicKeyFilePath` or the current working directory where the command is run. - - - The certificate type to issue SSH credentials for. - - Default value: `user` - - Available options: `user` or `host` - - - The time-to-live (TTL) for the issued SSH certificate (e.g. `2 days`, `1d`, `2h`, `1y`). - - Defaults to the Default TTL value set in the certificate template. - - - A custom Key ID to issue SSH credentials for. - - Defaults to the autogenerated Key ID by Infisical. - - - An authenticated token to use to issue SSH credentials. + Use a machine identity access token diff --git a/docs/documentation/platform/access-controls/assume-privilege.mdx b/docs/documentation/platform/access-controls/assume-privilege.mdx new file mode 100644 index 000000000..a38fd65f0 --- /dev/null +++ b/docs/documentation/platform/access-controls/assume-privilege.mdx @@ -0,0 +1,40 @@ +--- +title: "Assume Privileges" +description: "Learn how to temporarily assume the privileges of a user or machine identity within a project." +--- + +This feature allows authorized users to temporarily take on the permissions of another user or identity. It helps administrators and access managers test and verify permissions before granting access, ensuring everything is set up correctly. +It also reduces back-and-forth with end users when troubleshooting permission-related issues. + +## How It Works + +When an authorized user activates assume privileges mode, they temporarily inherit the target user or identity’s permissions for up to one hour. +During this time, they can perform actions within the system with the same level of access as the target user. + +- **Permission-based**: Only permissions are inherited, not the full identity +- **Time-limited**: Access automatically expires after one hour +- **Audited**: All actions are logged under the original user's account. This means any action taken during the session will be recorded under the entity assuming the privileges, not the target entity. +- **Authorization required**: Only users with the specific **assume privilege** permission can use this feature +- **Scoped to a single project**: You can only assume privileges for one project at a time + +## How to Assume Privileges + + + + Click on the user or identity you want to assume. + + ![Access control page](/images/platform/access-controls/assume-privileges/access-control.png) + + + + Click **Assume Privilege**, then type `assume` to confirm and start your session. + + ![Access control detail page](/images/platform/access-controls/assume-privileges/access-control-detail.png) + + + + You will see a yellow banner indicating that your assume privilege session is active. You can exit at any time by clicking **Exit**. + + ![session start](/images/platform/access-controls/assume-privileges/session-start.png) + + \ No newline at end of file diff --git a/docs/documentation/platform/github-org-sync.mdx b/docs/documentation/platform/github-org-sync.mdx new file mode 100644 index 000000000..00c9bf4c4 --- /dev/null +++ b/docs/documentation/platform/github-org-sync.mdx @@ -0,0 +1,56 @@ +--- +title: "GitHub Team Sync" +description: "Learn how to automatically synchronize your GitHub teams with Infisical Groups." +--- + +## Overview + +The GitHub Organization Synchronization feature streamlines user and group management by automatically syncing users belonging to your specified GitHub organization with corresponding groups within Infisical. This integration ensures that users logging in via GitHub are automatically added to or removed from Infisical groups based on their team memberships within your GitHub organization. + +## Configuration + +To enable and configure GitHub Organization Synchronization, follow these steps: + + + + 1. Navigate to **Organization Settings** and select the **Security Tab**. + ![config](../../images/platform/external-syncs/github-org-sync-section.png) + 2. Click the **Configure** button and provide the name of your GitHub Organization. + ![config-modal](../../images/platform/external-syncs/github-org-sync-config-modal.png) + + + Toggle ON GitHub Organization sync to activate sync. + ![toggle-on](../../images/platform/external-syncs/github-org-sync-active.png) + + + Connecting the Infisical OAuth application grants it permission to **read:org** details. This approval is done by selecting your organization during the GitHub OAuth login process. + + 1. Initiate the login process via the GitHub OAuth flow. + ![oauth-flow-start](../../images/platform/external-syncs/github-org-sync-oauth-flow-start.png) + 2. Select the organization you have connected. + 3. Grant access to Infisical oauth application to your configured organization. Infisical shown here is an organization, just for walkthrough. + ![grant-access](../../images/platform/external-syncs/github-org-sync-oauth.png) + + + This action only needs to be done once and authorizes the Infisical OAuth app to read organization details, including team information. + The following users don't need to select organization in GitHub on login anymore. + + + + + +## Working + +Once configured, the GitHub Organization Synchronization feature functions as follows: + +When a user logs in via the GitHub OAuth flow and selects the configured organization, the system will then automatically synchronize the teams they are a part of in GitHub with corresponding groups in Infisical. + +## Troubleshooting + + + If you encounter an error related to this, it indicates that you need to approve the Infisical OAuth application within your GitHub organization. + + You can verify the application's approval status by navigating to **https://github.com/organizations/__your-organization__/settings/oauth_application_policy**. Replace `__your-organization__` with the actual name of your GitHub organization. + + ![check-approval](../../images/platform/external-syncs/github-org-sync-approved-oauth-apps.png) + diff --git a/docs/images/platform/access-controls/assume-privileges/access-control-detail.png b/docs/images/platform/access-controls/assume-privileges/access-control-detail.png new file mode 100644 index 000000000..e0844b8f4 Binary files /dev/null and b/docs/images/platform/access-controls/assume-privileges/access-control-detail.png differ diff --git a/docs/images/platform/access-controls/assume-privileges/access-control.png b/docs/images/platform/access-controls/assume-privileges/access-control.png new file mode 100644 index 000000000..aa6974cdd Binary files /dev/null and b/docs/images/platform/access-controls/assume-privileges/access-control.png differ diff --git a/docs/images/platform/access-controls/assume-privileges/session-start.png b/docs/images/platform/access-controls/assume-privileges/session-start.png new file mode 100644 index 000000000..1aab112c4 Binary files /dev/null and b/docs/images/platform/access-controls/assume-privileges/session-start.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-active.png b/docs/images/platform/external-syncs/github-org-sync-active.png new file mode 100644 index 000000000..bb5ce1ca3 Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-active.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-approved-oauth-apps.png b/docs/images/platform/external-syncs/github-org-sync-approved-oauth-apps.png new file mode 100644 index 000000000..d65d5a43f Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-approved-oauth-apps.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-config-modal.png b/docs/images/platform/external-syncs/github-org-sync-config-modal.png new file mode 100644 index 000000000..b856048e3 Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-config-modal.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-oauth-flow-start.png b/docs/images/platform/external-syncs/github-org-sync-oauth-flow-start.png new file mode 100644 index 000000000..9810e3ddf Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-oauth-flow-start.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-oauth.png b/docs/images/platform/external-syncs/github-org-sync-oauth.png new file mode 100644 index 000000000..68b13c3a7 Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-oauth.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-section.png b/docs/images/platform/external-syncs/github-org-sync-section.png new file mode 100644 index 000000000..dad1fa425 Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-section.png differ diff --git a/docs/integrations/secret-syncs/teamcity.mdx b/docs/integrations/secret-syncs/teamcity.mdx index e79fc0f0c..af4c8d76a 100644 --- a/docs/integrations/secret-syncs/teamcity.mdx +++ b/docs/integrations/secret-syncs/teamcity.mdx @@ -34,7 +34,7 @@ description: "Learn how to configure a TeamCity Sync for Infisical." - **Build Configuration**: The build configuration to sync secrets to. - Not including a Build Configuration will sync secrets to the entire project. + Not including a Build Configuration will sync secrets to the project. 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. @@ -44,6 +44,11 @@ description: "Learn how to configure a TeamCity Sync for Infisical." - **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 TeamCity when keys conflict. - **Import Secrets (Prioritize TeamCity)**: Imports secrets from the destination endpoint before syncing, prioritizing values from TeamCity over Infisical when keys conflict. + + + Infisical only syncs secrets from within the target scope; inherited secrets will not be imported. + + - **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. diff --git a/docs/mint.json b/docs/mint.json index 510496cd1..a010706ac 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -160,6 +160,7 @@ }, "documentation/platform/access-controls/additional-privileges", "documentation/platform/access-controls/temporary-access", + "documentation/platform/access-controls/assume-privilege", "documentation/platform/access-controls/access-requests", "documentation/platform/access-controls/project-access-requests", "documentation/platform/pr-workflows", @@ -300,7 +301,8 @@ "documentation/platform/scim/jumpcloud", "documentation/platform/scim/group-mappings" ] - } + }, + "documentation/platform/github-org-sync" ] }, { @@ -887,8 +889,8 @@ ] }, { - "group": "LDAP Password", - "pages": [ + "group": "LDAP Password", + "pages": [ "api-reference/endpoints/secret-rotations/ldap-password/create", "api-reference/endpoints/secret-rotations/ldap-password/delete", "api-reference/endpoints/secret-rotations/ldap-password/get-by-id", diff --git a/frontend/src/components/v2/ConfirmActionModal/ConfirmActionModal.tsx b/frontend/src/components/v2/ConfirmActionModal/ConfirmActionModal.tsx new file mode 100644 index 000000000..c6bbec305 --- /dev/null +++ b/frontend/src/components/v2/ConfirmActionModal/ConfirmActionModal.tsx @@ -0,0 +1,113 @@ +import { ReactNode, useEffect, useState } from "react"; + +import { useToggle } from "@app/hooks"; + +import { Button } from "../Button"; +import { FormControl } from "../FormControl"; +import { Input } from "../Input"; +import { Modal, ModalClose, ModalContent } from "../Modal"; + +type Props = { + isOpen?: boolean; + onClose?: () => void; + onChange?: (isOpen: boolean) => void; + confirmKey: string; + title: string; + subTitle?: string; + onConfirmed: () => Promise; + buttonText?: string; + formContent?: ReactNode; + children?: ReactNode; + confirmationMessage?: ReactNode; +}; + +export const ConfirmActionModal = ({ + isOpen, + onClose, + onChange, + confirmKey, + onConfirmed, + title, + subTitle = "This action is irreversible.", + buttonText = "Yes", + formContent, + confirmationMessage, + children +}: Props): JSX.Element => { + const [inputData, setInputData] = useState(""); + const [isLoading, setIsLoading] = useToggle(); + + useEffect(() => { + setInputData(""); + }, [isOpen]); + + const onDelete = async () => { + setIsLoading.on(); + try { + await onConfirmed(); + } finally { + setIsLoading.off(); + } + }; + + return ( + { + setInputData(""); + if (onChange) onChange(isOpenState); + }} + > + + + + + + + } + onClose={onClose} + > + {formContent} +
{ + evt.preventDefault(); + if (confirmKey === inputData) onDelete(); + }} + > + + {confirmationMessage || ( + <> + Type {confirmKey} to perform this action + + )} + + } + className="mb-0" + > + setInputData(e.target.value)} + placeholder={`Type ${confirmKey} here`} + /> + + {children} +
+
+
+ ); +}; diff --git a/frontend/src/components/v2/ConfirmActionModal/index.tsx b/frontend/src/components/v2/ConfirmActionModal/index.tsx new file mode 100644 index 000000000..8c292343f --- /dev/null +++ b/frontend/src/components/v2/ConfirmActionModal/index.tsx @@ -0,0 +1 @@ +export { ConfirmActionModal } from "./ConfirmActionModal"; diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index 49c10805c..f17c5763f 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -14,7 +14,7 @@ export const PageHeader = ({ title, description, children, className }: Props) =

{title}

-
{children}
+
{children}
{description}
diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index 96f79e65f..c8b8f2ee6 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -93,6 +93,7 @@ export const SecretInput = forwardRef( onFocus={(evt) => { onFocus?.(evt); setIsSecretFocused.on(); + evt.currentTarget.select(); }} disabled={isDisabled} spellCheck={false} diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 9dcf72e40..ede71e324 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -6,6 +6,7 @@ export * from "./Breadcrumb"; export * from "./Button"; export * from "./Card"; export * from "./Checkbox"; +export * from "./ConfirmActionModal"; export * from "./ContentLoader"; export * from "./DatePicker"; export * from "./DeleteActionModal"; diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 1c2de52e2..2dbfaacb7 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -35,7 +35,8 @@ export enum OrgPermissionSubjects { AppConnections = "app-connections", Kmip = "kmip", Gateway = "gateway", - SecretShare = "secret-share" + SecretShare = "secret-share", + GithubOrgSync = "github-org-sync" } export enum OrgPermissionAdminConsoleAction { @@ -93,6 +94,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Settings] | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Scim] + | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] diff --git a/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx b/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx index f398eb0db..a824f6fa6 100644 --- a/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx +++ b/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx @@ -14,12 +14,13 @@ export const useProjectPermission = () => { strict: false, select: (el) => el?.projectId }); + if (!projectId) { throw new Error("useProjectPermission to be used within "); } const { - data: { permission, membership } + data: { permission, membership, assumedPrivilegeDetails } } = useSuspenseQuery({ queryKey: roleQueryKeys.getUserProjectPermissions({ workspaceId: projectId }), queryFn: () => fetchUserProjectPermissions({ workspaceId: projectId }), @@ -29,6 +30,7 @@ export const useProjectPermission = () => { const ability = evaluatePermissionsAbility(rule); return { permission: ability, + assumedPrivilegeDetails: data.assumedPrivilegeDetails, membership: { ...data.membership, roles: data.membership.roles.map(({ role }) => role) @@ -42,5 +44,5 @@ export const useProjectPermission = () => { [] ); - return { permission, membership, hasProjectRole }; + return { permission, membership, hasProjectRole, assumedPrivilegeDetails }; }; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index a327913d9..f7ba69e17 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -58,7 +58,8 @@ export enum ProjectPermissionIdentityActions { Create = "create", Edit = "edit", Delete = "delete", - GrantPrivileges = "grant-privileges" + GrantPrivileges = "grant-privileges", + AssumePrivileges = "assume-privileges" } export enum ProjectPermissionMemberActions { @@ -66,7 +67,8 @@ export enum ProjectPermissionMemberActions { Create = "create", Edit = "edit", Delete = "delete", - GrantPrivileges = "grant-privileges" + GrantPrivileges = "grant-privileges", + AssumePrivileges = "assume-privileges" } export enum ProjectPermissionGroupActions { @@ -247,7 +249,7 @@ export type ProjectPermissionSet = ] | [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Tags] - | [ProjectPermissionActions, ProjectPermissionSub.Member] + | [ProjectPermissionMemberActions, ProjectPermissionSub.Member] | [ProjectPermissionActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] @@ -258,7 +260,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ - ProjectPermissionActions, + ProjectPermissionIdentityActions, ( | ProjectPermissionSub.Identity | (ForcedSubject & IdentityManagementSubjectFields) diff --git a/frontend/src/hooks/api/assumePrivileges/index.tsx b/frontend/src/hooks/api/assumePrivileges/index.tsx new file mode 100644 index 000000000..ab4b122c5 --- /dev/null +++ b/frontend/src/hooks/api/assumePrivileges/index.tsx @@ -0,0 +1 @@ +export { useAssumeProjectPrivileges, useRemoveAssumeProjectPrivilege } from "./mutations"; diff --git a/frontend/src/hooks/api/assumePrivileges/mutations.tsx b/frontend/src/hooks/api/assumePrivileges/mutations.tsx new file mode 100644 index 000000000..50e4e5b6c --- /dev/null +++ b/frontend/src/hooks/api/assumePrivileges/mutations.tsx @@ -0,0 +1,28 @@ +import { useMutation } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TProjectAssumePrivilegesDTO } from "./types"; + +export const useAssumeProjectPrivileges = () => + useMutation({ + mutationFn: async ({ projectId, actorId, actorType }: TProjectAssumePrivilegesDTO) => { + const { data } = await apiRequest.post<{ message: string }>( + `/api/v1/workspace/${projectId}/assume-privileges`, + { actorId, actorType } + ); + + return data; + } + }); + +export const useRemoveAssumeProjectPrivilege = () => + useMutation({ + mutationFn: async ({ projectId }: { projectId: string }) => { + const { data } = await apiRequest.delete<{ message: string }>( + `/api/v1/workspace/${projectId}/assume-privileges` + ); + + return data; + } + }); diff --git a/frontend/src/hooks/api/assumePrivileges/types.ts b/frontend/src/hooks/api/assumePrivileges/types.ts new file mode 100644 index 000000000..07e14d518 --- /dev/null +++ b/frontend/src/hooks/api/assumePrivileges/types.ts @@ -0,0 +1,7 @@ +import { ActorType } from "../auditLogs/enums"; + +export type TProjectAssumePrivilegesDTO = { + projectId: string; + actorType: ActorType; + actorId: string; +}; diff --git a/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx b/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx new file mode 100644 index 000000000..585426469 --- /dev/null +++ b/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx @@ -0,0 +1,6 @@ +export { + useCreateGithubSyncOrgConfig, + useDeleteGithubSyncOrgConfig, + useUpdateGithubSyncOrgConfig +} from "./mutations"; +export { githubOrgSyncConfigQueryKeys } from "./queries"; diff --git a/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx b/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx new file mode 100644 index 000000000..0cb9b56e8 --- /dev/null +++ b/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { githubOrgSyncConfigQueryKeys } from "./queries"; +import { TCreateGithubOrgSyncDTO, TUpdateGithubOrgSyncDTO } from "./types"; + +export const useCreateGithubSyncOrgConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (dto: TCreateGithubOrgSyncDTO) => { + return apiRequest.post("/api/v1/github-org-sync-config", dto); + }, + onSuccess: () => { + queryClient.invalidateQueries(githubOrgSyncConfigQueryKeys.get()); + } + }); +}; + +export const useUpdateGithubSyncOrgConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (dto: TUpdateGithubOrgSyncDTO) => { + return apiRequest.patch("/api/v1/github-org-sync-config", dto); + }, + onSuccess: () => { + queryClient.invalidateQueries(githubOrgSyncConfigQueryKeys.get()); + } + }); +}; + +export const useDeleteGithubSyncOrgConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => { + return apiRequest.delete("/api/v1/github-org-sync-config"); + }, + onSuccess: () => { + queryClient.invalidateQueries(githubOrgSyncConfigQueryKeys.get()); + } + }); +}; diff --git a/frontend/src/hooks/api/githubOrgSyncConfig/queries.tsx b/frontend/src/hooks/api/githubOrgSyncConfig/queries.tsx new file mode 100644 index 000000000..11bfa97bf --- /dev/null +++ b/frontend/src/hooks/api/githubOrgSyncConfig/queries.tsx @@ -0,0 +1,20 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TGithubOrgSyncConfig } from "./types"; + +export const githubOrgSyncConfigQueryKeys = { + allKey: () => ["github-org-sync-config"], + getKey: () => [...githubOrgSyncConfigQueryKeys.allKey(), "list"], + get: () => + queryOptions({ + queryKey: githubOrgSyncConfigQueryKeys.getKey(), + queryFn: async () => { + const { data } = await apiRequest.get<{ githubOrgSyncConfig: TGithubOrgSyncConfig }>( + "/api/v1/github-org-sync-config" + ); + return data.githubOrgSyncConfig; + } + }) +}; diff --git a/frontend/src/hooks/api/githubOrgSyncConfig/types.ts b/frontend/src/hooks/api/githubOrgSyncConfig/types.ts new file mode 100644 index 000000000..f663c8caf --- /dev/null +++ b/frontend/src/hooks/api/githubOrgSyncConfig/types.ts @@ -0,0 +1,20 @@ +export type TGithubOrgSyncConfig = { + id: string; + orgId: string; + githubOrgAccessToken?: string; + githubOrgName: string; + createdAt: string; + isActive?: boolean; +}; + +export interface TCreateGithubOrgSyncDTO { + githubOrgName: string; + githubOrgAccessToken?: string; + isActive?: boolean; +} + +export interface TUpdateGithubOrgSyncDTO { + githubOrgName?: string; + githubOrgAccessToken?: string; + isActive?: boolean; +} diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 52d6dceb2..2a80c6174 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,6 +1,7 @@ export * from "./accessApproval"; export * from "./admin"; export * from "./apiKeys"; +export * from "./assumePrivileges"; export * from "./auditLogs"; export * from "./auditLogStreams"; export * from "./auth"; @@ -11,6 +12,7 @@ export * from "./certificateTemplates"; export * from "./dynamicSecret"; export * from "./dynamicSecretLease"; export * from "./gateways"; +export * from "./githubOrgSyncConfig"; export * from "./groups"; export * from "./identities"; export * from "./identityProjectAdditionalPrivilege"; diff --git a/frontend/src/hooks/api/oidcConfig/types.ts b/frontend/src/hooks/api/oidcConfig/types.ts index 7c41d3400..a814947c7 100644 --- a/frontend/src/hooks/api/oidcConfig/types.ts +++ b/frontend/src/hooks/api/oidcConfig/types.ts @@ -19,5 +19,6 @@ export type OIDCConfigData = { export enum OIDCJWTSignatureAlgorithm { RS256 = "RS256", HS256 = "HS256", - RS512 = "RS512" + RS512 = "RS512", + EDDSA = "EdDSA" } diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 3353a0e96..a406b6d45 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -10,6 +10,7 @@ import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext/type import { groupBy } from "@app/lib/fn/array"; import { omit } from "@app/lib/fn/object"; +import { ActorType } from "../auditLogs/enums"; import { OrgUser, TProjectMembership } from "../users/types"; import { TGetUserOrgPermissionsDTO, @@ -137,6 +138,12 @@ export const fetchUserProjectPermissions = async ({ data: { permissions: PackRule>>[]; membership: Omit & { roles: { role: string }[] }; + assumedPrivilegeDetails?: { + actorId: string; + actorType: ActorType; + actorEmail: string; + actorName: string; + }; }; }>(`/api/v1/workspace/${workspaceId}/permissions`, {}); diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index 68453ecdd..3862d1f8d 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -83,6 +83,7 @@ export const useUpdateSecretV3 = ({ secretComment, secretReminderRepeatDays, secretReminderNote, + secretReminderRecipients, newSecretName, skipMultilineEncoding, secretMetadata @@ -93,6 +94,7 @@ export const useUpdateSecretV3 = ({ type, secretReminderNote, secretReminderRepeatDays, + secretReminderRecipients, secretPath, skipMultilineEncoding, newSecretName, diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index b7370a29a..10796c6e2 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -80,6 +80,7 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => { comment: el.secretComment || "", reminderRepeatDays: el.secretReminderRepeatDays, reminderNote: el.secretReminderNote, + secretReminderRecipients: el.secretReminderRecipients, createdAt: el.createdAt, updatedAt: el.updatedAt, version: el.version, diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 187ff684c..fa597169f 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -7,6 +7,14 @@ export enum SecretType { Personal = "personal" } +export type SecretReminderRecipient = { + user: { + id: string; + username: string; + email: string; + }; + id: string; +}; export type EncryptedSecret = { id: string; version: number; @@ -42,6 +50,7 @@ export type SecretV3RawSanitized = { comment?: string; reminderRepeatDays?: number | null; reminderNote?: string | null; + reminderRecipients?: string[]; tags?: WsTag[]; createdAt: string; updatedAt: string; @@ -55,6 +64,7 @@ export type SecretV3RawSanitized = { secretMetadata?: { key: string; value: string }[]; isReminderEvent?: boolean; isRotatedSecret?: boolean; + secretReminderRecipients?: SecretReminderRecipient[]; rotationId?: string; }; @@ -80,6 +90,7 @@ export type SecretV3Raw = { updatedAt: string; isRotatedSecret?: boolean; rotationId?: string; + secretReminderRecipients?: SecretReminderRecipient[]; }; export type SecretV3RawResponse = { @@ -177,6 +188,7 @@ export type TUpdateSecretsV3DTO = { secretReminderNote?: string | null; tagIds?: string[]; secretMetadata?: { key: string; value: string }[]; + secretReminderRecipients?: string[] | null; }; export type TDeleteSecretsV3DTO = { diff --git a/frontend/src/hooks/api/sshHost/types.ts b/frontend/src/hooks/api/sshHost/types.ts index 4bb61008c..ebeb5130a 100644 --- a/frontend/src/hooks/api/sshHost/types.ts +++ b/frontend/src/hooks/api/sshHost/types.ts @@ -2,6 +2,7 @@ export type TSshHost = { id: string; projectId: string; hostname: string; + alias: string | null; userCertTtl: string; hostCertTtl: string; loginMappings: { @@ -15,6 +16,7 @@ export type TSshHost = { export type TCreateSshHostDTO = { projectId: string; hostname: string; + alias?: string; userCertTtl?: string; hostCertTtl?: string; loginMappings: { @@ -28,6 +30,7 @@ export type TCreateSshHostDTO = { export type TUpdateSshHostDTO = { sshHostId: string; hostname?: string; + alias?: string; userCertTtl?: string; hostCertTtl?: string; loginMappings?: { diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index b6b653ece..ab277ddc8 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -12,6 +12,7 @@ export type SubscriptionPlan = { customAlerts: boolean; customRateLimits: boolean; pitRecovery: boolean; + githubOrgSync: boolean; ipAllowlisting: boolean; rbac: boolean; secretVersioning: boolean; diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index dc64ccb12..a862c31a3 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -13,6 +13,7 @@ import { TBreadcrumbFormat } from "@app/components/v2"; import { + useProjectPermission, ProjectPermissionActions, ProjectPermissionSub, useSubscription, @@ -25,6 +26,7 @@ import { } from "@app/hooks/api"; import { ProjectType } from "@app/hooks/api/workspace/types"; +import { AssumePrivilegeModeBanner } from "./components/AssumePrivilegeModeBanner"; import { ProjectSelect } from "./components/ProjectSelect"; // This is a generic layout shared by all types of projects. @@ -35,6 +37,7 @@ export const ProjectLayout = () => { const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; const { t } = useTranslation(); + const { assumedPrivilegeDetails } = useProjectPermission(); const workspaceId = currentWorkspace?.id || ""; const projectSlug = currentWorkspace?.slug || ""; const { subscription } = useSubscription(); @@ -68,6 +71,7 @@ export const ProjectLayout = () => { return ( <>
+ {assumedPrivilegeDetails && }
{ + const { currentWorkspace } = useWorkspace(); + const exitAssumePrivilegeMode = useRemoveAssumeProjectPrivilege(); + const { assumedPrivilegeDetails } = useProjectPermission(); + + if (!assumedPrivilegeDetails) return null; + + return ( +
+
+ + You are currently viewing the project with privileges of{" "} + + {assumedPrivilegeDetails?.actorType === ActorType.IDENTITY ? "identity" : "user"}{" "} + {assumedPrivilegeDetails?.actorName} + +
+
+ +
+
+ ); +}; diff --git a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/index.tsx b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/index.tsx new file mode 100644 index 000000000..e8ebac19e --- /dev/null +++ b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/index.tsx @@ -0,0 +1 @@ +export { AssumePrivilegeModeBanner } from "./AssumePrivilegeModeBanner"; diff --git a/frontend/src/pages/auth/LoginPage/components/InitialStep/InitialStep.tsx b/frontend/src/pages/auth/LoginPage/components/InitialStep/InitialStep.tsx index ac32816e0..820cda786 100644 --- a/frontend/src/pages/auth/LoginPage/components/InitialStep/InitialStep.tsx +++ b/frontend/src/pages/auth/LoginPage/components/InitialStep/InitialStep.tsx @@ -269,12 +269,19 @@ export const InitialStep = ({ variant="outline_bg" onClick={() => { const callbackPort = queryParams.get("callback_port"); + const searchParams = new URLSearchParams(); - window.open( - `/api/v1/sso/redirect/google${ - callbackPort ? `?callback_port=${callbackPort}` : "" - }` - ); + if (callbackPort) { + searchParams.append("callback_port", callbackPort); + } + + if (isAdmin) { + searchParams.append("is_admin_login", "true"); + } + + const queryString = searchParams.toString(); + + window.open(`/api/v1/sso/redirect/google${queryString ? `?${queryString}` : ""}`); window.close(); }} className="h-10 w-full bg-mineshaft-600" @@ -291,13 +298,19 @@ export const InitialStep = ({ variant="outline_bg" onClick={() => { const callbackPort = queryParams.get("callback_port"); + const searchParams = new URLSearchParams(); - window.open( - `/api/v1/sso/redirect/github${ - callbackPort ? `?callback_port=${callbackPort}` : "" - }` - ); + if (callbackPort) { + searchParams.append("callback_port", callbackPort); + } + if (isAdmin) { + searchParams.append("is_admin_login", "true"); + } + + const queryString = searchParams.toString(); + + window.open(`/api/v1/sso/redirect/github${queryString ? `?${queryString}` : ""}`); window.close(); }} className="h-10 w-full bg-mineshaft-600" @@ -314,13 +327,19 @@ export const InitialStep = ({ variant="outline_bg" onClick={() => { const callbackPort = queryParams.get("callback_port"); + const searchParams = new URLSearchParams(); - window.open( - `/api/v1/sso/redirect/gitlab${ - callbackPort ? `?callback_port=${callbackPort}` : "" - }` - ); + if (callbackPort) { + searchParams.append("callback_port", callbackPort); + } + if (isAdmin) { + searchParams.append("is_admin_login", "true"); + } + + const queryString = searchParams.toString(); + + window.open(`/api/v1/sso/redirect/gitlab${queryString ? `?${queryString}` : ""}`); window.close(); }} className="h-10 w-full bg-mineshaft-600" diff --git a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx index 20b47599f..529509b49 100644 --- a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx @@ -27,9 +27,16 @@ type Props = { email: string; password: string; setPassword: (password: string) => void; + isAdminLogin?: boolean; }; -export const PasswordStep = ({ providerAuthToken, email, password, setPassword }: Props) => { +export const PasswordStep = ({ + providerAuthToken, + email, + password, + setPassword, + isAdminLogin +}: Props) => { const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const navigate = useNavigate(); @@ -114,7 +121,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword } // case: user has orgs, so we navigate the user to select an org if (userOrgs.length > 0) { - navigateToSelectOrganization(callbackPort); + navigateToSelectOrganization(callbackPort, isAdminLogin); } // case: no orgs found, so we navigate the user to create an org else { @@ -216,7 +223,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword } // case: user has orgs, so we navigate the user to select an org if (userOrgs.length > 0) { - navigateToSelectOrganization(callbackPort); + navigateToSelectOrganization(callbackPort, isAdminLogin); } // case: no orgs found, so we navigate the user to create an org else { @@ -249,7 +256,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword } const userOrgs = await fetchOrganizations(); if (userOrgs.length > 0) { - navigateToSelectOrganization(); + navigateToSelectOrganization(undefined, isAdminLogin); } else { await navigateUserToOrg(navigate); } diff --git a/frontend/src/pages/auth/LoginSsoPage/LoginSsoPage.tsx b/frontend/src/pages/auth/LoginSsoPage/LoginSsoPage.tsx index 5af256d1a..8379fc725 100644 --- a/frontend/src/pages/auth/LoginSsoPage/LoginSsoPage.tsx +++ b/frontend/src/pages/auth/LoginSsoPage/LoginSsoPage.tsx @@ -34,6 +34,7 @@ export const LoginSsoPage = () => { email={username} password={password} setPassword={setPassword} + isAdminLogin={search.isAdminLogin} /> ); default: diff --git a/frontend/src/pages/auth/LoginSsoPage/route.tsx b/frontend/src/pages/auth/LoginSsoPage/route.tsx index 88cc0c539..f66925b93 100644 --- a/frontend/src/pages/auth/LoginSsoPage/route.tsx +++ b/frontend/src/pages/auth/LoginSsoPage/route.tsx @@ -5,7 +5,8 @@ import { z } from "zod"; import { LoginSsoPage } from "./LoginSsoPage"; const LoginSSOQueryParamsSchema = z.object({ - token: z.string() + token: z.string(), + isAdminLogin: z.boolean().optional().catch(false) }); export const Route = createFileRoute("/_restrict-login-signup/login/sso")({ diff --git a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts index d88640cc1..72ebc2987 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts @@ -110,6 +110,7 @@ export const formSchema = z.object({ "secret-scanning": generalPermissionSchema, sso: generalPermissionSchema, scim: generalPermissionSchema, + [OrgPermissionSubjects.GithubOrgSync]: generalPermissionSchema, ldap: generalPermissionSchema, billing: generalPermissionSchema, identity: identityPermissionSchema, diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx index f077a63a7..7c9cb9850 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -63,6 +63,10 @@ const SIMPLE_PERMISSION_OPTIONS = [ title: "SCIM", formName: "scim" }, + { + title: "GitHub Organization Sync", + formName: OrgPermissionSubjects.GithubOrgSync + }, { title: "External KMS", formName: OrgPermissionSubjects.Kms diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/GithubOrgSyncConfigModal.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/GithubOrgSyncConfigModal.tsx new file mode 100644 index 000000000..1aa050441 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/GithubOrgSyncConfigModal.tsx @@ -0,0 +1,172 @@ +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, DeleteActionModal, FormControl, Input } from "@app/components/v2"; +import { + useCreateGithubSyncOrgConfig, + useDeleteGithubSyncOrgConfig, + useUpdateGithubSyncOrgConfig +} from "@app/hooks/api"; +import { TGithubOrgSyncConfig } from "@app/hooks/api/githubOrgSyncConfig/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + githubOrgName: z.string(), + githubOrgAccessToken: z.string().optional() +}); + +export type FormData = z.infer; + +type Props = { + data?: TGithubOrgSyncConfig; + popUp: UsePopUpState<["githubOrgSyncConfig", "deleteGithubOrgSyncConfig"]>; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteGithubOrgSyncConfig"]>, + data?: { + scimTokenId: string; + } + ) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["githubOrgSyncConfig", "deleteGithubOrgSyncConfig"]>, + state?: boolean + ) => void; +}; + +export const GithubOrgSyncConfigModal = ({ + popUp, + handlePopUpOpen, + handlePopUpToggle, + data +}: Props) => { + const isUpdate = Boolean(data); + const { mutateAsync: createGithubSyncOrgConfig } = useCreateGithubSyncOrgConfig(); + const { mutateAsync: updateGithubSyncOrgConfig } = useUpdateGithubSyncOrgConfig(); + const { mutateAsync: deleteGithubSyncOrgConfig } = useDeleteGithubSyncOrgConfig(); + + const { + control, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + values: data ? { githubOrgName: data.githubOrgName } : undefined + }); + + const onFormSubmit = async ({ githubOrgName, githubOrgAccessToken }: FormData) => { + try { + if (isUpdate) { + await updateGithubSyncOrgConfig({ + githubOrgName, + githubOrgAccessToken + }); + + createNotification({ + text: "Successfully updated GitHub Organization Sync", + type: "success" + }); + } else { + await createGithubSyncOrgConfig({ + githubOrgName, + githubOrgAccessToken, + isActive: false + }); + + createNotification({ + text: "Successfully created GitHub Organization Sync", + type: "success" + }); + } + handlePopUpToggle("githubOrgSyncConfig"); + } catch { + createNotification({ + text: "Failed to setup GitHub Organization Sync", + type: "error" + }); + } + }; + + const onDelete = async () => { + try { + await deleteGithubSyncOrgConfig(); + + handlePopUpToggle("deleteGithubOrgSyncConfig", false); + handlePopUpToggle("githubOrgSyncConfig", false); + createNotification({ + text: "Successfully deleted GitHub Organization Sync", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete GitHub Organization Sync", + type: "error" + }); + } + }; + + return ( + <> +
+ ( + + + + )} + /> + {/* ( + + + + )} + /> */} +
+ + +
+ {isUpdate && ( + + )} +
+ + handlePopUpToggle("deleteGithubOrgSyncConfig", isOpen)} + deleteKey="confirm" + onDeleteApproved={onDelete} + /> + + ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OIDCModal.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OIDCModal.tsx index 4fff131a7..241348b0d 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OIDCModal.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OIDCModal.tsx @@ -387,6 +387,7 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDele RS256 RS512 HS256 + EdDSA )} diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx index 40d0e7840..bf40c7484 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgAuthTab.tsx @@ -18,6 +18,7 @@ import { LDAPModal } from "./LDAPModal"; import { OIDCModal } from "./OIDCModal"; import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection"; import { OrgGenericAuthSection } from "./OrgGenericAuthSection"; +import { OrgGithubSyncSection } from "./OrgGithubSyncSection"; import { OrgLDAPSection } from "./OrgLDAPSection"; import { OrgOIDCSection } from "./OrgOIDCSection"; import { OrgScimSection } from "./OrgSCIMSection"; @@ -181,6 +182,7 @@ export const OrgAuthTab = withPermission( )} + handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGithubSyncSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGithubSyncSection.tsx new file mode 100644 index 000000000..c638a280c --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgAuthTab/OrgGithubSyncSection.tsx @@ -0,0 +1,114 @@ +import { useQuery } from "@tanstack/react-query"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, Modal, ModalContent, Skeleton, Spinner, Switch } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; +import { githubOrgSyncConfigQueryKeys, useUpdateGithubSyncOrgConfig } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { GithubOrgSyncConfigModal } from "./GithubOrgSyncConfigModal"; + +export const OrgGithubSyncSection = () => { + const { subscription } = useSubscription(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "upgradePlan", + "githubOrgSyncConfig", + "deleteGithubOrgSyncConfig" + ] as const); + + const githubOrgSyncConfig = useQuery({ + ...githubOrgSyncConfigQueryKeys.get(), + enabled: subscription.githubOrgSync, + retry: false + }); + + const updateGithubSyncOrgConfig = useUpdateGithubSyncOrgConfig(); + + const isPending = subscription.githubOrgSync && githubOrgSyncConfig.isPending; + const data = !isPending && !githubOrgSyncConfig?.isError ? githubOrgSyncConfig?.data : undefined; + + return ( +
+

+ Sync user groups from your GitHub Organization +

+
+
+

GitHub Organization

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

+ {isPending ? : null} + {data ? data?.githubOrgName : "Not configured"} +

+
+ {data && ( +
+
+

Enable GitHub Sync

+ + {(isAllowed) => ( + + updateGithubSyncOrgConfig.mutate({ + isActive: value + }) + } + isChecked={githubOrgSyncConfig?.data?.isActive ?? false} + isDisabled={!isAllowed} + > + {updateGithubSyncOrgConfig?.isPending && } + + )} + +
+

+ Allow group provisioning/deprovisioning with GitHub +

+
+ )} + { + handlePopUpToggle("githubOrgSyncConfig", isOpen); + }} + > + + + + + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use GitHub Organization Plan if you switch to Infisical's Enterprise plan." + /> +
+ ); +}; diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx index 70155eb13..d18f8715a 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx @@ -27,6 +27,7 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, useSubscription, @@ -68,7 +69,7 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); const { permission } = useProjectPermission(); const isMemberEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, + ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member ); diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx index e9c9d15fc..e8bc03698 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -37,6 +37,7 @@ import { import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, useWorkspace @@ -88,7 +89,8 @@ export const SpecificPrivilegeSecretForm = ({ ] as const); const { permission } = useProjectPermission(); const isMemberEditDisabled = - permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege; + permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member) && + Boolean(privilege); const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege(); const requestAccess = useCreateAccessRequest(); diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index 825a34a33..493a348dd 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -6,13 +6,27 @@ import { formatRelative } from "date-fns"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { Button, DeleteActionModal, EmptyState, PageHeader, Spinner } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { + Button, + ConfirmActionModal, + DeleteActionModal, + EmptyState, + PageHeader, + Spinner +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionIdentityActions, + ProjectPermissionSub, + useWorkspace +} from "@app/context"; import { usePopUp } from "@app/hooks"; import { + useAssumeProjectPrivileges, useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMembershipDetails } from "@app/hooks/api"; +import { ActorType } from "@app/hooks/api/auditLogs/enums"; import { IdentityProjectAdditionalPrivilegeSection } from "./components/IdentityProjectAdditionalPrivilegeSection"; import { IdentityRoleDetailsSection } from "./components/IdentityRoleDetailsSection"; @@ -35,8 +49,28 @@ const Page = () => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "deleteIdentity", - "upgradePlan" + "assumePrivileges" ] as const); + const assumePrivileges = useAssumeProjectPrivileges(); + + const handleAssumePrivileges = async () => { + assumePrivileges.mutate( + { + actorId: identityId, + actorType: ActorType.IDENTITY, + projectId: workspaceId + }, + { + onSuccess: () => { + createNotification({ + type: "success", + text: "Identity privilege assumption has started" + }); + window.location.href = `/${currentWorkspace.type}/${currentWorkspace.id}/overview`; + } + } + ); + }; const onRemoveIdentitySubmit = async () => { try { @@ -100,6 +134,24 @@ const Page = () => { > Copy Membership ID + + {(isAllowed) => ( + + )} + { deleteKey="remove" onDeleteApproved={() => onRemoveIdentitySubmit()} /> + handlePopUpToggle("assumePrivileges", isOpen)} + onConfirmed={handleAssumePrivileges} + buttonText="Confirm" + /> ) : ( diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx index 17d223954..8557519c9 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx @@ -32,7 +32,7 @@ import { Tooltip } from "@app/components/v2"; import { - ProjectPermissionActions, + ProjectPermissionIdentityActions, ProjectPermissionSub, useProjectPermission, useWorkspace @@ -97,7 +97,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({ }); const { permission } = useProjectPermission(); const isIdentityEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, subject(ProjectPermissionSub.Identity, { identityId }) ); diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx index ef566be71..be3ae90b3 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx @@ -22,7 +22,12 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionIdentityActions, + ProjectPermissionSub, + useProjectPermission +} from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteIdentityProjectAdditionalPrivilege } from "@app/hooks/api"; import { IdentityMembership } from "@app/hooks/api/identities/types"; @@ -83,7 +88,7 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe identityId={identityId} privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} isDisabled={permission.cannot( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, subject(ProjectPermissionSub.Identity, { identityId }) diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx index 5aa4ffc32..c5860ec11 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx @@ -27,6 +27,7 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, + ProjectPermissionIdentityActions, ProjectPermissionSub, useProjectPermission, useWorkspace @@ -66,7 +67,7 @@ export const IdentityRoleModify = ({ identityProjectMembership }: Props) => { const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); const { permission } = useProjectPermission(); const isIdentityEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, ProjectPermissionSub.Identity ); diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx index 790214141..c061fd6f2 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx @@ -6,15 +6,28 @@ import { formatRelative } from "date-fns"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { Button, DeleteActionModal, EmptyState, PageHeader, Spinner } from "@app/components/v2"; +import { + Button, + ConfirmActionModal, + DeleteActionModal, + EmptyState, + PageHeader, + Spinner +} from "@app/components/v2"; import { ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useOrganization, useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; -import { useDeleteUserFromWorkspace, useGetWorkspaceUserDetails } from "@app/hooks/api"; +import { + useAssumeProjectPrivileges, + useDeleteUserFromWorkspace, + useGetWorkspaceUserDetails +} from "@app/hooks/api"; +import { ActorType } from "@app/hooks/api/auditLogs/enums"; import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection"; import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection"; @@ -35,12 +48,34 @@ export const Page = () => { const { mutateAsync: removeUserFromWorkspace, isPending: isRemovingUserFromWorkspace } = useDeleteUserFromWorkspace(); + const assumePrivileges = useAssumeProjectPrivileges(); const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ "removeMember", - "upgradePlan" + "upgradePlan", + "assumePrivileges" ] as const); + const handleAssumePrivileges = async () => { + const { userId } = popUp?.assumePrivileges?.data as { userId: string }; + assumePrivileges.mutate( + { + actorId: userId, + actorType: ActorType.USER, + projectId: workspaceId + }, + { + onSuccess: () => { + createNotification({ + type: "success", + text: "User privilege assumption has started" + }); + window.location.href = `/${currentWorkspace.type}/${currentWorkspace.id}/overview`; + } + } + ); + }; + const handleRemoveUser = async () => { if (!currentOrg?.id || !currentWorkspace?.id || !membershipDetails?.user?.username) return; @@ -91,7 +126,29 @@ export const Page = () => { description={`User joined on ${membershipDetails?.createdAt && formatRelative(new Date(membershipDetails?.createdAt || ""), new Date())}`} > + {(isAllowed) => ( + + )} + + + { onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)} onDeleteApproved={handleRemoveUser} /> + handlePopUpToggle("assumePrivileges", isOpen)} + onConfirmed={handleAssumePrivileges} + buttonText="Confirm" + /> handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx index 95a7ffa66..76c631e43 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx @@ -23,6 +23,7 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, useUser @@ -90,7 +91,7 @@ export const MemberProjectAdditionalPrivilegeSection = ({ membershipDetails }: P privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} isDisabled={ isOwnProjectMembershipDetails || - permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) + permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member) } /> diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx index 4e329a2d9..2cf1e5567 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx @@ -31,7 +31,7 @@ import { Tooltip } from "@app/components/v2"; import { - ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, useWorkspace @@ -95,7 +95,7 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({ const { permission } = useProjectPermission(); const isMemberEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, + ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member ); diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx index 3a140ba56..739b22b7d 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx @@ -27,6 +27,7 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, useSubscription, @@ -69,7 +70,7 @@ export const MemberRoleModify = ({ projectMember, onOpenUpgradeModal }: Props) = const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); const { permission } = useProjectPermission(); const isMemberEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, + ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member ); diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx index a6e472976..f0388a2f4 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx @@ -1,8 +1,9 @@ -import { cloneElement } from "react"; +import { cloneElement, useState } from "react"; import { Controller, useFieldArray, useFormContext } from "react-hook-form"; import { faChevronDown, faChevronRight, + faGripVertical, faInfoCircle, faPlus, faTrash @@ -36,14 +37,44 @@ export const GeneralPermissionPolicies = ) => { const { control, watch } = useFormContext(); - const items = useFieldArray({ + const { fields, remove, insert, move } = useFieldArray({ control, name: `permissions.${subject}` }); const [isOpen, setIsOpen] = useToggle(); - // const [hideFullReadAccess, setHideFullReadAccess] = useState(false); + const [draggedItem, setDraggedItem] = useState(null); + const [dragOverItem, setDragOverItem] = useState(null); - if (!items.fields.length) return
; + if (!fields.length) return
; + + const handleDragStart = (_: React.DragEvent, index: number) => { + setDraggedItem(index); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + setDragOverItem(index); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + + if (draggedItem === null || dragOverItem === null || draggedItem === dragOverItem) { + setDraggedItem(null); + setDragOverItem(null); + return; + } + + move(draggedItem, dragOverItem); + + setDraggedItem(null); + setDragOverItem(null); + }; + + const handleDragEnd = () => { + setDraggedItem(null); + setDragOverItem(null); + }; return (
@@ -62,17 +93,17 @@ export const GeneralPermissionPolicies =
{title}
- {items.fields.length > 1 && ( + {fields.length > 1 && (
- {items.fields.length} rules + {fields.length} rules
)}
{isOpen && (
- {items.fields.map((el, rootIndex) => { + {fields.map((el, rootIndex) => { let isFullReadAccessEnabled = false; if (subject === ProjectPermissionSub.Secrets) { @@ -82,47 +113,72 @@ export const GeneralPermissionPolicies = - {isConditionalSubjects(subject) && ( -
-
Permission
-
- ( - - )} - /> -
-
- -

- Whether to allow or forbid the selected actions when the following - conditions (if any) are met. -

-

Forbid rules must come after allow rules.

- - } - > - -
-
-
+ className={twMerge( + "relative bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md", + dragOverItem === rootIndex ? "border-2 border-blue-400" : "", + draggedItem === rootIndex ? "opacity-50" : "" )} + onDragOver={(e) => handleDragOver(e, rootIndex)} + onDrop={handleDrop} + > + {!isDisabled && ( + +
handleDragStart(e, rootIndex)} + onDragEnd={handleDragEnd} + className="absolute right-3 top-2 cursor-move rounded-md bg-mineshaft-700 p-2 text-gray-400 hover:text-gray-200" + > + +
+
+ )} + +
+ {isConditionalSubjects(subject) && ( +
+
Permission
+
+ ( + + )} + /> +
+
+ +

+ Whether to allow or forbid the selected actions when the following + conditions (if any) are met. +

+

Forbid rules must come after allow rules.

+ + } + > + +
+
+
+ )} +
Actions
@@ -179,7 +235,7 @@ export const GeneralPermissionPolicies = { - items.insert(rootIndex + 1, [ + insert(rootIndex + 1, [ { read: false, edit: false, create: false, delete: false } as any ]); }} @@ -194,7 +250,7 @@ export const GeneralPermissionPolicies = items.remove(rootIndex)} + onClick={() => remove(rootIndex)} isDisabled={isDisabled} > Remove policy diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 57d617527..de2295940 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -92,7 +92,8 @@ const MemberPolicyActionSchema = z.object({ [ProjectPermissionMemberActions.Create]: z.boolean().optional(), [ProjectPermissionMemberActions.Edit]: z.boolean().optional(), [ProjectPermissionMemberActions.Delete]: z.boolean().optional(), - [ProjectPermissionMemberActions.GrantPrivileges]: z.boolean().optional() + [ProjectPermissionMemberActions.GrantPrivileges]: z.boolean().optional(), + [ProjectPermissionMemberActions.AssumePrivileges]: z.boolean().optional() }); const IdentityPolicyActionSchema = z.object({ @@ -100,7 +101,8 @@ const IdentityPolicyActionSchema = z.object({ [ProjectPermissionIdentityActions.Create]: z.boolean().optional(), [ProjectPermissionIdentityActions.Edit]: z.boolean().optional(), [ProjectPermissionIdentityActions.Delete]: z.boolean().optional(), - [ProjectPermissionIdentityActions.GrantPrivileges]: z.boolean().optional() + [ProjectPermissionIdentityActions.GrantPrivileges]: z.boolean().optional(), + [ProjectPermissionIdentityActions.AssumePrivileges]: z.boolean().optional() }); const GroupPolicyActionSchema = z.object({ @@ -577,6 +579,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { const canEdit = action.includes(ProjectPermissionMemberActions.Edit); const canDelete = action.includes(ProjectPermissionMemberActions.Delete); const canGrantPrivileges = action.includes(ProjectPermissionMemberActions.GrantPrivileges); + const canAssumePrivileges = action.includes(ProjectPermissionMemberActions.AssumePrivileges); if (!formVal[subject]) formVal[subject] = [{}]; @@ -587,6 +590,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { if (canDelete) formVal[subject]![0][ProjectPermissionMemberActions.Delete] = true; if (canGrantPrivileges) formVal[subject]![0][ProjectPermissionMemberActions.GrantPrivileges] = true; + if (canAssumePrivileges) + formVal[subject]![0][ProjectPermissionMemberActions.AssumePrivileges] = true; return; } @@ -596,6 +601,9 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { const canEdit = action.includes(ProjectPermissionIdentityActions.Edit); const canDelete = action.includes(ProjectPermissionIdentityActions.Delete); const canGrantPrivileges = action.includes(ProjectPermissionIdentityActions.GrantPrivileges); + const canAssumePrivileges = action.includes( + ProjectPermissionIdentityActions.AssumePrivileges + ); if (!formVal[subject]) formVal[subject] = [{ conditions: [] }]; @@ -606,6 +614,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { if (canDelete) formVal[subject]![0][ProjectPermissionIdentityActions.Delete] = true; if (canGrantPrivileges) formVal[subject]![0][ProjectPermissionIdentityActions.GrantPrivileges] = true; + if (canAssumePrivileges) + formVal[subject]![0][ProjectPermissionIdentityActions.AssumePrivileges] = true; return; } @@ -904,7 +914,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Add", value: ProjectPermissionMemberActions.Create }, { label: "Modify", value: ProjectPermissionMemberActions.Edit }, { label: "Remove", value: ProjectPermissionMemberActions.Delete }, - { label: "Grant Privileges", value: ProjectPermissionMemberActions.GrantPrivileges } + { label: "Grant Privileges", value: ProjectPermissionMemberActions.GrantPrivileges }, + { label: "Assume Privileges", value: ProjectPermissionMemberActions.AssumePrivileges } ] }, [ProjectPermissionSub.Identity]: { @@ -914,7 +925,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Add", value: ProjectPermissionIdentityActions.Create }, { label: "Modify", value: ProjectPermissionIdentityActions.Edit }, { label: "Remove", value: ProjectPermissionIdentityActions.Delete }, - { label: "Grant Privileges", value: ProjectPermissionIdentityActions.GrantPrivileges } + { label: "Grant Privileges", value: ProjectPermissionIdentityActions.GrantPrivileges }, + { label: "Assume Privileges", value: ProjectPermissionIdentityActions.AssumePrivileges } ] }, [ProjectPermissionSub.Groups]: { diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index ddb9a99d1..bd7660838 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -4,6 +4,7 @@ import { subject } from "@casl/ability"; import { faCheck, faCopy, + faEyeSlash, faProjectDiagram, faTrash, faXmark @@ -25,7 +26,6 @@ import { ModalTrigger, Tooltip } from "@app/components/v2"; -import { Blur } from "@app/components/v2/Blur"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; @@ -124,7 +124,13 @@ export const SecretEditRow = ({ ); } } - reset({ value }); + if (secretValueHidden && !isOverride) { + setTimeout(() => { + reset({ value: defaultValue || null }); + }, 50); + } else { + reset({ value }); + } }; const canReadSecretValue = hasSecretReadValueOrDescribePermission( @@ -132,6 +138,16 @@ export const SecretEditRow = ({ ProjectPermissionSecretActions.ReadValue ); + const canEditSecretValue = permission.can( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName, + secretTags: ["*"] + }) + ); + const handleDeleteSecret = useCallback(async () => { setIsDeleting.on(); setIsModalOpen(false); @@ -153,29 +169,32 @@ export const SecretEditRow = ({ deleteKey={secretName} onDeleteApproved={handleDeleteSecret} /> - + {secretValueHidden && !isOverride && ( + + + + )}
- {secretValueHidden ? ( - - ) : ( - ( - - )} - /> - )} + ( + + )} + />
{ + const canEditSecretValue = permission.can( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment: secret?.env || "", + secretPath: secret?.path || "", + secretName: secret?.key || "", + secretTags: ["*"] + }) + ); + + if (secret?.secretValueHidden && !secret?.valueOverride) { + return canEditSecretValue ? "******" : ""; + } + return secret?.valueOverride || secret?.value || importedSecret?.secret?.value || ""; + }; + return ( <> setIsFormExpanded.toggle()} className="group"> @@ -228,13 +256,7 @@ export const SecretOverviewTableRow = ({ isVisible={isSecretVisible} secretName={secretKey} secretValueHidden={secret?.secretValueHidden || false} - defaultValue={ - secret?.secretValueHidden - ? "" - : secret?.valueOverride || - secret?.value || - importedSecret?.secret?.value - } + defaultValue={getDefaultValue(secret, importedSecret)} secretId={secret?.id} isOverride={Boolean(secret?.valueOverride)} isImportedSecret={isImportedSecret} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index b857e3038..9afe4795d 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -25,7 +25,7 @@ import { } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; import { - ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, useSubscription, @@ -289,7 +289,10 @@ export const AccessApprovalRequest = ({ ))} - {!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && ( + {!!permission.can( + ProjectPermissionMemberActions.Read, + ProjectPermissionSub.Member + ) && (
+ } + label="Recipients" + className="mb-0" + > + ({ + label: member.user.username || member.user.email, + value: member.user.id + }))} + value={field.value} + onChange={field.onChange} + /> + + )} + />