diff --git a/backend/package-lock.json b/backend/package-lock.json index 5c3b9d02f..eaf32ae4c 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", @@ -7244,47 +7245,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", @@ -7379,6 +7580,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", @@ -7460,28 +7673,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" }, @@ -7490,9 +7689,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", @@ -7542,6 +7742,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", @@ -11642,9 +11895,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", @@ -19646,6 +19901,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", @@ -19662,6 +19973,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 bbb2b5cf9..5db1dffe0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -149,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", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 441d3ce4c..34d816b9b 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"; @@ -109,12 +111,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 { @@ -138,6 +142,7 @@ declare module "fastify" { passportUser: { isUserCompleted: boolean; providerAuthToken: string; + externalProviderAccessToken?: string; }; kmipUser: { projectId: string; @@ -241,6 +246,8 @@ declare module "fastify" { kmipOperation: TKmipOperationServiceFactory; gateway: TGatewayServiceFactory; secretRotationV2: TSecretRotationV2ServiceFactory; + 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 dc0e5ee67..091199938 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, @@ -423,6 +426,11 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { + TSecretReminderRecipients, + TSecretReminderRecipientsInsert, + TSecretReminderRecipientsUpdate +} from "@app/db/schemas/secret-reminder-recipients"; declare module "knex" { namespace Knex { @@ -994,5 +1002,15 @@ declare module "knex/types/tables" { TSecretRotationV2SecretMappingsInsert, TSecretRotationV2SecretMappingsUpdate >; + [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 8543417cf..7ccd71376 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 95561c14a..dd23c26da 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -146,7 +146,9 @@ export enum TableName { KmipOrgServerCertificates = "kmip_org_server_certificates", KmipClientCertificates = "kmip_client_certificates", SecretRotationV2 = "secret_rotations_v2", - SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings" + SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings", + 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 181df25f0..216b50847 100644 --- a/backend/src/db/schemas/oidc-configs.ts +++ b/backend/src/db/schemas/oidc-configs.ts @@ -30,9 +30,9 @@ 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, - manageGroupMemberships: z.boolean().default(false), 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/secret-reminder-recipients.ts b/backend/src/db/schemas/secret-reminder-recipients.ts new file mode 100644 index 000000000..3a132b367 --- /dev/null +++ b/backend/src/db/schemas/secret-reminder-recipients.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretReminderRecipientsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + secretId: z.string().uuid(), + userId: z.string().uuid(), + projectId: z.string() +}); + +export type TSecretReminderRecipients = z.infer; +export type TSecretReminderRecipientsInsert = Omit, TImmutableDBKeys>; +export type TSecretReminderRecipientsUpdate = Partial< + Omit, TImmutableDBKeys> +>; 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/routes/v2/secret-rotation-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts index 7fee08164..3dcdac30b 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts @@ -2,6 +2,7 @@ import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotat import { registerAuth0ClientSecretRotationRouter } from "./auth0-client-secret-rotation-router"; import { registerAwsIamUserSecretRotationRouter } from "./aws-iam-user-secret-rotation-router"; +import { registerLdapPasswordRotationRouter } from "./ldap-password-rotation-router"; import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router"; import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; @@ -14,5 +15,6 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< [SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter, [SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter, [SecretRotation.Auth0ClientSecret]: registerAuth0ClientSecretRotationRouter, + [SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter, [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter }; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/ldap-password-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/ldap-password-rotation-router.ts new file mode 100644 index 000000000..04d2b50ac --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/ldap-password-rotation-router.ts @@ -0,0 +1,19 @@ +import { + CreateLdapPasswordRotationSchema, + LdapPasswordRotationGeneratedCredentialsSchema, + LdapPasswordRotationSchema, + UpdateLdapPasswordRotationSchema +} from "@app/ee/services/secret-rotation-v2/ldap-password"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerLdapPasswordRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.LdapPassword, + server, + responseSchema: LdapPasswordRotationSchema, + createSchema: CreateLdapPasswordRotationSchema, + updateSchema: UpdateLdapPasswordRotationSchema, + generatedCredentialsSchema: LdapPasswordRotationGeneratedCredentialsSchema + }); diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts index 2383d98e2..772f70035 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { Auth0ClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret"; import { AwsIamUserSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/aws-iam-user-secret"; +import { LdapPasswordRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; @@ -15,6 +16,7 @@ const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ PostgresCredentialsRotationListItemSchema, MsSqlCredentialsRotationListItemSchema, Auth0ClientSecretRotationListItemSchema, + LdapPasswordRotationListItemSchema, AwsIamUserSecretRotationListItemSchema ]); 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 f85fbd6a3..b6527f3f4 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -320,7 +320,9 @@ export enum EventType { DELETE_SECRET_ROTATION = "delete-secret-rotation", SECRET_ROTATION_ROTATE_SECRETS = "secret-rotation-rotate-secrets", - PROJECT_ACCESS_REQUEST = "project-access-request" + PROJECT_ACCESS_REQUEST = "project-access-request", + PROJECT_ASSUME_PRIVILEGE_SESSION_START = "project-assume-privileges-session-start", + PROJECT_ASSUME_PRIVILEGE_SESSION_END = "project-assume-privileges-session-end" } export const filterableSecretEvents: EventType[] = [ @@ -1494,6 +1496,7 @@ interface CreateSshHost { metadata: { sshHostId: string; hostname: string; + alias: string | null; userCertTtl: string; hostCertTtl: string; loginMappings: { @@ -1512,6 +1515,7 @@ interface UpdateSshHost { metadata: { sshHostId: string; hostname?: string; + alias?: string | null; userCertTtl?: string; hostCertTtl?: string; loginMappings?: { @@ -2452,6 +2456,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: { @@ -2757,6 +2784,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/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/secret-rotation-v2/ldap-password/index.ts b/backend/src/ee/services/secret-rotation-v2/ldap-password/index.ts new file mode 100644 index 000000000..55929169a --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/ldap-password/index.ts @@ -0,0 +1,3 @@ +export * from "./ldap-password-rotation-constants"; +export * from "./ldap-password-rotation-schemas"; +export * from "./ldap-password-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-constants.ts new file mode 100644 index 000000000..061bf11ea --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-constants.ts @@ -0,0 +1,15 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const LDAP_PASSWORD_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "LDAP Password", + type: SecretRotation.LdapPassword, + connection: AppConnection.LDAP, + template: { + secretsMapping: { + dn: "LDAP_DN", + password: "LDAP_PASSWORD" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts new file mode 100644 index 000000000..0fd01b753 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts @@ -0,0 +1,181 @@ +import ldap from "ldapjs"; + +import { + TRotationFactory, + TRotationFactoryGetSecretsPayload, + TRotationFactoryIssueCredentials, + TRotationFactoryRevokeCredentials, + TRotationFactoryRotateCredentials +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { logger } from "@app/lib/logger"; +import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; +import { getLdapConnectionClient, LdapProvider, TLdapConnection } from "@app/services/app-connection/ldap"; + +import { generatePassword } from "../shared/utils"; +import { + TLdapPasswordRotationGeneratedCredentials, + TLdapPasswordRotationWithConnection +} from "./ldap-password-rotation-types"; + +const getEncodedPassword = (password: string) => Buffer.from(`"${password}"`, "utf16le"); + +export const ldapPasswordRotationFactory: TRotationFactory< + TLdapPasswordRotationWithConnection, + TLdapPasswordRotationGeneratedCredentials +> = (secretRotation, appConnectionDAL, kmsService) => { + const { + connection, + parameters: { dn, passwordRequirements }, + secretsMapping + } = secretRotation; + + const $verifyCredentials = async (credentials: Pick) => { + try { + const client = await getLdapConnectionClient({ ...connection.credentials, ...credentials }); + + client.unbind(); + client.destroy(); + } catch (error) { + throw new Error(`Failed to verify credentials - ${(error as Error).message}`); + } + }; + + const $rotatePassword = async () => { + const { credentials, orgId } = connection; + + if (!credentials.url.startsWith("ldaps")) throw new Error("Password Rotation requires an LDAPS connection"); + + const client = await getLdapConnectionClient(credentials); + const isPersonalRotation = credentials.dn === dn; + + const password = generatePassword(passwordRequirements); + + let changes: ldap.Change[] | ldap.Change; + + switch (credentials.provider) { + case LdapProvider.ActiveDirectory: + { + const encodedPassword = getEncodedPassword(password); + + // service account vs personal password rotation require different changes + if (isPersonalRotation) { + const currentEncodedPassword = getEncodedPassword(credentials.password); + + changes = [ + new ldap.Change({ + operation: "delete", + modification: { + type: "unicodePwd", + values: [currentEncodedPassword] + } + }), + new ldap.Change({ + operation: "add", + modification: { + type: "unicodePwd", + values: [encodedPassword] + } + }) + ]; + } else { + changes = new ldap.Change({ + operation: "replace", + modification: { + type: "unicodePwd", + values: [encodedPassword] + } + }); + } + } + break; + default: + throw new Error(`Unhandled provider: ${credentials.provider as LdapProvider}`); + } + + try { + await new Promise((resolve, reject) => { + client.modify(dn, changes, (err) => { + if (err) { + logger.error(err, "LDAP Password Rotation Failed"); + reject(new Error(`Provider Modify Error: ${err.message}`)); + } else { + resolve(true); + } + }); + }); + } finally { + client.unbind(); + client.destroy(); + } + + await $verifyCredentials({ dn, password }); + + if (isPersonalRotation) { + const updatedCredentials: TLdapConnection["credentials"] = { + ...credentials, + password + }; + + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: updatedCredentials, + orgId, + kmsService + }); + + await appConnectionDAL.updateById(connection.id, { encryptedCredentials }); + } + + return { dn, password }; + }; + + const issueCredentials: TRotationFactoryIssueCredentials = async ( + callback + ) => { + const credentials = await $rotatePassword(); + + return callback(credentials); + }; + + const revokeCredentials: TRotationFactoryRevokeCredentials = async ( + _, + callback + ) => { + // we just rotate to a new password, essentially revoking old credentials + await $rotatePassword(); + + return callback(); + }; + + const rotateCredentials: TRotationFactoryRotateCredentials = async ( + _, + callback + ) => { + const credentials = await $rotatePassword(); + + return callback(credentials); + }; + + const getSecretsPayload: TRotationFactoryGetSecretsPayload = ( + generatedCredentials + ) => { + const secrets = [ + { + key: secretsMapping.dn, + value: generatedCredentials.dn + }, + { + key: secretsMapping.password, + value: generatedCredentials.password + } + ]; + + return secrets; + }; + + return { + issueCredentials, + revokeCredentials, + rotateCredentials, + getSecretsPayload + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-schemas.ts new file mode 100644 index 000000000..e99569d9a --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-schemas.ts @@ -0,0 +1,68 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { PasswordRequirementsSchema } from "@app/ee/services/secret-rotation-v2/shared/general"; +import { SecretRotations } from "@app/lib/api-docs"; +import { DistinguishedNameRegex } from "@app/lib/regex"; +import { SecretNameSchema } from "@app/server/lib/schemas"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const LdapPasswordRotationGeneratedCredentialsSchema = z + .object({ + dn: z.string(), + password: z.string() + }) + .array() + .min(1) + .max(2); + +const LdapPasswordRotationParametersSchema = z.object({ + dn: z + .string() + .trim() + .regex(new RE2(DistinguishedNameRegex), "Invalid DN format, ie; CN=user,OU=users,DC=example,DC=com") + .min(1, "Distinguished Name (DN) Required") + .describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.dn), + passwordRequirements: PasswordRequirementsSchema.optional() +}); + +const LdapPasswordRotationSecretsMappingSchema = z.object({ + dn: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.LDAP_PASSWORD.dn), + password: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.LDAP_PASSWORD.password) +}); + +export const LdapPasswordRotationTemplateSchema = z.object({ + secretsMapping: z.object({ + dn: z.string(), + password: z.string() + }) +}); + +export const LdapPasswordRotationSchema = BaseSecretRotationSchema(SecretRotation.LdapPassword).extend({ + type: z.literal(SecretRotation.LdapPassword), + parameters: LdapPasswordRotationParametersSchema, + secretsMapping: LdapPasswordRotationSecretsMappingSchema +}); + +export const CreateLdapPasswordRotationSchema = BaseCreateSecretRotationSchema(SecretRotation.LdapPassword).extend({ + parameters: LdapPasswordRotationParametersSchema, + secretsMapping: LdapPasswordRotationSecretsMappingSchema +}); + +export const UpdateLdapPasswordRotationSchema = BaseUpdateSecretRotationSchema(SecretRotation.LdapPassword).extend({ + parameters: LdapPasswordRotationParametersSchema.optional(), + secretsMapping: LdapPasswordRotationSecretsMappingSchema.optional() +}); + +export const LdapPasswordRotationListItemSchema = z.object({ + name: z.literal("LDAP Password"), + connection: z.literal(AppConnection.LDAP), + type: z.literal(SecretRotation.LdapPassword), + template: LdapPasswordRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types.ts new file mode 100644 index 000000000..cb15b0734 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { TLdapConnection } from "@app/services/app-connection/ldap"; + +import { + CreateLdapPasswordRotationSchema, + LdapPasswordRotationGeneratedCredentialsSchema, + LdapPasswordRotationListItemSchema, + LdapPasswordRotationSchema +} from "./ldap-password-rotation-schemas"; + +export type TLdapPasswordRotation = z.infer; + +export type TLdapPasswordRotationInput = z.infer; + +export type TLdapPasswordRotationListItem = z.infer; + +export type TLdapPasswordRotationWithConnection = TLdapPasswordRotation & { + connection: TLdapConnection; +}; + +export type TLdapPasswordRotationGeneratedCredentials = z.infer; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts index 1e387a3b9..4ddf4ee0c 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts @@ -2,6 +2,7 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", Auth0ClientSecret = "auth0-client-secret", + LdapPassword = "ldap-password", AwsIamUserSecret = "aws-iam-user-secret" } diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts index 87115e7f4..23452d7d4 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts @@ -5,6 +5,7 @@ import { KmsDataKey } from "@app/services/kms/kms-types"; import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret"; import { AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION } from "./aws-iam-user-secret"; +import { LDAP_PASSWORD_ROTATION_LIST_OPTION } from "./ldap-password"; import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums"; @@ -20,6 +21,7 @@ const SECRET_ROTATION_LIST_OPTIONS: Record = { [SecretRotation.PostgresCredentials]: "PostgreSQL Credentials", [SecretRotation.MsSqlCredentials]: "Microsoft SQL Server Credentials", [SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret", + [SecretRotation.LdapPassword]: "LDAP Password", [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret" }; @@ -12,5 +13,6 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record>>; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts index af46cb36c..4d51a23c3 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { Auth0ClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret"; +import { LdapPasswordRotationSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; @@ -10,5 +11,6 @@ export const SecretRotationV2Schema = z.discriminatedUnion("type", [ PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, Auth0ClientSecretRotationSchema, + LdapPasswordRotationSchema, AwsIamUserSecretRotationSchema ]); diff --git a/backend/src/ee/services/secret-rotation-v2/shared/general/index.ts b/backend/src/ee/services/secret-rotation-v2/shared/general/index.ts new file mode 100644 index 000000000..9b2148414 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/shared/general/index.ts @@ -0,0 +1 @@ +export * from "./password-requirements-schema"; diff --git a/backend/src/ee/services/secret-rotation-v2/shared/general/password-requirements-schema.ts b/backend/src/ee/services/secret-rotation-v2/shared/general/password-requirements-schema.ts new file mode 100644 index 000000000..5d575239d --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/shared/general/password-requirements-schema.ts @@ -0,0 +1,44 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { SecretRotations } from "@app/lib/api-docs"; + +export const PasswordRequirementsSchema = z + .object({ + length: z + .number() + .min(1, "Password length must be a positive number") + .max(250, "Password length must be less than 250") + .describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.length), + required: z.object({ + digits: z + .number() + .min(0, "Digit count must be non-negative") + .describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.required.digits), + lowercase: z + .number() + .min(0, "Lowercase count must be non-negative") + .describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.required.lowercase), + uppercase: z + .number() + .min(0, "Uppercase count must be non-negative") + .describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.required.uppercase), + symbols: z + .number() + .min(0, "Symbol count must be non-negative") + .describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.required.symbols) + }), + allowedSymbols: z + .string() + .regex(new RE2("[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?~]"), "Invalid symbols") + .optional() + .describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.allowedSymbols) + }) + .refine((data) => { + return Object.values(data.required).some((count) => count > 0); + }, "At least one character type must be required") + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length") + .describe(SecretRotations.PARAMETERS.GENERAL.PASSWORD_REQUIREMENTS.base); diff --git a/backend/src/ee/services/secret-rotation-v2/shared/utils/index.ts b/backend/src/ee/services/secret-rotation-v2/shared/utils/index.ts index dfe4c22ed..9b2eb7839 100644 --- a/backend/src/ee/services/secret-rotation-v2/shared/utils/index.ts +++ b/backend/src/ee/services/secret-rotation-v2/shared/utils/index.ts @@ -1,6 +1,17 @@ import { randomInt } from "crypto"; -const DEFAULT_PASSWORD_REQUIREMENTS = { +type TPasswordRequirements = { + length: number; + required: { + lowercase: number; + uppercase: number; + digits: number; + symbols: number; + }; + allowedSymbols?: string; +}; + +const DEFAULT_PASSWORD_REQUIREMENTS: TPasswordRequirements = { length: 48, required: { lowercase: 1, @@ -11,9 +22,9 @@ const DEFAULT_PASSWORD_REQUIREMENTS = { allowedSymbols: "-_.~!*" }; -export const generatePassword = () => { +export const generatePassword = (passwordRequirements?: TPasswordRequirements) => { try { - const { length, required, allowedSymbols } = DEFAULT_PASSWORD_REQUIREMENTS; + const { length, required, allowedSymbols } = passwordRequirements ?? DEFAULT_PASSWORD_REQUIREMENTS; const chars = { lowercase: "abcdefghijklmnopqrstuvwxyz", 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 b5181ae81..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')", @@ -1858,6 +1862,16 @@ export const AppConnections = { instanceUrl: "The Windmill instance URL to connect with (defaults to https://app.windmill.dev).", accessToken: "The access token to use to connect with Windmill." }, + LDAP: { + provider: "The type of LDAP provider. Determines provider-specific behaviors.", + url: "The LDAP/LDAPS URL to connect to (e.g., 'ldap://domain-or-ip:389' or 'ldaps://domain-or-ip:636').", + dn: "The Distinguished Name (DN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com').", + password: "The password to bind with for authentication.", + sslRejectUnauthorized: + "Whether or not to reject unauthorized SSL certificates (true/false) when using ldaps://. Set to false only in test environments.", + sslCertificate: + "The SSL certificate (PEM format) to use for secure connection when using ldaps:// with a self-signed certificate." + }, TEAMCITY: { instanceUrl: "The TeamCity instance URL to connect with.", accessToken: "The access token to use to connect with TeamCity." @@ -2069,6 +2083,22 @@ export const SecretRotations = { AUTH0_CLIENT_SECRET: { clientId: "The client ID of the Auth0 Application to rotate the client secret for." }, + LDAP_PASSWORD: { + dn: "The Distinguished Name (DN) of the principal to rotate the password for." + }, + GENERAL: { + PASSWORD_REQUIREMENTS: { + base: "The password requirements to use when generating the new password.", + length: "The length of the password to generate.", + required: { + digits: "The amount of digits to require in the generated password.", + lowercase: "The amount of lowercase characters to require in the generated password.", + uppercase: "The amount of uppercase characters to require in the generated password.", + symbols: "The amount of symbols to require in the generated password." + }, + allowedSymbols: 'The allowed symbols to use in the generated password (defaults to "-_.~!*").' + } + }, AWS_IAM_USER_SECRET: { userName: "The name of the client to rotate credentials for.", region: "The AWS region the client is present in." @@ -2083,6 +2113,10 @@ export const SecretRotations = { clientId: "The name of the secret that the client ID will be mapped to.", clientSecret: "The name of the secret that the rotated client secret will be mapped to." }, + LDAP_PASSWORD: { + dn: "The name of the secret that the Distinguished Name (DN) of the principal will be mapped to.", + password: "The name of the secret that the rotated password will be mapped to." + }, AWS_IAM_USER_SECRET: { accessKeyId: "The name of the secret that the access key ID will be mapped to.", secretAccessKey: "The name of the secret that the rotated secret access key will be mapped to." 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/regex/index.ts b/backend/src/lib/regex/index.ts new file mode 100644 index 000000000..68ba7671d --- /dev/null +++ b/backend/src/lib/regex/index.ts @@ -0,0 +1,3 @@ +export const DistinguishedNameRegex = + // DN format, ie; CN=user,OU=users,DC=example,DC=com + /^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/; 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 2c1b768fb..8ceeba648 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"; @@ -214,6 +217,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"; @@ -248,6 +252,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"; @@ -417,6 +422,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); @@ -427,6 +434,11 @@ export const registerRoutes = async ( serviceTokenDAL, projectDAL }); + const assumePrivilegeService = assumePrivilegeServiceFactory({ + projectDAL, + permissionService + }); + const licenseService = licenseServiceFactory({ permissionService, orgDAL, @@ -549,6 +561,15 @@ export const registerRoutes = async ( externalGroupOrgRoleMappingDAL }); + const githubOrgSyncConfigService = githubOrgSyncServiceFactory({ + licenseService, + githubOrgSyncDAL, + kmsService, + permissionService, + groupDAL, + userGroupMembershipDAL + }); + const ldapService = ldapConfigServiceFactory({ ldapConfigDAL, ldapGroupMapDAL, @@ -728,6 +749,7 @@ export const registerRoutes = async ( projectKeyDAL, projectRoleDAL, groupProjectDAL, + secretReminderRecipientsDAL, licenseService }); const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ @@ -961,6 +983,7 @@ export const registerRoutes = async ( secretApprovalRequestDAL, projectKeyDAL, projectUserMembershipRoleDAL, + secretReminderRecipientsDAL, orgService, resourceMetadataDAL, secretSyncQueue @@ -1022,7 +1045,9 @@ export const registerRoutes = async ( projectRoleDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, - projectDAL + projectDAL, + identityDAL, + userDAL }); const snapshotService = secretSnapshotServiceFactory({ @@ -1675,7 +1700,9 @@ export const registerRoutes = async ( kmip: kmipService, kmipOperation: kmipOperationService, gateway: gatewayService, - secretRotationV2: secretRotationV2Service + secretRotationV2: secretRotationV2Service, + assumePrivileges: assumePrivilegeService, + githubOrgSync: githubOrgSyncConfigService }); const cronJobs: CronJob[] = []; @@ -1696,6 +1723,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); @@ -1735,30 +1763,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/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index d472a9cb1..0b565f7d8 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -28,6 +28,7 @@ import { HumanitecConnectionListItemSchema, SanitizedHumanitecConnectionSchema } from "@app/services/app-connection/humanitec"; +import { LdapConnectionListItemSchema, SanitizedLdapConnectionSchema } from "@app/services/app-connection/ldap"; import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql"; import { PostgresConnectionListItemSchema, @@ -64,6 +65,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedCamundaConnectionSchema.options, ...SanitizedWindmillConnectionSchema.options, ...SanitizedAuth0ConnectionSchema.options, + ...SanitizedLdapConnectionSchema.options, ...SanitizedTeamCityConnectionSchema.options ]); @@ -82,6 +84,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ CamundaConnectionListItemSchema, WindmillConnectionListItemSchema, Auth0ConnectionListItemSchema, + LdapConnectionListItemSchema, TeamCityConnectionListItemSchema ]); diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index de9db9d85..c2398fd78 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -1,6 +1,6 @@ -import { registerAuth0ConnectionRouter } from "@app/server/routes/v1/app-connection-routers/auth0-connection-router"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { registerAuth0ConnectionRouter } from "./auth0-connection-router"; import { registerAwsConnectionRouter } from "./aws-connection-router"; import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-configuration-connection-router"; import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connection-router"; @@ -9,6 +9,7 @@ import { registerDatabricksConnectionRouter } from "./databricks-connection-rout import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; +import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; @@ -34,5 +35,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.LDAP, + server, + sanitizedResponseSchema: SanitizedLdapConnectionSchema, + createSchema: CreateLdapConnectionSchema, + updateSchema: UpdateLdapConnectionSchema + }); +}; 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 3731a7aa0..d8e1ae543 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -14,9 +14,11 @@ 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 { 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"; @@ -99,7 +101,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientID: appCfg.CLIENT_ID_GITHUB_LOGIN!, clientSecret: appCfg.CLIENT_SECRET_GITHUB_LOGIN!, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/github`, - scope: ["user:email"], + scope: ["user:email", "read:org"], state: true, pkce: true, passReqToCallback: true @@ -125,7 +127,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { callbackPort }); - done(null, { isUserCompleted, providerAuthToken }); + done(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken }); } catch (err) { logger.error(err); done(err as Error, false); @@ -179,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"], @@ -213,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( @@ -230,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, @@ -302,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( @@ -319,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, @@ -355,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/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 8852ed6bc..de20f3f64 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -13,6 +13,7 @@ export enum AppConnection { Camunda = "camunda", Windmill = "windmill", Auth0 = "auth0", + LDAP = "ldap", TeamCity = "teamcity" } diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 076f52f99..92595619c 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -41,6 +41,7 @@ import { HumanitecConnectionMethod, validateHumanitecConnectionCredentials } from "./humanitec"; +import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnectionCredentials } from "./ldap"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { @@ -77,6 +78,7 @@ export const listAppConnectionOptions = () => { getCamundaConnectionListItem(), getWindmillConnectionListItem(), getAuth0ConnectionListItem(), + getLdapConnectionListItem(), getTeamCityConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -142,6 +144,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Auth0]: validateAuth0ConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.LDAP]: validateLdapConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.TeamCity]: validateTeamCityConnectionCredentials as TAppConnectionCredentialsValidator }; @@ -178,6 +181,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "Access Token"; case Auth0ConnectionMethod.ClientCredentials: return "Client Credentials"; + case LdapConnectionMethod.SimpleBind: + return "Simple Bind"; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection Method: ${method}`); @@ -223,5 +228,6 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Vercel]: platformManagedCredentialsNotSupported, [AppConnection.Windmill]: platformManagedCredentialsNotSupported, [AppConnection.Auth0]: platformManagedCredentialsNotSupported, + [AppConnection.LDAP]: platformManagedCredentialsNotSupported, // we could support this in the future [AppConnection.TeamCity]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 41cef4af1..524993b23 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -15,5 +15,6 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Camunda]: "Camunda", [AppConnection.Windmill]: "Windmill", [AppConnection.Auth0]: "Auth0", + [AppConnection.LDAP]: "LDAP", [AppConnection.TeamCity]: "TeamCity" }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 5ca0c1424..e872c07e4 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -43,6 +43,7 @@ import { ValidateGitHubConnectionCredentialsSchema } from "./github"; import { githubConnectionService } from "./github/github-connection-service"; import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; +import { ValidateLdapConnectionCredentialsSchema } from "./ldap"; import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; import { ValidateTeamCityConnectionCredentialsSchema } from "./teamcity"; @@ -77,6 +78,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { + return { + name: "LDAP" as const, + app: AppConnection.LDAP as const, + methods: Object.values(LdapConnectionMethod) as [LdapConnectionMethod.SimpleBind] + }; +}; + +const LDAP_TIMEOUT = 15_000; + +export const getLdapConnectionClient = async ({ + url, + dn, + password, + sslCertificate, + sslRejectUnauthorized = true +}: TLdapConnectionConfig["credentials"]) => { + await blockLocalAndPrivateIpAddresses(url); + + const isSSL = url.startsWith("ldaps"); + + return new Promise((resolve, reject) => { + const client = ldap.createClient({ + url, + timeout: LDAP_TIMEOUT, + connectTimeout: LDAP_TIMEOUT, + tlsOptions: isSSL + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate ? [sslCertificate] : undefined + } + : undefined + }); + + client.on("error", (err: Error) => { + logger.error(err, "LDAP Error"); + client.destroy(); + reject(new Error(`Provider Error - ${err.message}`)); + }); + + client.on("connectError", (err: Error) => { + logger.error(err, "LDAP Connection Error"); + client.destroy(); + reject(new Error(`Provider Connect Error - ${err.message}`)); + }); + + client.on("connectRefused", (err: Error) => { + logger.error(err, "LDAP Connection Refused"); + client.destroy(); + reject(new Error(`Provider Connection Refused - ${err.message}`)); + }); + + client.on("connectTimeout", (err: Error) => { + logger.error(err, "LDAP Connection Timeout"); + client.destroy(); + reject(new Error(`Provider Connection Timeout - ${err.message}`)); + }); + + client.on("connect", () => { + client.bind(dn, password, (err) => { + if (err) { + logger.error(err, "LDAP Bind Error"); + reject(new Error(`Bind Error: ${err.message}`)); + client.destroy(); + } + + resolve(client); + }); + }); + }); +}; + +export const validateLdapConnectionCredentials = async ({ credentials }: TLdapConnectionConfig) => { + let client: ldap.Client | undefined; + + try { + client = await getLdapConnectionClient(credentials); + + // this shouldn't occur as handle connection error events in client but here as fallback + if (!client.connected) { + throw new BadRequestError({ message: "Unable to connect to LDAP server" }); + } + + return credentials; + } catch (e: unknown) { + throw new BadRequestError({ + message: `Unable to validate connection: ${(e as Error).message || "verify credentials"}` + }); + } finally { + client?.destroy(); + } +}; diff --git a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts new file mode 100644 index 000000000..91884b914 --- /dev/null +++ b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts @@ -0,0 +1,93 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { DistinguishedNameRegex } from "@app/lib/regex"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { LdapConnectionMethod, LdapProvider } from "./ldap-connection-enums"; + +export const LdapConnectionSimpleBindCredentialsSchema = z.object({ + provider: z.nativeEnum(LdapProvider).describe(AppConnections.CREDENTIALS.LDAP.provider), + url: z + .string() + .trim() + .min(1, "URL required") + .regex(new RE2(/^ldaps?:\/\//)) + .describe(AppConnections.CREDENTIALS.LDAP.url), + dn: z + .string() + .trim() + .regex(new RE2(DistinguishedNameRegex), "Invalid DN format, ie; CN=user,OU=users,DC=example,DC=com") + .min(1, "Distinguished Name (DN) required") + .describe(AppConnections.CREDENTIALS.LDAP.dn), + password: z.string().trim().min(1, "Password required").describe(AppConnections.CREDENTIALS.LDAP.password), + sslRejectUnauthorized: z.boolean().optional().describe(AppConnections.CREDENTIALS.LDAP.sslRejectUnauthorized), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() + .describe(AppConnections.CREDENTIALS.LDAP.sslCertificate) +}); + +const BaseLdapConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.LDAP) +}); + +export const LdapConnectionSchema = z.intersection( + BaseLdapConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(LdapConnectionMethod.SimpleBind), + credentials: LdapConnectionSimpleBindCredentialsSchema + }) + ]) +); + +export const SanitizedLdapConnectionSchema = z.discriminatedUnion("method", [ + BaseLdapConnectionSchema.extend({ + method: z.literal(LdapConnectionMethod.SimpleBind), + credentials: LdapConnectionSimpleBindCredentialsSchema.pick({ + provider: true, + url: true, + dn: true, + sslRejectUnauthorized: true, + sslCertificate: true + }) + }) +]); + +export const ValidateLdapConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(LdapConnectionMethod.SimpleBind).describe(AppConnections.CREATE(AppConnection.LDAP).method), + credentials: LdapConnectionSimpleBindCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.LDAP).credentials + ) + }) +]); + +export const CreateLdapConnectionSchema = ValidateLdapConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.LDAP) +); + +export const UpdateLdapConnectionSchema = z + .object({ + credentials: LdapConnectionSimpleBindCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.LDAP).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.LDAP)); + +export const LdapConnectionListItemSchema = z.object({ + name: z.literal("LDAP"), + app: z.literal(AppConnection.LDAP), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(AwsConnectionMethod.ServicePrincipal), z.literal(AwsConnectionMethod.AccessKey)]), + methods: z.nativeEnum(LdapConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/ldap/ldap-connection-types.ts b/backend/src/services/app-connection/ldap/ldap-connection-types.ts new file mode 100644 index 000000000..ee69b2542 --- /dev/null +++ b/backend/src/services/app-connection/ldap/ldap-connection-types.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { + CreateLdapConnectionSchema, + LdapConnectionSchema, + ValidateLdapConnectionCredentialsSchema +} from "./ldap-connection-schemas"; + +export type TLdapConnection = z.infer; + +export type TLdapConnectionInput = z.infer & { + app: AppConnection.LDAP; +}; + +export type TValidateLdapConnectionCredentialsSchema = typeof ValidateLdapConnectionCredentialsSchema; + +export type TLdapConnectionConfig = DiscriminativePick & { + orgId: string; +}; diff --git a/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts index 38ef0eef6..994f9a40d 100644 --- a/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts +++ b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts @@ -31,7 +31,8 @@ export const SanitizedMsSqlConnectionSchema = z.discriminatedUnion("method", [ port: true, username: true, sslEnabled: true, - sslRejectUnauthorized: true + sslRejectUnauthorized: true, + sslCertificate: true }) }) ]); diff --git a/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts index 510f7b7d0..1ddf1e2da 100644 --- a/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts +++ b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts @@ -29,7 +29,8 @@ export const SanitizedPostgresConnectionSchema = z.discriminatedUnion("method", port: true, username: true, sslEnabled: true, - sslRejectUnauthorized: true + sslRejectUnauthorized: true, + sslCertificate: true }) }) ]); 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-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index 05fc7cd35..cf389ac2a 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 @@ -79,7 +79,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 +113,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 +511,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 +545,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 +593,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..5c2f6a2f0 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 @@ -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,6 +717,7 @@ export const reshapeBridgeSecret = ( updatedAt: secret.updatedAt, isRotatedSecret: secret.isRotatedSecret, rotationId: secret.rotationId, + secretReminderRecipients: secret.secretReminderRecipients || [], ...(secretValueHidden ? { secretValue: INFISICAL_SECRET_VALUE_HIDDEN_MASK, 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..010f5beb1 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 }); 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/api-reference/endpoints/app-connections/ldap/available.mdx b/docs/api-reference/endpoints/app-connections/ldap/available.mdx new file mode 100644 index 000000000..b42f2bc3d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/ldap/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/ldap/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/ldap/create.mdx b/docs/api-reference/endpoints/app-connections/ldap/create.mdx new file mode 100644 index 000000000..181a76902 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/ldap/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/ldap" +--- + + + Check out the configuration docs for [LDAP Connections](/integrations/app-connections/ldap) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/ldap/delete.mdx b/docs/api-reference/endpoints/app-connections/ldap/delete.mdx new file mode 100644 index 000000000..4888fd04d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/ldap/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/ldap/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/ldap/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/ldap/get-by-id.mdx new file mode 100644 index 000000000..7c4524ed8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/ldap/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/ldap/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/ldap/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/ldap/get-by-name.mdx new file mode 100644 index 000000000..dc7516bba --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/ldap/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/ldap/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/ldap/list.mdx b/docs/api-reference/endpoints/app-connections/ldap/list.mdx new file mode 100644 index 000000000..e909c9266 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/ldap/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/ldap" +--- diff --git a/docs/api-reference/endpoints/app-connections/ldap/update.mdx b/docs/api-reference/endpoints/app-connections/ldap/update.mdx new file mode 100644 index 000000000..06c7f7f77 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/ldap/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/ldap/{connectionId}" +--- + + + Check out the configuration docs for [LDAP Connections](/integrations/app-connections/ldap) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-rotations/ldap-password/create.mdx b/docs/api-reference/endpoints/secret-rotations/ldap-password/create.mdx new file mode 100644 index 000000000..682b531ad --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/ldap-password/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/ldap-password" +--- + + + Check out the configuration docs for [LDAP Password Rotations](/documentation/platform/secret-rotation/ldap-password) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-rotations/ldap-password/delete.mdx b/docs/api-reference/endpoints/secret-rotations/ldap-password/delete.mdx new file mode 100644 index 000000000..d4cee951f --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/ldap-password/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/ldap-password/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/ldap-password/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/ldap-password/get-by-id.mdx new file mode 100644 index 000000000..f422d036d --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/ldap-password/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/ldap-password/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/ldap-password/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/ldap-password/get-by-name.mdx new file mode 100644 index 000000000..68de6a722 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/ldap-password/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/ldap-password/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/ldap-password/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/ldap-password/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..6aed49218 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/ldap-password/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/ldap-password/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/ldap-password/list.mdx b/docs/api-reference/endpoints/secret-rotations/ldap-password/list.mdx new file mode 100644 index 000000000..bf2bb5562 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/ldap-password/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/ldap-password" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/ldap-password/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/ldap-password/rotate-secrets.mdx new file mode 100644 index 000000000..8ad2ae52b --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/ldap-password/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/ldap-password/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/ldap-password/update.mdx b/docs/api-reference/endpoints/secret-rotations/ldap-password/update.mdx new file mode 100644 index 000000000..b59ea5250 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/ldap-password/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/ldap-password/{rotationId}" +--- + + + Check out the configuration docs for [LDAP Rotations](/documentation/platform/secret-rotation/ldap-password) to learn how to obtain the + required parameters. + \ No newline at end of file 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/github-org-sync.mdx b/docs/documentation/platform/github-org-sync.mdx new file mode 100644 index 000000000..426c51060 --- /dev/null +++ b/docs/documentation/platform/github-org-sync.mdx @@ -0,0 +1,56 @@ +--- +title: "GitHub Organization 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/documentation/platform/secret-rotation/auth0-client-secret.mdx b/docs/documentation/platform/secret-rotation/auth0-client-secret.mdx index 3845a3879..0fd43f2c4 100644 --- a/docs/documentation/platform/secret-rotation/auth0-client-secret.mdx +++ b/docs/documentation/platform/secret-rotation/auth0-client-secret.mdx @@ -1,5 +1,5 @@ --- -title: "Auth0 Client Secret" +title: "Auth0 Client Secret Rotation" description: "Learn how to automatically rotate Auth0 Client Secrets." --- diff --git a/docs/documentation/platform/secret-rotation/ldap-password.mdx b/docs/documentation/platform/secret-rotation/ldap-password.mdx new file mode 100644 index 000000000..103fe4656 --- /dev/null +++ b/docs/documentation/platform/secret-rotation/ldap-password.mdx @@ -0,0 +1,173 @@ +--- +title: "LDAP Password Rotation" +description: "Learn how to automatically rotate LDAP passwords." +--- + + + Due to how LDAP passwords are rotated, retired credentials will not be able to + authenticate with the LDAP provider during their [inactive period](./overview#how-rotation-works). + + This is a limitation of the LDAP provider and cannot be + rectified by Infisical. + + +## Prerequisites + +- Create an [LDAP Connection](/integrations/app-connections/ldap) with the **Secret Rotation** requirements + +## Create an LDAP Password Rotation in Infisical + + + + 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown. + ![Secret Manager Dashboard](/images/secret-rotations-v2/generic/add-secret-rotation.png) + + 2. Select the **LDAP Password** option. + ![Select LDAP Password](/images/secret-rotations-v2/ldap-password/select-ldap-password-option.png) + + 3. Select the **LDAP Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/ldap-password/ldap-password-configuration.png) + + - **LDAP Connection** - the connection that will perform the rotation of the configured DN's password. + + LDAP Password Rotations require an LDAP Connection that uses ldaps:// protocol. + + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. + + Due to LDAP Password Rotations rotating a single credential set, auto-rotation may result in service interruptions. If you need to ensure service continuity, we recommend disabling this option. + + + + 4. Specify the Distinguished Name (DN) of the principal whose password you want to rotate and configure the password requirements. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png) + + 5. Specify the secret names that the client credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/ldap-password/ldap-password-secrets-mapping.png) + + - **DN** - the name of the secret that the principal's Distinguished Name (DN) will be mapped to. + - **Password** - the name of the secret that the rotated password will be mapped to. + + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/ldap-password/ldap-password-details.png) + + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. + + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/ldap-password/ldap-password-confirm.png) + + 8. Your **LDAP Password** credentials are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/ldap-password/ldap-password-created.png) + + + To create an LDAP Password Rotation, make an API request to the [Create LDAP + Password Rotation](/api-reference/endpoints/secret-rotations/ldap-password/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/ldap-password \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-ldap-rotation", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my ldap password rotation", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": false, + "rotationInterval": 30, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "parameters": { + "dn": "CN=John,CN=Users,DC=example,DC=com", + "passwordRequirements": { + "length": 48, + "required": { + "digits": 2, + "lowercase": 2, + "uppercase": 2, + "symbols": 2 + }, + "allowedSymbols": "-_.~!*" + } + }, + "secretsMapping": { + "dn": "LDAP_DN", + "password": "LDAP_PASSWORD" + } + }' + ``` + + + Due to LDAP Password Rotations rotating a single credential set, auto-rotation may result in service interruptions. If you need to ensure service continuity, we recommend disabling this option. + + + ### Sample response + + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-ldap-rotation", + "description": "my ldap password rotation", + "secretsMapping": { + "dn": "LDAP_DN", + "password": "LDAP_PASSWORD" + }, + "isAutoRotationEnabled": false, + "activeIndex": 0, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 30, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "nextRotationAt": "2023-11-07T05:31:56Z", + "connection": { + "app": "ldap", + "name": "my-ldap-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "lastRotationMessage": null, + "type": "ldap-password", + "parameters": { + "dn": "CN=John,CN=Users,DC=example,DC=com", + "passwordRequirements": { + "length": 48, + "required": { + "digits": 2, + "lowercase": 2, + "uppercase": 2, + "symbols": 2 + }, + "allowedSymbols": "-_.~!*" + } + } + } + } + ``` + + diff --git a/docs/documentation/platform/secret-rotation/mssql-credentials.mdx b/docs/documentation/platform/secret-rotation/mssql-credentials.mdx index 8789e4152..c20622f26 100644 --- a/docs/documentation/platform/secret-rotation/mssql-credentials.mdx +++ b/docs/documentation/platform/secret-rotation/mssql-credentials.mdx @@ -1,5 +1,5 @@ --- -title: "Microsoft SQL Server Credentials" +title: "Microsoft SQL Server Credentials Rotation" description: "Learn how to automatically rotate Microsoft SQL Server credentials." --- diff --git a/docs/documentation/platform/secret-rotation/postgres-credentials.mdx b/docs/documentation/platform/secret-rotation/postgres-credentials.mdx index e0606e6ab..55175d967 100644 --- a/docs/documentation/platform/secret-rotation/postgres-credentials.mdx +++ b/docs/documentation/platform/secret-rotation/postgres-credentials.mdx @@ -1,5 +1,5 @@ --- -title: "PostgreSQL Credentials" +title: "PostgreSQL Credentials Rotation" description: "Learn how to automatically rotate PostgreSQL credentials." --- diff --git a/docs/images/app-connections/ldap/create-simple-bind-method.png b/docs/images/app-connections/ldap/create-simple-bind-method.png new file mode 100644 index 000000000..e7fff6789 Binary files /dev/null and b/docs/images/app-connections/ldap/create-simple-bind-method.png differ diff --git a/docs/images/app-connections/ldap/select-ldap-connection.png b/docs/images/app-connections/ldap/select-ldap-connection.png new file mode 100644 index 000000000..48465df67 Binary files /dev/null and b/docs/images/app-connections/ldap/select-ldap-connection.png differ diff --git a/docs/images/app-connections/ldap/simple-bind-connection.png b/docs/images/app-connections/ldap/simple-bind-connection.png new file mode 100644 index 000000000..8013a4687 Binary files /dev/null and b/docs/images/app-connections/ldap/simple-bind-connection.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/images/secret-rotations-v2/ldap-password/ldap-password-configuration.png b/docs/images/secret-rotations-v2/ldap-password/ldap-password-configuration.png new file mode 100644 index 000000000..91a2f2fb3 Binary files /dev/null and b/docs/images/secret-rotations-v2/ldap-password/ldap-password-configuration.png differ diff --git a/docs/images/secret-rotations-v2/ldap-password/ldap-password-confirm.png b/docs/images/secret-rotations-v2/ldap-password/ldap-password-confirm.png new file mode 100644 index 000000000..1725c4355 Binary files /dev/null and b/docs/images/secret-rotations-v2/ldap-password/ldap-password-confirm.png differ diff --git a/docs/images/secret-rotations-v2/ldap-password/ldap-password-created.png b/docs/images/secret-rotations-v2/ldap-password/ldap-password-created.png new file mode 100644 index 000000000..4172ec7f7 Binary files /dev/null and b/docs/images/secret-rotations-v2/ldap-password/ldap-password-created.png differ diff --git a/docs/images/secret-rotations-v2/ldap-password/ldap-password-details.png b/docs/images/secret-rotations-v2/ldap-password/ldap-password-details.png new file mode 100644 index 000000000..ed41c13ad Binary files /dev/null and b/docs/images/secret-rotations-v2/ldap-password/ldap-password-details.png differ diff --git a/docs/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png b/docs/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png new file mode 100644 index 000000000..dfe723b06 Binary files /dev/null and b/docs/images/secret-rotations-v2/ldap-password/ldap-password-parameters.png differ diff --git a/docs/images/secret-rotations-v2/ldap-password/ldap-password-secrets-mapping.png b/docs/images/secret-rotations-v2/ldap-password/ldap-password-secrets-mapping.png new file mode 100644 index 000000000..997073bc5 Binary files /dev/null and b/docs/images/secret-rotations-v2/ldap-password/ldap-password-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/ldap-password/select-ldap-password-option.png b/docs/images/secret-rotations-v2/ldap-password/select-ldap-password-option.png new file mode 100644 index 000000000..4dd500fe1 Binary files /dev/null and b/docs/images/secret-rotations-v2/ldap-password/select-ldap-password-option.png differ diff --git a/docs/integrations/app-connections/ldap.mdx b/docs/integrations/app-connections/ldap.mdx new file mode 100644 index 000000000..63c4bfed1 --- /dev/null +++ b/docs/integrations/app-connections/ldap.mdx @@ -0,0 +1,96 @@ +--- +title: "LDAP Connection" +description: "Learn how to configure an LDAP Connection for Infisical." +--- + +Infisical supports the use of [Simple Binding](https://ldap.com/the-ldap-bind-operation) to connect with your LDAP provider. + +## Prerequisites + +You will need the following information to establish an LDAP connection: + +- **LDAP URL** - The LDAP/LDAPS URL to connect to (e.g., ldap://domain-or-ip:389 or ldaps://domain-or-ip:636) +- **Binding DN** - The Distinguished Name (DN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com') +- **Binding Password** - The password to bind with for authentication +- **CA Certificate** - The SSL certificate (PEM format) to use for secure connection when using ldaps:// with a self-signed certificate + +Depending on how you intend to use your LDAP connection, there may be additional requirements: + + + + + For Password Rotation, the following requirements must additionally be met: + - You must use an LDAPS connection + - The binding user must either have: + - Permission to change other users passwords if rotating directory users' passwords + - Permission to update their own password if rotating their personal password + + + + + +## Setup LDAP Connection in Infisical + + + + 1. Navigate to the App Connections tab on the Organization Settings page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + 2. Select the **LDAP Connection** option. + ![Select LDAP Connection](/images/app-connections/ldap/select-ldap-connection.png) + + 3. Select the **Simple Bind** method option and provide the details obtained from the previous section and press **Connect to Provider**. + ![Create LDAP Connection](/images/app-connections/ldap/create-simple-bind-method.png) + + 4. Your **LDAP Connection** is now available for use. + ![Assume Role LDAP Connection](/images/app-connections/ldap/simple-bind-connection.png) + + + To create an LDAP Connection, make an API request to the [Create LDAP + Connection](/api-reference/endpoints/app-connections/ldap/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/ldap \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-ldap-connection", + "method": "simple-bind", + "credentials": { + "provider": "active-directory", + "url": "ldaps://domain-or-ip:636", + "dn": "CN=John,CN=Users,DC=example,DC=com", + "password": "", + "sslRejectUnauthorized": true, + "sslCertificate": "..." + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-ldap-connection", + "version": 1, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "ldap", + "method": "simple-bind", + "credentials": { + "provider": "active-directory", + "url": "ldaps://domain-or-ip:636", + "dn": "CN=John,CN=Users,DC=example,DC=com", + "sslRejectUnauthorized": true, + "sslCertificate": "..." + } + } + } + ``` + + diff --git a/docs/mint.json b/docs/mint.json index cebdf9e7f..69470e2b7 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -180,8 +180,9 @@ "documentation/platform/secret-rotation/overview", "documentation/platform/secret-rotation/auth0-client-secret", "documentation/platform/secret-rotation/aws-iam-user-secret", - "documentation/platform/secret-rotation/postgres-credentials", - "documentation/platform/secret-rotation/mssql-credentials" + "documentation/platform/secret-rotation/ldap-password", + "documentation/platform/secret-rotation/mssql-credentials", + "documentation/platform/secret-rotation/postgres-credentials" ] }, { @@ -298,7 +299,8 @@ "documentation/platform/scim/jumpcloud", "documentation/platform/scim/group-mappings" ] - } + }, + "documentation/platform/github-org-sync" ] }, { @@ -433,6 +435,7 @@ "integrations/app-connections/gcp", "integrations/app-connections/github", "integrations/app-connections/humanitec", + "integrations/app-connections/ldap", "integrations/app-connections/mssql", "integrations/app-connections/postgres", "integrations/app-connections/teamcity", @@ -883,6 +886,19 @@ "api-reference/endpoints/secret-rotations/aws-iam-user-secret/update" ] }, + { + "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", + "api-reference/endpoints/secret-rotations/ldap-password/get-by-name", + "api-reference/endpoints/secret-rotations/ldap-password/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/ldap-password/list", + "api-reference/endpoints/secret-rotations/ldap-password/rotate-secrets", + "api-reference/endpoints/secret-rotations/ldap-password/update" + ] + }, { "group": "Microsoft SQL Server Credentials", "pages": [ @@ -1035,6 +1051,18 @@ "api-reference/endpoints/app-connections/humanitec/delete" ] }, + { + "group": "LDAP", + "pages": [ + "api-reference/endpoints/app-connections/ldap/list", + "api-reference/endpoints/app-connections/ldap/available", + "api-reference/endpoints/app-connections/ldap/get-by-id", + "api-reference/endpoints/app-connections/ldap/get-by-name", + "api-reference/endpoints/app-connections/ldap/create", + "api-reference/endpoints/app-connections/ldap/update", + "api-reference/endpoints/app-connections/ldap/delete" + ] + }, { "group": "Microsoft SQL Server", "pages": [ diff --git a/frontend/public/images/integrations/LDAP.png b/frontend/public/images/integrations/LDAP.png new file mode 100644 index 000000000..4cf290176 Binary files /dev/null and b/frontend/public/images/integrations/LDAP.png differ diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAuth0ClientSecretRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAuth0ClientSecretRotationGeneratedCredentials.tsx index d2340f42d..420889d2b 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAuth0ClientSecretRotationGeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAuth0ClientSecretRotationGeneratedCredentials.tsx @@ -1,8 +1,6 @@ -import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay"; -import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2"; import { TAuth0ClientSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/auth0-client-secret-rotation"; -import { ViewRotationGeneratedCredentialsDisplay } from "./shared"; +import { CredentialDisplay, ViewRotationGeneratedCredentialsDisplay } from "./shared"; type Props = { generatedCredentialsResponse: TAuth0ClientSecretRotationGeneratedCredentialsResponse; @@ -17,40 +15,23 @@ export const ViewAuth0ClientSecretRotationGeneratedCredentials = ({ const inactiveCredentials = generatedCredentials[inactiveIndex]; return ( - <> - - {activeCredentials?.clientId} - - {activeCredentials?.clientSecret} - - - } - inactiveCredentials={ - <> - {inactiveCredentials?.clientId} - - {inactiveCredentials?.clientSecret} - - - } - /> - -

- Due to how Auth0 client secrets are rotated, retired credentials will not be able to - authenticate with Auth0 during their{" "} - - inactive period - - . This is a limitation of the Auth0 platform and cannot be rectified by Infisical. -

-
- + + {activeCredentials?.clientId} + + {activeCredentials?.clientSecret} + + + } + inactiveCredentials={ + <> + {inactiveCredentials?.clientId} + + {inactiveCredentials?.clientSecret} + + + } + /> ); }; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials.tsx new file mode 100644 index 000000000..238dabea3 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials.tsx @@ -0,0 +1,41 @@ +import { TLdapPasswordRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation"; + +import { CredentialDisplay, ViewRotationGeneratedCredentialsDisplay } from "./shared"; + +type Props = { + generatedCredentialsResponse: TLdapPasswordRotationGeneratedCredentialsResponse; +}; + +export const ViewLdapPasswordRotationGeneratedCredentials = ({ + generatedCredentialsResponse: { generatedCredentials, activeIndex } +}: Props) => { + const inactiveIndex = activeIndex === 0 ? 1 : 0; + + const activeCredentials = generatedCredentials[activeIndex]; + const inactiveCredentials = generatedCredentials[inactiveIndex]; + + return ( + + + {activeCredentials?.dn} + + + {activeCredentials?.password} + + + } + inactiveCredentials={ + <> + + {inactiveCredentials?.dn} + + + {inactiveCredentials?.password} + + + } + /> + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx index 12ad1f9a5..d5af51eef 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -4,8 +4,15 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { ViewAuth0ClientSecretRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAuth0ClientSecretRotationGeneratedCredentials"; +import { ViewLdapPasswordRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials"; import { Modal, ModalContent, Spinner } from "@app/components/v2"; -import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { + IS_ROTATION_DUAL_CREDENTIALS, + SECRET_ROTATION_CONNECTION_MAP, + SECRET_ROTATION_MAP +} from "@app/helpers/secretRotationsV2"; import { SecretRotation, TSecretRotationV2, @@ -68,6 +75,13 @@ const Content = ({ secretRotation }: ContentProps) => { /> ); break; + case SecretRotation.LdapPassword: + Component = ( + + ); + break; case SecretRotation.AwsIamUserSecret: Component = ( { throw new Error("Unhandled View Generated Credential Rotation Type"); } + const appName = APP_CONNECTION_MAP[SECRET_ROTATION_CONNECTION_MAP[type]].name; + return (
{Component} + {!IS_ROTATION_DUAL_CREDENTIALS[type] && ( + +

+ Due to {SECRET_ROTATION_MAP[type].name} Rotations utilizing a single credential set, + retired credentials will not be able to authenticate with {appName} during their{" "} + + inactive period + + . This is a limitation of {appName} and cannot be rectified by Infisical. +

+
+ )} {nextRotationAt && (
diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/LdapPasswordRotationParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/LdapPasswordRotationParametersFields.tsx new file mode 100644 index 000000000..9c9d8329f --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/LdapPasswordRotationParametersFields.tsx @@ -0,0 +1,169 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { DEFAULT_PASSWORD_REQUIREMENTS } from "@app/components/secret-rotations-v2/forms/schemas/shared"; +import { FormControl, Input } from "@app/components/v2"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +export const LdapPasswordRotationParametersFields = () => { + const { control } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.LdapPassword; + } + >(); + + return ( + <> + ( + + + + )} + /> +
+
+ Password Requirements +
+
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(e.target.value)} + /> + + )} + /> +
+
+ + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx index e0841d096..cdbf63111 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx @@ -5,12 +5,14 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2Form } from "../schemas"; import { Auth0ClientSecretRotationParametersFields } from "./Auth0ClientSecretRotationParametersFields"; import { AwsIamUserSecretRotationParametersFields } from "./AwsIamUserSecretRotationParametersFields"; +import { LdapPasswordRotationParametersFields } from "./LdapPasswordRotationParametersFields"; import { SqlCredentialsRotationParametersFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationParametersFields, + [SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationParametersFields }; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/LdapPasswordRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/LdapPasswordRotationReviewFields.tsx new file mode 100644 index 000000000..1ffcad139 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/LdapPasswordRotationReviewFields.tsx @@ -0,0 +1,29 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +import { SecretRotationReviewSection } from "./shared"; + +export const LdapPasswordRotationReviewFields = () => { + const { watch } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.LdapPassword; + } + >(); + + const [parameters, { dn, password }] = watch(["parameters", "secretsMapping"]); + + return ( + <> + + {parameters.dn} + + + {dn} + {password} + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx index d8624e308..23ee25d7f 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx @@ -8,12 +8,14 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { Auth0ClientSecretRotationReviewFields } from "./Auth0ClientSecretRotationReviewFields"; import { AwsIamUserSecretRotationReviewFields } from "./AwsIamUserSecretRotationReviewFields"; +import { LdapPasswordRotationReviewFields } from "./LdapPasswordRotationReviewFields"; import { SqlCredentialsRotationReviewFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationReviewFields, + [SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationReviewFields }; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/LdapPasswordRotationSecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/LdapPasswordRotationSecretsMappingFields.tsx new file mode 100644 index 000000000..01d2e0d74 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/LdapPasswordRotationSecretsMappingFields.tsx @@ -0,0 +1,58 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { FormControl, Input } from "@app/components/v2"; +import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2"; + +import { SecretsMappingTable } from "./shared"; + +export const LdapPasswordRotationSecretsMappingFields = () => { + const { control } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.LdapPassword; + } + >(); + + const { rotationOption } = useSecretRotationV2Option(SecretRotation.LdapPassword); + + const items = [ + { + name: "DN", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.dn" + /> + ) + }, + { + name: "Password", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.password" + /> + ) + } + ]; + + return ; +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx index 6ede945db..16bffe6cf 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx @@ -5,12 +5,14 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2Form } from "../schemas"; import { Auth0ClientSecretRotationSecretsMappingFields } from "./Auth0ClientSecretRotationSecretsMappingFields"; import { AwsIamUserSecretRotationSecretsMappingFields } from "./AwsIamUserSecretRotationSecretsMappingFields"; +import { LdapPasswordRotationSecretsMappingFields } from "./LdapPasswordRotationSecretsMappingFields"; import { SqlCredentialsRotationSecretsMappingFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationSecretsMappingFields, + [SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationSecretsMappingFields }; diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts index acd56e571..6dd65b0bb 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { Auth0ClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/auth0-client-secret-rotation-schema"; import { AwsIamUserSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema"; +import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema"; import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema"; import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema"; @@ -9,6 +10,7 @@ const SecretRotationUnionSchema = z.discriminatedUnion("type", [ PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, Auth0ClientSecretRotationSchema, + LdapPasswordRotationSchema, AwsIamUserSecretRotationSchema ]); diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema.ts new file mode 100644 index 000000000..e18609f04 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema"; +import { PasswordRequirementsSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared"; +import { DistinguishedNameRegex } from "@app/helpers/string"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +export const LdapPasswordRotationSchema = z + .object({ + type: z.literal(SecretRotation.LdapPassword), + parameters: z.object({ + dn: z + .string() + .trim() + .regex(DistinguishedNameRegex, "Invalid Distinguished Name format") + .min(1, "Distinguished Name (DN) required"), + passwordRequirements: PasswordRequirementsSchema.optional() + }), + secretsMapping: z.object({ + dn: z.string().trim().min(1, "Distinguished Name (DN) required"), + password: z.string().trim().min(1, "Password required") + }) + }) + .merge(BaseSecretRotationSchema); diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/shared/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/shared/index.ts index 44b4c194f..284b705e4 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/shared/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/shared/index.ts @@ -1 +1,2 @@ +export * from "./password-requirements-schema"; export * from "./sql-credentials-rotation-schema"; diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/shared/password-requirements-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/shared/password-requirements-schema.ts new file mode 100644 index 000000000..a02852ec8 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/shared/password-requirements-schema.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +export const PasswordRequirementsSchema = z + .object({ + length: z + .number() + .min(1, "Password length must be a positive number") + .max(250, "Password length must be less than 250"), + required: z.object({ + digits: z.number().min(0, "Digit count must be non-negative"), + lowercase: z.number().min(0, "Lowercase count must be non-negative"), + uppercase: z.number().min(0, "Uppercase count must be non-negative"), + symbols: z.number().min(0, "Symbol count must be non-negative") + }), + allowedSymbols: z + .string() + .regex(/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?~]/, "Invalid symbols") + .optional() + .transform((value) => value || "-_.~!*") + }) + .refine( + (data) => { + return Object.values(data.required).some((count) => count > 0); + }, + { + message: "At least one character type must be required", + path: ["required.digits"] + } + ) + .refine( + (data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, + { message: "Sum of required characters cannot exceed the total length", path: ["length"] } + ); + +export const DEFAULT_PASSWORD_REQUIREMENTS = { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TeamCitySyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TeamCitySyncFields.tsx index f9948fcaa..4f2718089 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TeamCitySyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TeamCitySyncFields.tsx @@ -111,7 +111,7 @@ export const TeamCitySyncFields = () => { onChange(selectedOption?.id ?? ""); }} options={buildTypes} - isClearable={true} + isClearable placeholder="Select a build configuration..." getOptionLabel={(option) => option.name} getOptionValue={(option) => option.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/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/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index fd8b6666d..02c0fbbcb 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -1,5 +1,12 @@ import { faGithub } from "@fortawesome/free-brands-svg-icons"; -import { faKey, faLock, faPassport, faServer, faUser } from "@fortawesome/free-solid-svg-icons"; +import { + faKey, + faLink, + faLock, + faPassport, + faServer, + faUser +} from "@fortawesome/free-solid-svg-icons"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { @@ -12,6 +19,7 @@ import { GcpConnectionMethod, GitHubConnectionMethod, HumanitecConnectionMethod, + LdapConnectionMethod, MsSqlConnectionMethod, PostgresConnectionMethod, TAppConnection, @@ -45,6 +53,7 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Camunda]: { name: "Camunda", image: "Camunda.png" }, [AppConnection.Windmill]: { name: "Windmill", image: "Windmill.png" }, [AppConnection.Auth0]: { name: "Auth0", image: "Auth0.png", size: 40 }, + [AppConnection.LDAP]: { name: "LDAP", image: "LDAP.png", size: 65 }, [AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" } }; @@ -78,6 +87,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "Access Token", icon: faKey }; case Auth0ConnectionMethod.ClientCredentials: return { name: "Client Credentials", icon: faServer }; + case LdapConnectionMethod.SimpleBind: + return { name: "Simple Bind", icon: faLink }; default: throw new Error(`Unhandled App Connection Method: ${method}`); } diff --git a/frontend/src/helpers/secretRotationsV2.ts b/frontend/src/helpers/secretRotationsV2.ts index e5ab1419d..8acfaafe5 100644 --- a/frontend/src/helpers/secretRotationsV2.ts +++ b/frontend/src/helpers/secretRotationsV2.ts @@ -20,6 +20,11 @@ export const SECRET_ROTATION_MAP: Record< image: "Auth0.png", size: 35 }, + [SecretRotation.LdapPassword]: { + name: "LDAP Password", + image: "LDAP.png", + size: 65 + }, [SecretRotation.AwsIamUserSecret]: { name: "AWS IAM User Secret", image: "Amazon Web Services.png", @@ -31,6 +36,7 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: true, [SecretRotation.MsSqlCredentials]: true, [SecretRotation.Auth0ClientSecret]: false, + [SecretRotation.LdapPassword]: false, [SecretRotation.AwsIamUserSecret]: true }; diff --git a/frontend/src/helpers/string.ts b/frontend/src/helpers/string.ts index 109b51d49..ddd9fb7c9 100644 --- a/frontend/src/helpers/string.ts +++ b/frontend/src/helpers/string.ts @@ -12,3 +12,6 @@ export const isValidPath = (val: string): boolean => { const validPathRegex = /^[a-zA-Z0-9-_.:]+(?:\/[a-zA-Z0-9-_.:]+)*$/; return validPathRegex.test(val); }; + +export const DistinguishedNameRegex = + /^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 31e2df71f..5b9d3fad4 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -13,5 +13,6 @@ export enum AppConnection { Camunda = "camunda", Windmill = "windmill", Auth0 = "auth0", + LDAP = "ldap", TeamCity = "teamcity" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 3122ca32c..10c1076cd 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -67,6 +67,10 @@ export type TAuth0ConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Auth0; }; +export type TLdapConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.LDAP; +}; + export type TTeamCityConnectionOption = TAppConnectionOptionBase & { app: AppConnection.TeamCity; }; @@ -103,5 +107,6 @@ export type TAppConnectionOptionMap = { [AppConnection.Camunda]: TCamundaConnectionOption; [AppConnection.Windmill]: TWindmillConnectionOption; [AppConnection.Auth0]: TAuth0ConnectionOption; + [AppConnection.LDAP]: TLdapConnectionOption; [AppConnection.TeamCity]: TTeamCityConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 929ca796d..2eabee1f2 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -9,6 +9,7 @@ import { TDatabricksConnection } from "./databricks-connection"; import { TGcpConnection } from "./gcp-connection"; import { TGitHubConnection } from "./github-connection"; import { THumanitecConnection } from "./humanitec-connection"; +import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TPostgresConnection } from "./postgres-connection"; import { TTeamCityConnection } from "./teamcity-connection"; @@ -25,6 +26,7 @@ export * from "./databricks-connection"; export * from "./gcp-connection"; export * from "./github-connection"; export * from "./humanitec-connection"; +export * from "./ldap-connection"; export * from "./mssql-connection"; export * from "./postgres-connection"; export * from "./teamcity-connection"; @@ -47,6 +49,7 @@ export type TAppConnection = | TCamundaConnection | TWindmillConnection | TAuth0Connection + | TLdapConnection | TTeamCityConnection; export type TAvailableAppConnection = Pick; @@ -89,5 +92,6 @@ export type TAppConnectionMap = { [AppConnection.Camunda]: TCamundaConnection; [AppConnection.Windmill]: TWindmillConnection; [AppConnection.Auth0]: TAuth0Connection; + [AppConnection.LDAP]: TLdapConnection; [AppConnection.TeamCity]: TTeamCityConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/ldap-connection.ts b/frontend/src/hooks/api/appConnections/types/ldap-connection.ts new file mode 100644 index 000000000..95165fc5d --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/ldap-connection.ts @@ -0,0 +1,21 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum LdapConnectionMethod { + SimpleBind = "simple-bind" +} + +export enum LdapConnectionProvider { + ActiveDirectory = "active-directory" +} + +export type TLdapConnection = TRootAppConnection & { app: AppConnection.LDAP } & { + method: LdapConnectionMethod.SimpleBind; + credentials: { + provider: LdapConnectionProvider; + url: string; + dn: string; + sslRejectUnauthorized?: boolean; + sslCertificate?: string; + }; +}; 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/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/secretRotationsV2/enums.ts b/frontend/src/hooks/api/secretRotationsV2/enums.ts index 1e387a3b9..4ddf4ee0c 100644 --- a/frontend/src/hooks/api/secretRotationsV2/enums.ts +++ b/frontend/src/hooks/api/secretRotationsV2/enums.ts @@ -2,6 +2,7 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", Auth0ClientSecret = "auth0-client-secret", + LdapPassword = "ldap-password", AwsIamUserSecret = "aws-iam-user-secret" } diff --git a/frontend/src/hooks/api/secretRotationsV2/types/index.ts b/frontend/src/hooks/api/secretRotationsV2/types/index.ts index 212175ae0..4119efc15 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/index.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/index.ts @@ -9,6 +9,11 @@ import { TAwsIamUserSecretRotationGeneratedCredentialsResponse, TAwsIamUserSecretRotationOption } from "@app/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation"; +import { + TLdapPasswordRotation, + TLdapPasswordRotationGeneratedCredentialsResponse, + TLdapPasswordRotationOption +} from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation"; import { TMsSqlCredentialsRotation, TMsSqlCredentialsRotationGeneratedCredentialsResponse @@ -25,6 +30,7 @@ export type TSecretRotationV2 = ( | TPostgresCredentialsRotation | TMsSqlCredentialsRotation | TAuth0ClientSecretRotation + | TLdapPasswordRotation | TAwsIamUserSecretRotation ) & { secrets: (SecretV3RawSanitized | null)[]; @@ -33,6 +39,7 @@ export type TSecretRotationV2 = ( export type TSecretRotationV2Option = | TSqlCredentialsRotationOption | TAuth0ClientSecretRotationOption + | TLdapPasswordRotationOption | TAwsIamUserSecretRotationOption; export type TListSecretRotationV2Options = { secretRotationOptions: TSecretRotationV2Option[] }; @@ -43,6 +50,7 @@ export type TViewSecretRotationGeneratedCredentialsResponse = | TPostgresCredentialsRotationGeneratedCredentialsResponse | TMsSqlCredentialsRotationGeneratedCredentialsResponse | TAuth0ClientSecretRotationGeneratedCredentialsResponse + | TLdapPasswordRotationGeneratedCredentialsResponse | TAwsIamUserSecretRotationGeneratedCredentialsResponse; export type TCreateSecretRotationV2DTO = DiscriminativePick< @@ -90,6 +98,7 @@ export type TSecretRotationOptionMap = { [SecretRotation.PostgresCredentials]: TSqlCredentialsRotationOption; [SecretRotation.MsSqlCredentials]: TSqlCredentialsRotationOption; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationOption; + [SecretRotation.LdapPassword]: TLdapPasswordRotationOption; [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationOption; }; @@ -97,5 +106,6 @@ export type TSecretRotationGeneratedCredentialsResponseMap = { [SecretRotation.PostgresCredentials]: TPostgresCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.MsSqlCredentials]: TMsSqlCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationGeneratedCredentialsResponse; + [SecretRotation.LdapPassword]: TLdapPasswordRotationGeneratedCredentialsResponse; [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationGeneratedCredentialsResponse; }; diff --git a/frontend/src/hooks/api/secretRotationsV2/types/ldap-password-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/ldap-password-rotation.ts new file mode 100644 index 000000000..b8d2ade2b --- /dev/null +++ b/frontend/src/hooks/api/secretRotationsV2/types/ldap-password-rotation.ts @@ -0,0 +1,37 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { + TSecretRotationV2Base, + TSecretRotationV2GeneratedCredentialsResponseBase +} from "@app/hooks/api/secretRotationsV2/types/shared"; + +export type TLdapPasswordRotation = TSecretRotationV2Base & { + type: SecretRotation.LdapPassword; + parameters: { + dn: string; + }; + secretsMapping: { + dn: string; + password: string; + }; +}; + +export type TLdapPasswordRotationGeneratedCredentials = { + dn: string; + password: string; +}; + +export type TLdapPasswordRotationGeneratedCredentialsResponse = + TSecretRotationV2GeneratedCredentialsResponseBase< + SecretRotation.LdapPassword, + TLdapPasswordRotationGeneratedCredentials + >; + +export type TLdapPasswordRotationOption = { + name: string; + type: SecretRotation.LdapPassword; + connection: AppConnection.LDAP; + template: { + secretsMapping: TLdapPasswordRotation["secretsMapping"]; + }; +}; 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/middlewares/authenticate.tsx b/frontend/src/pages/middlewares/authenticate.tsx index 2f5789096..03005ce05 100644 --- a/frontend/src/pages/middlewares/authenticate.tsx +++ b/frontend/src/pages/middlewares/authenticate.tsx @@ -1,13 +1,13 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { AxiosError } from "axios"; +import { addSeconds, formatISO } from "date-fns"; import { createNotification } from "@app/components/notifications"; +import { SessionStorageKeys } from "@app/const"; import { ROUTE_PATHS } from "@app/const/routes"; import { userKeys } from "@app/hooks/api"; import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries"; import { clearSession, fetchUserDetails, logoutUser } from "@app/hooks/api/users/queries"; -import { SessionStorageKeys } from "@app/const"; -import { addSeconds, formatISO } from "date-fns"; export const Route = createFileRoute("/_authenticate")({ beforeLoad: async ({ context, location }) => { diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 1259c50d6..f47de43dd 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -18,6 +18,7 @@ import { DatabricksConnectionForm } from "./DatabricksConnectionForm"; import { GcpConnectionForm } from "./GcpConnectionForm"; import { GitHubConnectionForm } from "./GitHubConnectionForm"; import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; +import { LdapConnectionForm } from "./LdapConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { PostgresConnectionForm } from "./PostgresConnectionForm"; import { TeamCityConnectionForm } from "./TeamCityConnectionForm"; @@ -90,6 +91,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Auth0: return ; + case AppConnection.LDAP: + return ; case AppConnection.TeamCity: return ; default: @@ -156,8 +159,11 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Auth0: return ; + case AppConnection.LDAP: + return ; case AppConnection.TeamCity: return ; + default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx new file mode 100644 index 000000000..7346f84af --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx @@ -0,0 +1,329 @@ +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Tab } from "@headlessui/react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem, + Switch, + TextArea, + Tooltip +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { DistinguishedNameRegex } from "@app/helpers/string"; +import { + LdapConnectionMethod, + LdapConnectionProvider, + TLdapConnection +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TLdapConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.LDAP) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(LdapConnectionMethod.SimpleBind), + credentials: z.object({ + provider: z.nativeEnum(LdapConnectionProvider), + url: z + .string() + .regex(/^ldaps?:\/\//, 'Must start with "ldaps://" or "ldap://"') + .url() + .trim() + .min(1, "LDAP URL required"), + dn: z + .string() + .trim() + .regex(DistinguishedNameRegex, "Invalid Distinguished Name format") + .min(1, "Distinguished Name (DN) required"), + password: z.string().trim().min(1, "Password required"), + sslRejectUnauthorized: z.boolean(), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() + }) + }) +]); + +type FormData = z.infer; + +export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.LDAP, + method: LdapConnectionMethod.SimpleBind, + credentials: { + provider: LdapConnectionProvider.ActiveDirectory, + url: "", + dn: "", + password: "", + sslRejectUnauthorized: true, + sslCertificate: undefined + } + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty }, + watch + } = form; + + const selectedProvider = watch("credentials.provider"); + const sslEnabled = watch("credentials.url")?.startsWith("ldaps://") ?? false; + + return ( + +
{ + setSelectedTabIndex(0); + handleSubmit(onSubmit)(e); + }} + > + {!isUpdate && } +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + + + `w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + > + Configuration + + + `w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + > + SSL ({sslEnabled ? "Enabled" : "Disabled"}) + + + {selectedTabIndex === 1 && ( +
Requires ldaps:// URL
+ )} + + + ( + + + + )} + /> +
+ ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+
+ + ( + +