diff --git a/backend/package-lock.json b/backend/package-lock.json index be6137424..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", @@ -91,10 +92,10 @@ "ora": "^7.0.1", "oracledb": "^6.4.0", "otplib": "^12.0.1", - "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", + "passport-oauth2": "^1.8.0", "pg": "^8.11.3", "pg-boss": "^10.1.5", "pg-query-stream": "^4.5.3", @@ -135,7 +136,6 @@ "@types/lodash.isequal": "^4.5.8", "@types/node": "^20.17.30", "@types/nodemailer": "^6.4.14", - "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.10.9", "@types/picomatch": "^2.3.3", @@ -7245,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", @@ -7380,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", @@ -7461,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" }, @@ -7491,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", @@ -7543,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", @@ -9871,17 +10123,6 @@ "@types/express": "*" } }, - "node_modules/@types/passport-github": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@types/passport-github/-/passport-github-1.1.12.tgz", - "integrity": "sha512-VJpMEIH+cOoXB694QgcxuvWy2wPd1Oq3gqrg2Y9DMVBYs9TmH9L14qnqPDZsNMZKBDH+SvqRsGZj9SgHYeDgcA==", - "dev": true, - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-oauth2": "*" - } - }, "node_modules/@types/passport-google-oauth20": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.14.tgz", @@ -11654,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", @@ -18142,9 +18385,10 @@ "integrity": "sha512-p1TRH/edngVEHVbwqWnxUViEmq5znDvyB+Sik5cmuLpGOIfDf/39zLiq3swPF8Vakqn+gvNiOQAZu8djYlQILA==" }, "node_modules/oauth": { - "version": "0.9.15", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", - "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", @@ -18827,17 +19071,6 @@ "url": "https://github.com/sponsors/jaredhanson" } }, - "node_modules/passport-github": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/passport-github/-/passport-github-1.1.0.tgz", - "integrity": "sha512-XARXJycE6fFh/dxF+Uut8OjlwbFEXgbPVj/+V+K7cvriRK7VcAOm+NgBmbiLM9Qv3SSxEAV+V6fIk89nYHXa8A==", - "dependencies": { - "passport-oauth2": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/passport-gitlab2": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/passport-gitlab2/-/passport-gitlab2-5.0.0.tgz", @@ -18873,12 +19106,13 @@ } }, "node_modules/passport-oauth2": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", - "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", "dependencies": { "base64url": "3.x.x", - "oauth": "0.9.x", + "oauth": "0.10.x", "passport-strategy": "1.x.x", "uid2": "0.0.x", "utils-merge": "1.x.x" @@ -19667,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", @@ -19683,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 b2c0d751a..5db1dffe0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -91,7 +91,6 @@ "@types/lodash.isequal": "^4.5.8", "@types/node": "^20.17.30", "@types/nodemailer": "^6.4.14", - "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.10.9", "@types/picomatch": "^2.3.3", @@ -150,6 +149,7 @@ "@infisical/quic": "^1.0.8", "@node-saml/passport-saml": "^5.0.1", "@octokit/auth-app": "^7.1.1", + "@octokit/plugin-paginate-graphql": "^5.2.4", "@octokit/plugin-retry": "^5.0.5", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", @@ -208,10 +208,10 @@ "ora": "^7.0.1", "oracledb": "^6.4.0", "otplib": "^12.0.1", - "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", + "passport-oauth2": "^1.8.0", "pg": "^8.11.3", "pg-boss": "^10.1.5", "pg-query-stream": "^4.5.3", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 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/20250425163216_ssh-nullable-ca-defaults.ts b/backend/src/db/migrations/20250425163216_ssh-nullable-ca-defaults.ts new file mode 100644 index 000000000..2a0b85e1c --- /dev/null +++ b/backend/src/db/migrations/20250425163216_ssh-nullable-ca-defaults.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { ProjectType, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasDefaultUserCaCol = await knex.schema.hasColumn(TableName.ProjectSshConfig, "defaultUserSshCaId"); + const hasDefaultHostCaCol = await knex.schema.hasColumn(TableName.ProjectSshConfig, "defaultHostSshCaId"); + + if (hasDefaultUserCaCol && hasDefaultHostCaCol) { + await knex.schema.alterTable(TableName.ProjectSshConfig, (t) => { + t.dropForeign(["defaultUserSshCaId"]); + t.dropForeign(["defaultHostSshCaId"]); + }); + await knex.schema.alterTable(TableName.ProjectSshConfig, (t) => { + // allow nullable (does not wipe existing values) + t.uuid("defaultUserSshCaId").nullable().alter(); + t.uuid("defaultHostSshCaId").nullable().alter(); + // re-add with SET NULL behavior (previously CASCADE) + t.foreign("defaultUserSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + t.foreign("defaultHostSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + }); + } + + // (dangtony98): backfill by adding null defaults CAs for all existing Infisical SSH projects + // that do not have an associated ProjectSshConfig record introduced in Infisical SSH V2. + + const allProjects = await knex(TableName.Project).where("type", ProjectType.SSH).select("id"); + + const projectsWithConfig = await knex(TableName.ProjectSshConfig).select("projectId"); + const projectIdsWithConfig = new Set(projectsWithConfig.map((config) => config.projectId)); + + const projectsNeedingConfig = allProjects.filter((project) => !projectIdsWithConfig.has(project.id)); + + if (projectsNeedingConfig.length > 0) { + const configsToInsert = projectsNeedingConfig.map((project) => ({ + projectId: project.id, + defaultUserSshCaId: null, + defaultHostSshCaId: null, + createdAt: new Date(), + updatedAt: new Date() + })); + + await knex.batchInsert(TableName.ProjectSshConfig, configsToInsert); + } +} + +export async function down(): Promise {} 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/aws-iam-user-secret-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/aws-iam-user-secret-rotation-router.ts new file mode 100644 index 000000000..489f3ea55 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/aws-iam-user-secret-rotation-router.ts @@ -0,0 +1,19 @@ +import { + AwsIamUserSecretRotationGeneratedCredentialsSchema, + AwsIamUserSecretRotationSchema, + CreateAwsIamUserSecretRotationSchema, + UpdateAwsIamUserSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/aws-iam-user-secret"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerAwsIamUserSecretRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.AwsIamUserSecret, + server, + responseSchema: AwsIamUserSecretRotationSchema, + createSchema: CreateAwsIamUserSecretRotationSchema, + updateSchema: UpdateAwsIamUserSecretRotationSchema, + generatedCredentialsSchema: AwsIamUserSecretRotationGeneratedCredentialsSchema + }); 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 d70b5bd52..90edc1306 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts @@ -1,7 +1,9 @@ import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; import { registerAuth0ClientSecretRotationRouter } from "./auth0-client-secret-rotation-router"; +import { registerAwsIamUserSecretRotationRouter } from "./aws-iam-user-secret-rotation-router"; import { registerAzureClientSecretRotationRouter } from "./azure-client-secret-rotation-router"; +import { registerLdapPasswordRotationRouter } from "./ldap-password-rotation-router"; import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router"; import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; @@ -14,5 +16,7 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< [SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter, [SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter, [SecretRotation.Auth0ClientSecret]: registerAuth0ClientSecretRotationRouter, - [SecretRotation.AzureClientSecret]: registerAzureClientSecretRotationRouter + [SecretRotation.AzureClientSecret]: registerAzureClientSecretRotationRouter, + [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter, + [SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter }; 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 8d28faa6e..298f2c412 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts @@ -2,7 +2,9 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { Auth0ClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret"; +import { AwsIamUserSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/aws-iam-user-secret"; import { AzureClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; +import { LdapPasswordRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; @@ -15,7 +17,9 @@ const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ PostgresCredentialsRotationListItemSchema, MsSqlCredentialsRotationListItemSchema, Auth0ClientSecretRotationListItemSchema, - AzureClientSecretRotationListItemSchema + AzureClientSecretRotationListItemSchema, + AwsIamUserSecretRotationListItemSchema, + LdapPasswordRotationListItemSchema ]); export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => { 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 a31200a1b..b6527f3f4 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -249,6 +249,8 @@ export enum EventType { DELETE_SLACK_INTEGRATION = "delete-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", + GET_PROJECT_SSH_CONFIG = "get-project-ssh-config", + UPDATE_PROJECT_SSH_CONFIG = "update-project-ssh-config", INTEGRATION_SYNCED = "integration-synced", CREATE_CMEK = "create-cmek", UPDATE_CMEK = "update-cmek", @@ -318,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[] = [ @@ -1492,6 +1496,7 @@ interface CreateSshHost { metadata: { sshHostId: string; hostname: string; + alias: string | null; userCertTtl: string; hostCertTtl: string; loginMappings: { @@ -1510,6 +1515,7 @@ interface UpdateSshHost { metadata: { sshHostId: string; hostname?: string; + alias?: string | null; userCertTtl?: string; hostCertTtl?: string; loginMappings?: { @@ -1992,6 +1998,25 @@ interface GetProjectSlackConfig { id: string; }; } + +interface GetProjectSshConfig { + type: EventType.GET_PROJECT_SSH_CONFIG; + metadata: { + id: string; + projectId: string; + }; +} + +interface UpdateProjectSshConfig { + type: EventType.UPDATE_PROJECT_SSH_CONFIG; + metadata: { + id: string; + projectId: string; + defaultUserSshCaId?: string | null; + defaultHostSshCaId?: string | null; + }; +} + interface IntegrationSyncedEvent { type: EventType.INTEGRATION_SYNCED; metadata: { @@ -2431,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: { @@ -2677,6 +2725,8 @@ export type Event = | GetSlackIntegration | UpdateProjectSlackConfig | GetProjectSlackConfig + | GetProjectSshConfig + | UpdateProjectSshConfig | IntegrationSyncedEvent | CreateCmekEvent | UpdateCmekEvent @@ -2734,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/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index adfe92341..bc60dff25 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -685,10 +685,16 @@ export const oidcConfigServiceFactory = ({ id_token_signed_response_alg: oidcCfg.jwtSignatureAlgorithm }); + // Check if the OIDC provider supports PKCE + const codeChallengeMethods = client.issuer.metadata.code_challenge_methods_supported; + const supportsPKCE = Array.isArray(codeChallengeMethods) && codeChallengeMethods.includes("S256"); + const strategy = new OpenIdStrategy( { client, - passReqToCallback: true + passReqToCallback: true, + usePKCE: supportsPKCE, + params: supportsPKCE ? { code_challenge_method: "S256" } : undefined }, // eslint-disable-next-line @typescript-eslint/no-explicit-any (_req: any, tokenSet: TokenSet, cb: any) => { diff --git a/backend/src/ee/services/oidc/oidc-config-types.ts b/backend/src/ee/services/oidc/oidc-config-types.ts index 3b2194375..c56427e63 100644 --- a/backend/src/ee/services/oidc/oidc-config-types.ts +++ b/backend/src/ee/services/oidc/oidc-config-types.ts @@ -8,7 +8,8 @@ export enum OIDCConfigurationType { export enum OIDCJWTSignatureAlgorithm { RS256 = "RS256", HS256 = "HS256", - RS512 = "RS512" + RS512 = "RS512", + EDDSA = "EdDSA" } export type TOidcLoginDTO = { diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 17b4e7f6c..7026899c7 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -74,6 +74,7 @@ export enum OrgPermissionSubjects { IncidentAccount = "incident-contact", Sso = "sso", Scim = "scim", + GithubOrgSync = "github-org-sync", Ldap = "ldap", Groups = "groups", Billing = "billing", @@ -101,6 +102,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Scim] + | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] @@ -165,6 +167,10 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ subject: z.literal(OrgPermissionSubjects.Scim).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") }), + z.object({ + subject: z.literal(OrgPermissionSubjects.GithubOrgSync).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), z.object({ subject: z.literal(OrgPermissionSubjects.Ldap).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") @@ -273,6 +279,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim); + can(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync); + can(OrgPermissionActions.Create, OrgPermissionSubjects.GithubOrgSync); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSync); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSync); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 0082c3d17..3d2f96f82 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -551,13 +551,26 @@ export const permissionServiceFactory = ({ }; const getProjectPermission = async ({ - actor, - actorId, + actor: inputActor, + actorId: inputActorId, projectId, actorAuthMethod, actorOrgId, actionProjectType }: TGetProjectPermissionArg): Promise> => { + let actor = inputActor; + let actorId = inputActorId; + const assumedPrivilegeDetailsCtx = requestContext.get("assumedPrivilegeDetails"); + if ( + assumedPrivilegeDetailsCtx && + actor === ActorType.USER && + actorId === assumedPrivilegeDetailsCtx.requesterId && + projectId === assumedPrivilegeDetailsCtx.projectId + ) { + actor = assumedPrivilegeDetailsCtx.actorType; + actorId = assumedPrivilegeDetailsCtx.actorId; + } + switch (actor) { case ActorType.USER: return getUserProjectPermission({ diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index b5cfadbeb..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 ); @@ -965,7 +969,6 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); - can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateAuthorities); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); @@ -1031,7 +1034,6 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); 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/aws-iam-user-secret/aws-iam-user-secret-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-constants.ts new file mode 100644 index 000000000..1b36b5f30 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-constants.ts @@ -0,0 +1,15 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "AWS IAM User Secret", + type: SecretRotation.AwsIamUserSecret, + connection: AppConnection.AWS, + template: { + secretsMapping: { + accessKeyId: "AWS_ACCESS_KEY_ID", + secretAccessKey: "AWS_SECRET_ACCESS_KEY" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-fns.ts new file mode 100644 index 000000000..c08eb162b --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-fns.ts @@ -0,0 +1,123 @@ +import AWS from "aws-sdk"; + +import { + TAwsIamUserSecretRotationGeneratedCredentials, + TAwsIamUserSecretRotationWithConnection +} from "@app/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-types"; +import { + TRotationFactory, + TRotationFactoryGetSecretsPayload, + TRotationFactoryIssueCredentials, + TRotationFactoryRevokeCredentials, + TRotationFactoryRotateCredentials +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws"; + +const getCreateDate = (key: AWS.IAM.AccessKeyMetadata): number => { + return key.CreateDate ? new Date(key.CreateDate).getTime() : 0; +}; + +export const awsIamUserSecretRotationFactory: TRotationFactory< + TAwsIamUserSecretRotationWithConnection, + TAwsIamUserSecretRotationGeneratedCredentials +> = (secretRotation) => { + const { + parameters: { region, userName }, + connection, + secretsMapping + } = secretRotation; + + const $rotateClientSecret = async () => { + const { credentials } = await getAwsConnectionConfig(connection, region); + const iam = new AWS.IAM({ credentials }); + + const { AccessKeyMetadata } = await iam.listAccessKeys({ UserName: userName }).promise(); + + if (AccessKeyMetadata && AccessKeyMetadata.length > 0) { + // Sort keys by creation date (oldest first) + const sortedKeys = [...AccessKeyMetadata].sort((a, b) => getCreateDate(a) - getCreateDate(b)); + + // If we already have 2 keys, delete the oldest one + if (sortedKeys.length >= 2) { + const accessId = sortedKeys[0].AccessKeyId || sortedKeys[1].AccessKeyId; + if (accessId) { + await iam + .deleteAccessKey({ + UserName: userName, + AccessKeyId: accessId + }) + .promise(); + } + } + } + + const { AccessKey } = await iam.createAccessKey({ UserName: userName }).promise(); + + return { + accessKeyId: AccessKey.AccessKeyId, + secretAccessKey: AccessKey.SecretAccessKey + }; + }; + + const issueCredentials: TRotationFactoryIssueCredentials = async ( + callback + ) => { + const credentials = await $rotateClientSecret(); + + return callback(credentials); + }; + + const revokeCredentials: TRotationFactoryRevokeCredentials = async ( + generatedCredentials, + callback + ) => { + const { credentials } = await getAwsConnectionConfig(connection, region); + const iam = new AWS.IAM({ credentials }); + + await Promise.all( + generatedCredentials.map((generatedCredential) => + iam + .deleteAccessKey({ + UserName: userName, + AccessKeyId: generatedCredential.accessKeyId + }) + .promise() + ) + ); + + return callback(); + }; + + const rotateCredentials: TRotationFactoryRotateCredentials = async ( + _, + callback + ) => { + const credentials = await $rotateClientSecret(); + + return callback(credentials); + }; + + const getSecretsPayload: TRotationFactoryGetSecretsPayload = ( + generatedCredentials + ) => { + const secrets = [ + { + key: secretsMapping.accessKeyId, + value: generatedCredentials.accessKeyId + }, + { + key: secretsMapping.secretAccessKey, + value: generatedCredentials.secretAccessKey + } + ]; + + return secrets; + }; + + return { + issueCredentials, + revokeCredentials, + rotateCredentials, + getSecretsPayload + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-schemas.ts new file mode 100644 index 000000000..dba4c6102 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-schemas.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { SecretRotations } from "@app/lib/api-docs"; +import { SecretNameSchema } from "@app/server/lib/schemas"; +import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; + +export const AwsIamUserSecretRotationGeneratedCredentialsSchema = z + .object({ + accessKeyId: z.string(), + secretAccessKey: z.string() + }) + .array() + .min(1) + .max(2); + +const AwsIamUserSecretRotationParametersSchema = z.object({ + userName: z + .string() + .trim() + .min(1, "Client Name Required") + .describe(SecretRotations.PARAMETERS.AWS_IAM_USER_SECRET.userName), + region: z.nativeEnum(AWSRegion).describe(SecretRotations.PARAMETERS.AWS_IAM_USER_SECRET.region).optional() +}); + +const AwsIamUserSecretRotationSecretsMappingSchema = z.object({ + accessKeyId: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.AWS_IAM_USER_SECRET.accessKeyId), + secretAccessKey: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.AWS_IAM_USER_SECRET.secretAccessKey) +}); + +export const AwsIamUserSecretRotationTemplateSchema = z.object({ + secretsMapping: z.object({ + accessKeyId: z.string(), + secretAccessKey: z.string() + }) +}); + +export const AwsIamUserSecretRotationSchema = BaseSecretRotationSchema(SecretRotation.AwsIamUserSecret).extend({ + type: z.literal(SecretRotation.AwsIamUserSecret), + parameters: AwsIamUserSecretRotationParametersSchema, + secretsMapping: AwsIamUserSecretRotationSecretsMappingSchema +}); + +export const CreateAwsIamUserSecretRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.AwsIamUserSecret +).extend({ + parameters: AwsIamUserSecretRotationParametersSchema, + secretsMapping: AwsIamUserSecretRotationSecretsMappingSchema +}); + +export const UpdateAwsIamUserSecretRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.AwsIamUserSecret +).extend({ + parameters: AwsIamUserSecretRotationParametersSchema.optional(), + secretsMapping: AwsIamUserSecretRotationSecretsMappingSchema.optional() +}); + +export const AwsIamUserSecretRotationListItemSchema = z.object({ + name: z.literal("AWS IAM User Secret"), + connection: z.literal(AppConnection.AWS), + type: z.literal(SecretRotation.AwsIamUserSecret), + template: AwsIamUserSecretRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-types.ts new file mode 100644 index 000000000..5db7ef2b0 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/aws-iam-user-secret-rotation-types.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { TAwsConnection } from "@app/services/app-connection/aws"; + +import { + AwsIamUserSecretRotationGeneratedCredentialsSchema, + AwsIamUserSecretRotationListItemSchema, + AwsIamUserSecretRotationSchema, + CreateAwsIamUserSecretRotationSchema +} from "./aws-iam-user-secret-rotation-schemas"; + +export type TAwsIamUserSecretRotation = z.infer; + +export type TAwsIamUserSecretRotationInput = z.infer; + +export type TAwsIamUserSecretRotationListItem = z.infer; + +export type TAwsIamUserSecretRotationWithConnection = TAwsIamUserSecretRotation & { + connection: TAwsConnection; +}; + +export type TAwsIamUserSecretRotationGeneratedCredentials = z.infer< + typeof AwsIamUserSecretRotationGeneratedCredentialsSchema +>; diff --git a/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/index.ts b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/index.ts new file mode 100644 index 000000000..69635c68c --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/aws-iam-user-secret/index.ts @@ -0,0 +1,3 @@ +export * from "./aws-iam-user-secret-rotation-constants"; +export * from "./aws-iam-user-secret-rotation-schemas"; +export * from "./aws-iam-user-secret-rotation-types"; 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 3a362f50b..d67abea2b 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts @@ -2,7 +2,9 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", Auth0ClientSecret = "auth0-client-secret", - AzureClientSecret = "azure-client-secret" + AzureClientSecret = "azure-client-secret", + AwsIamUserSecret = "aws-iam-user-secret", + LdapPassword = "ldap-password" } export enum SecretRotationStatus { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts index f403796db..5c0d97ee8 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 @@ -4,7 +4,9 @@ import { getConfig } from "@app/lib/config/env"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret"; +import { AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION } from "./aws-iam-user-secret"; import { AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./azure-client-secret"; +import { LDAP_PASSWORD_ROTATION_LIST_OPTION } from "./ldap-password"; import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums"; @@ -20,7 +22,9 @@ const SECRET_ROTATION_LIST_OPTIONS: Record { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts index 63eb5d6a3..f4ea75558 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts @@ -5,12 +5,16 @@ export const SECRET_ROTATION_NAME_MAP: Record = { [SecretRotation.PostgresCredentials]: "PostgreSQL Credentials", [SecretRotation.MsSqlCredentials]: "Microsoft SQL Server Credentials", [SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret", - [SecretRotation.AzureClientSecret]: "Azure Client Secret" + [SecretRotation.AzureClientSecret]: "Azure Client Secret", + [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret", + [SecretRotation.LdapPassword]: "LDAP Password" }; export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: AppConnection.Postgres, [SecretRotation.MsSqlCredentials]: AppConnection.MsSql, [SecretRotation.Auth0ClientSecret]: AppConnection.Auth0, - [SecretRotation.AzureClientSecret]: AppConnection.AzureClientSecrets + [SecretRotation.AzureClientSecret]: AppConnection.AzureClientSecrets, + [SecretRotation.AwsIamUserSecret]: AppConnection.AWS, + [SecretRotation.LdapPassword]: AppConnection.LDAP }; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index 26717765b..69743f133 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -15,6 +15,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { auth0ClientSecretRotationFactory } from "@app/ee/services/secret-rotation-v2/auth0-client-secret/auth0-client-secret-rotation-fns"; import { azureClientSecretRotationFactory } from "@app/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns"; +import { ldapPasswordRotationFactory } from "@app/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns"; import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; import { calculateNextRotationAt, @@ -78,6 +79,7 @@ import { import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; +import { awsIamUserSecretRotationFactory } from "./aws-iam-user-secret/aws-iam-user-secret-rotation-fns"; import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal"; export type TSecretRotationV2ServiceFactoryDep = { @@ -116,7 +118,9 @@ const SECRET_ROTATION_FACTORY_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 08865f57f..f6fdafe1d 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts @@ -2,12 +2,17 @@ import { z } from "zod"; import { Auth0ClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret"; import { AzureClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; +import { LdapPasswordRotationSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; +import { AwsIamUserSecretRotationSchema } from "./aws-iam-user-secret"; + export const SecretRotationV2Schema = z.discriminatedUnion("type", [ PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, Auth0ClientSecretRotationSchema, - AzureClientSecretRotationSchema + AzureClientSecretRotationSchema, + 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 e02df659d..b8ea6bf7e 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')", @@ -1857,6 +1861,20 @@ export const AppConnections = { WINDMILL: { 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." } } }; @@ -1996,6 +2014,10 @@ export const SecretSyncs = { WINDMILL: { workspace: "The Windmill workspace to sync secrets to.", path: "The Windmill workspace path to sync secrets to." + }, + TEAMCITY: { + project: "The TeamCity project to sync secrets to.", + buildConfig: "The TeamCity build configuration to sync secrets to." } } }; @@ -2064,6 +2086,26 @@ export const SecretRotations = { AZURE_CLIENT_SECRET: { appId: "The ID of the Azure Application to rotate the client secret for.", appName: "The name of the Azure Application to rotate the client secret for." + }, + 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." } }, SECRETS_MAPPING: { @@ -2078,6 +2120,14 @@ export const SecretRotations = { AZURE_CLIENT_SECRET: { clientId: "The name of the secret that the client ID will be mapped to.", clientSecret: "The name of the secret that the rotated client secret will be mapped to." + }, + LDAP_PASSWORD: { + dn: "The name of the secret that the Distinguished Name (DN) of the principal will be mapped to.", + password: "The name of the secret that the rotated password will be mapped to." + }, + 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/dates/index.ts b/backend/src/lib/dates/index.ts index 1b6e5dec0..369e289cd 100644 --- a/backend/src/lib/dates/index.ts +++ b/backend/src/lib/dates/index.ts @@ -2,7 +2,7 @@ export const daysToMillisecond = (days: number) => days * 24 * 60 * 60 * 1000; export const secondsToMillis = (seconds: number) => seconds * 1000; -export const applyJitter = (delayMs: number, jitterMs: number) => { - const jitter = Math.floor(Math.random() * (2 * jitterMs)) - jitterMs; - return delayMs + jitter; +export const applyJitter = (delay: number, jitter: number) => { + const jitterTime = Math.floor(Math.random() * (2 * jitter)) - jitter; + return delay + jitterTime; }; diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index d43d2af8e..55d4bf399 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -2,6 +2,8 @@ import { Knex } from "knex"; import { Tables } from "knex/types/tables"; +import { TableName } from "@app/db/schemas"; + import { DatabaseError } from "../errors"; import { buildDynamicKnexQuery, TKnexDynamicOperator } from "./dynamic"; @@ -25,28 +27,41 @@ export type TFindFilter = Partial & { $search?: Partial<{ [k in keyof R]: R[k] }>; $complex?: TKnexDynamicOperator; }; + export const buildFindFilter = - ({ $in, $notNull, $search, $complex, ...filter }: TFindFilter) => + ( + { $in, $notNull, $search, $complex, ...filter }: TFindFilter, + tableName?: TableName, + excludeKeys?: Array + ) => (bd: Knex.QueryBuilder) => { - void bd.where(filter); + const processedFilter = tableName + ? Object.fromEntries( + Object.entries(filter) + .filter(([key]) => !excludeKeys || !excludeKeys.includes(key as keyof R)) + .map(([key, value]) => [`${tableName}.${key}`, value]) + ) + : filter; + + void bd.where(processedFilter); if ($in) { Object.entries($in).forEach(([key, val]) => { if (val) { - void bd.whereIn(key as never, val as never); + void bd.whereIn([`${tableName ? `${tableName}.` : ""}${key}`] as never, val as never); } }); } if ($notNull?.length) { $notNull.forEach((key) => { - void bd.whereNotNull(key as never); + void bd.whereNotNull([`${tableName ? `${tableName}.` : ""}${key as string}`] as never); }); } if ($search) { Object.entries($search).forEach(([key, val]) => { if (val) { - void bd.whereILike(key as never, val as never); + void bd.whereILike([`${tableName ? `${tableName}.` : ""}${key}`] as never, val as never); } }); } diff --git a/backend/src/lib/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/lib/requests/github.ts b/backend/src/lib/requests/github.ts index 723e4957a..f25e46af5 100644 --- a/backend/src/lib/requests/github.ts +++ b/backend/src/lib/requests/github.ts @@ -16,3 +16,17 @@ export const fetchGithubEmails = async (accessToken: string) => { }); return data; }; + +type TGithubUser = { + name?: string; + login: string; +}; + +export const fetchGithubUser = async (accessToken: string) => { + const { data } = await request.get(`${INTEGRATION_GITHUB_API_URL}/user`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + return data; +}; diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts index b555869d7..8f195e0b5 100644 --- a/backend/src/lib/validator/validate-url.ts +++ b/backend/src/lib/validator/validate-url.ts @@ -15,13 +15,13 @@ export const blockLocalAndPrivateIpAddresses = async (url: string) => { const validUrl = new URL(url); const inputHostIps: string[] = []; - if (isIPv4(validUrl.host)) { - inputHostIps.push(validUrl.host); + if (isIPv4(validUrl.hostname)) { + inputHostIps.push(validUrl.hostname); } else { - if (validUrl.host === "localhost" || validUrl.host === "host.docker.internal") { + if (validUrl.hostname === "localhost" || validUrl.hostname === "host.docker.internal") { throw new BadRequestError({ message: "Local IPs not allowed as URL" }); } - const resolvedIps = await dns.resolve4(validUrl.host); + const resolvedIps = await dns.resolve4(validUrl.hostname); inputHostIps.push(...resolvedIps); } const isInternalIp = inputHostIps.some((el) => isPrivateIp(el)); diff --git a/backend/src/server/plugins/auth/inject-assume-privilege.ts b/backend/src/server/plugins/auth/inject-assume-privilege.ts new file mode 100644 index 000000000..58eb5c99c --- /dev/null +++ b/backend/src/server/plugins/auth/inject-assume-privilege.ts @@ -0,0 +1,24 @@ +import { requestContext } from "@fastify/request-context"; +import fp from "fastify-plugin"; + +import { AuthMode } from "@app/services/auth/auth-type"; + +export const injectAssumePrivilege = fp(async (server: FastifyZodProvider) => { + server.addHook("onRequest", async (req, res) => { + const assumeRoleCookie = req.cookies["infisical-project-assume-privileges"]; + try { + if (req?.auth?.authMode === AuthMode.JWT && assumeRoleCookie) { + const decodedToken = server.services.assumePrivileges.verifyAssumePrivilegeToken( + assumeRoleCookie, + req.auth.tokenVersionId + ); + if (decodedToken) { + requestContext.set("assumedPrivilegeDetails", decodedToken); + } + } + } catch (error) { + req.log.error({ error }, "Failed to verify assume privilege token"); + void res.clearCookie("infisical-project-assume-privileges"); + } + }); +}); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 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 11ef049d2..f6180b9b6 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 @@ -32,11 +32,16 @@ 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, SanitizedPostgresConnectionSchema } from "@app/services/app-connection/postgres"; +import { + SanitizedTeamCityConnectionSchema, + TeamCityConnectionListItemSchema +} from "@app/services/app-connection/teamcity"; import { SanitizedTerraformCloudConnectionSchema, TerraformCloudConnectionListItemSchema @@ -64,7 +69,9 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedCamundaConnectionSchema.options, ...SanitizedAuth0ConnectionSchema.options, ...SanitizedAzureClientSecretsConnectionSchema.options, - ...SanitizedWindmillConnectionSchema.options + ...SanitizedWindmillConnectionSchema.options, + ...SanitizedLdapConnectionSchema.options, + ...SanitizedTeamCityConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -82,7 +89,9 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ CamundaConnectionListItemSchema, Auth0ConnectionListItemSchema, AzureClientSecretsConnectionListItemSchema, - WindmillConnectionListItemSchema + WindmillConnectionListItemSchema, + LdapConnectionListItemSchema, + TeamCityConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts index 674e6e417..3226a6aa8 100644 --- a/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts @@ -59,4 +59,40 @@ export const registerAwsConnectionRouter = async (server: FastifyZodProvider) => return { kmsKeys }; } }); + + server.route({ + method: "GET", + url: `/:connectionId/users`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + iamUsers: z + .object({ + UserName: z.string(), + Arn: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const iamUsers = await server.services.appConnection.aws.listIamUsers( + { + connectionId + }, + req.permission + ); + + return { iamUsers }; + } + }); }; 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 179326cca..194f32290 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 { registerAzureClientSecretsConnectionRouter } from "./azure-client-secrets-connection-router"; @@ -10,8 +10,10 @@ 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"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; import { registerVercelConnectionRouter } from "./vercel-connection-router"; import { registerWindmillConnectionRouter } from "./windmill-connection-router"; @@ -34,5 +36,7 @@ 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/app-connection-routers/teamcity-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/teamcity-connection-router.ts new file mode 100644 index 000000000..c794c36ca --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/teamcity-connection-router.ts @@ -0,0 +1,60 @@ +import z from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateTeamCityConnectionSchema, + SanitizedTeamCityConnectionSchema, + UpdateTeamCityConnectionSchema +} from "@app/services/app-connection/teamcity"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerTeamCityConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.TeamCity, + server, + sanitizedResponseSchema: SanitizedTeamCityConnectionSchema, + createSchema: CreateTeamCityConnectionSchema, + updateSchema: UpdateTeamCityConnectionSchema + }); + + // The following endpoints are for internal Infisical App use only and not part of the public API + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + buildTypes: z.object({ + buildType: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + }) + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const projects = await server.services.appConnection.teamcity.listProjects(connectionId, req.permission); + + return projects; + } + }); +}; 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/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 19423901b..7df68f39a 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -1,3 +1,4 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { @@ -6,6 +7,7 @@ import { ProjectMembershipsSchema, ProjectRolesSchema, ProjectSlackConfigsSchema, + ProjectSshConfigsSchema, ProjectType, SecretFoldersSchema, SortDirection, @@ -78,7 +80,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { includeGroupMembers: z .enum(["true", "false"]) .default("false") - .transform((value) => value === "true") + .transform((value) => value === "true"), + roles: z + .string() + .trim() + .transform(decodeURIComponent) + .refine((value) => { + if (!value) return true; + const slugs = value.split(","); + return slugs.every((slug) => slugify(slug.trim(), { lowercase: true }) === slug.trim()); + }) + .optional() }), params: z.object({ workspaceId: z.string().trim() @@ -117,13 +129,15 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { + const roles = (req.query.roles?.split(",") || []).filter(Boolean); const users = await server.services.projectMembership.getProjectMemberships({ actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, includeGroupMembers: req.query.includeGroupMembers, projectId: req.params.workspaceId, - actorOrgId: req.permission.orgId + actorOrgId: req.permission.orgId, + roles }); return { users }; @@ -623,6 +637,107 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:workspaceId/ssh-config", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: ProjectSshConfigsSchema.pick({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, + defaultUserSshCaId: true, + defaultHostSshCaId: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshConfig = await server.services.project.getProjectSshConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshConfig.projectId, + event: { + type: EventType.GET_PROJECT_SSH_CONFIG, + metadata: { + id: sshConfig.id, + projectId: sshConfig.projectId + } + } + }); + + return sshConfig; + } + }); + + server.route({ + method: "PATCH", + url: "/:workspaceId/ssh-config", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + defaultUserSshCaId: z.string().optional(), + defaultHostSshCaId: z.string().optional() + }), + response: { + 200: ProjectSshConfigsSchema.pick({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, + defaultUserSshCaId: true, + defaultHostSshCaId: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshConfig = await server.services.project.updateProjectSshConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshConfig.projectId, + event: { + type: EventType.UPDATE_PROJECT_SSH_CONFIG, + metadata: { + id: sshConfig.id, + projectId: sshConfig.projectId, + defaultUserSshCaId: sshConfig.defaultUserSshCaId, + defaultHostSshCaId: sshConfig.defaultHostSshCaId + } + } + }); + + return sshConfig; + } + }); + server.route({ method: "GET", url: "/:workspaceId/slack-config", diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index ee407cee3..a54777727 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -9,6 +9,7 @@ import { registerDatabricksSyncRouter } from "./databricks-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerTeamCitySyncRouter } from "./teamcity-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; import { registerVercelSyncRouter } from "./vercel-sync-router"; import { registerWindmillSyncRouter } from "./windmill-sync-router"; @@ -27,5 +28,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/secret-sync-routers/teamcity-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/teamcity-sync-router.ts new file mode 100644 index 000000000..a3091aae5 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/teamcity-sync-router.ts @@ -0,0 +1,17 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + CreateTeamCitySyncSchema, + TeamCitySyncSchema, + UpdateTeamCitySyncSchema +} from "@app/services/secret-sync/teamcity"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerTeamCitySyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.TeamCity, + server, + responseSchema: TeamCitySyncSchema, + createSchema: CreateTeamCitySyncSchema, + updateSchema: UpdateTeamCitySyncSchema + }); diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index a222ab172..f7a1b973a 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -9,15 +9,17 @@ import { Authenticator } from "@fastify/passport"; import fastifySession from "@fastify/session"; import RedisStore from "connect-redis"; -import { Strategy as GitHubStrategy } from "passport-github"; import { Strategy as GitLabStrategy } from "passport-gitlab2"; import { Strategy as GoogleStrategy } from "passport-google-oauth20"; +import { Strategy as OAuth2Strategy } from "passport-oauth2"; import { z } from "zod"; +import { INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN } from "@app/lib/config/const"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; -import { fetchGithubEmails } from "@app/lib/requests/github"; +import { ms } from "@app/lib/ms"; +import { fetchGithubEmails, fetchGithubUser } from "@app/lib/requests/github"; import { authRateLimit } from "@app/server/config/rateLimiter"; import { AuthMethod } from "@app/services/auth/auth-type"; import { OrgAuthMethod } from "@app/services/org/org-types"; @@ -42,6 +44,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { }); await server.register(passport.initialize()); await server.register(passport.secureSession()); + // passport oauth strategy for Google const isGoogleOauthActive = Boolean(appCfg.CLIENT_ID_GOOGLE_LOGIN && appCfg.CLIENT_SECRET_GOOGLE_LOGIN); if (isGoogleOauthActive) { @@ -52,8 +55,9 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientID: appCfg.CLIENT_ID_GOOGLE_LOGIN as string, clientSecret: appCfg.CLIENT_SECRET_GOOGLE_LOGIN as string, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/google`, - scope: ["profile", " email"], - state: true + scope: ["profile", "email"], + state: true, + pkce: true }, // eslint-disable-next-line async (req, _accessToken, _refreshToken, profile, cb) => { @@ -89,34 +93,44 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { const isGithubOauthActive = Boolean(appCfg.CLIENT_SECRET_GITHUB_LOGIN && appCfg.CLIENT_ID_GITHUB_LOGIN); if (isGithubOauthActive) { passport.use( - new GitHubStrategy( + "github", + new OAuth2Strategy( { - passReqToCallback: true, - clientID: appCfg.CLIENT_ID_GITHUB_LOGIN as string, - clientSecret: appCfg.CLIENT_SECRET_GITHUB_LOGIN as string, + authorizationURL: "https://github.com/login/oauth/authorize", + tokenURL: "https://github.com/login/oauth/access_token", + clientID: appCfg.CLIENT_ID_GITHUB_LOGIN!, + clientSecret: appCfg.CLIENT_SECRET_GITHUB_LOGIN!, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/github`, - scope: ["user:email"], - // akhilmhdh: because the ts type for this is outdated by the maintainer - state: true as unknown as string + scope: ["user:email", "read:org"], + state: true, + pkce: true, + passReqToCallback: true }, // eslint-disable-next-line - async (req, accessToken, _refreshToken, profile, cb) => { - // @ts-expect-error this is because this is express type and not fastify - const callbackPort = req.session.get("callbackPort"); + async (req: any, accessToken: string, _refreshToken: string, _profile: any, done: Function) => { try { const ghEmails = await fetchGithubEmails(accessToken); const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; + + if (!email) throw new Error("No primary email found"); + + // profile does not get automatically populated so we need to manually fetch user info + const user = await fetchGithubUser(accessToken); + + const callbackPort = req.session.get("callbackPort"); + const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, - firstName: profile.displayName || profile.username || "", + firstName: user.name || user.login, lastName: "", authMethod: AuthMethod.GITHUB, callbackPort }); - return cb(null, { isUserCompleted, providerAuthToken }); - } catch (error) { - logger.error(error); - cb(error as Error, false); + + done(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken }); + } catch (err) { + logger.error(err); + done(err as Error, false); } } ) @@ -136,7 +150,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { clientSecret: appCfg.CLIENT_SECRET_GITLAB_LOGIN, callbackURL: `${appCfg.SITE_URL}/api/v1/sso/gitlab`, baseURL: appCfg.CLIENT_GITLAB_LOGIN_URL, - state: true + state: true, + pkce: true }, async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => { try { @@ -166,17 +181,24 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { method: "GET", schema: { querystring: z.object({ - callback_port: z.string().optional() + callback_port: z.string().optional(), + is_admin_login: z + .string() + .optional() + .transform((val) => val === "true") }) }, preValidation: [ async (req, res) => { - const { callback_port: callbackPort } = req.query; + const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query; // ensure fresh session state per login attempt await req.session.regenerate(); if (callbackPort) { req.session.set("callbackPort", callbackPort); } + if (isAdminLogin) { + req.session.set("isAdminLogin", isAdminLogin); + } return ( passport.authenticate("google", { scope: ["profile", "email"], @@ -200,10 +222,13 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { // this is due to zod type difference }) as never, handler: async (req, res) => { + const isAdminLogin = req.session.get("isAdminLogin"); await req.session.destroy(); if (req.passportUser.isUserCompleted) { return res.redirect( - `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}${ + isAdminLogin ? `&isAdminLogin=${isAdminLogin}` : "" + }` ); } return res.redirect( @@ -217,18 +242,26 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { method: "GET", schema: { querystring: z.object({ - callback_port: z.string().optional() + callback_port: z.string().optional(), + is_admin_login: z + .string() + .optional() + .transform((val) => val === "true") }) }, preValidation: [ async (req, res) => { - const { callback_port: callbackPort } = req.query; + const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query; // ensure fresh session state per login attempt await req.session.regenerate(); if (callbackPort) { req.session.set("callbackPort", callbackPort); } + if (isAdminLogin) { + req.session.set("isAdminLogin", isAdminLogin); + } + return ( passport.authenticate("github", { session: false, @@ -289,10 +322,24 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { // this is due to zod type difference }) as any, handler: async (req, res) => { + const isAdminLogin = req.session.get("isAdminLogin"); await req.session.destroy(); + + if (req.passportUser.externalProviderAccessToken) { + void res.cookie(INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN, req.passportUser.externalProviderAccessToken, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED, + expires: new Date(Date.now() + ms(appCfg.JWT_PROVIDER_AUTH_LIFETIME)) + }); + } + if (req.passportUser.isUserCompleted) { return res.redirect( - `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}${ + isAdminLogin ? `&isAdminLogin=${isAdminLogin}` : "" + }` ); } return res.redirect( @@ -306,18 +353,26 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { method: "GET", schema: { querystring: z.object({ - callback_port: z.string().optional() + callback_port: z.string().optional(), + is_admin_login: z + .string() + .optional() + .transform((val) => val === "true") }) }, preValidation: [ async (req, res) => { - const { callback_port: callbackPort } = req.query; + const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query; // ensure fresh session state per login attempt await req.session.regenerate(); if (callbackPort) { req.session.set("callbackPort", callbackPort); } + if (isAdminLogin) { + req.session.set("isAdminLogin", isAdminLogin); + } + return ( passport.authenticate("gitlab", { session: false, @@ -342,10 +397,13 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as any, handler: async (req, res) => { + const isAdminLogin = req.session.get("isAdminLogin"); await req.session.destroy(); if (req.passportUser.isUserCompleted) { return res.redirect( - `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}` + `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}${ + isAdminLogin ? `&isAdminLogin=${isAdminLogin}` : "" + }` ); } return res.redirect( diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 851d9c4ff..027f527fc 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -252,6 +252,31 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "DELETE", + url: "/me/sessions/:sessionId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + sessionId: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.authToken.revokeMySessionById(req.permission.id, req.params.sessionId); + return { + message: "Successfully revoked session" + }; + } + }); + server.route({ method: "GET", url: "/me", 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 17b545a6a..82723a138 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -13,7 +13,9 @@ export enum AppConnection { MsSql = "mssql", Camunda = "camunda", Windmill = "windmill", - Auth0 = "auth0" + Auth0 = "auth0", + LDAP = "ldap", + TeamCity = "teamcity" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 622c8a2bc..3b020fb06 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -46,8 +46,14 @@ import { HumanitecConnectionMethod, validateHumanitecConnectionCredentials } from "./humanitec"; +import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnectionCredentials } from "./ldap"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; +import { + getTeamCityConnectionListItem, + TeamCityConnectionMethod, + validateTeamCityConnectionCredentials +} from "./teamcity"; import { getTerraformCloudConnectionListItem, TerraformCloudConnectionMethod, @@ -77,7 +83,9 @@ export const listAppConnectionOptions = () => { getCamundaConnectionListItem(), getAzureClientSecretsConnectionListItem(), getWindmillConnectionListItem(), - getAuth0ConnectionListItem() + getAuth0ConnectionListItem(), + getLdapConnectionListItem(), + getTeamCityConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -143,7 +151,9 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Vercel]: validateVercelConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Auth0]: validateAuth0ConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.LDAP]: validateLdapConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.TeamCity]: validateTeamCityConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -176,9 +186,12 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case MsSqlConnectionMethod.UsernameAndPassword: return "Username & Password"; case WindmillConnectionMethod.AccessToken: + case TeamCityConnectionMethod.AccessToken: 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}`); @@ -224,5 +237,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Vercel]: platformManagedCredentialsNotSupported, [AppConnection.AzureClientSecrets]: platformManagedCredentialsNotSupported, [AppConnection.Windmill]: platformManagedCredentialsNotSupported, - [AppConnection.Auth0]: 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 030410600..594c4c734 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -15,5 +15,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.MsSql]: "Microsoft SQL Server", [AppConnection.Camunda]: "Camunda", [AppConnection.Windmill]: "Windmill", - [AppConnection.Auth0]: "Auth0" + [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 a38f02768..9669de0c8 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -45,8 +45,11 @@ 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"; +import { teamcityConnectionService } from "./teamcity/teamcity-connection-service"; import { ValidateTerraformCloudConnectionCredentialsSchema } from "./terraform-cloud"; import { terraformCloudConnectionService } from "./terraform-cloud/terraform-cloud-connection-service"; import { ValidateVercelConnectionCredentialsSchema } from "./vercel"; @@ -77,7 +80,9 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record>>; @@ -126,6 +140,8 @@ export type TAppConnectionInput = { id: string } & ( | TAzureClientSecretsConnectionInput | TWindmillConnectionInput | TAuth0ConnectionInput + | TLdapConnectionInput + | TTeamCityConnectionInput ); export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput; @@ -153,7 +169,9 @@ export type TAppConnectionConfig = | TCamundaConnectionConfig | TVercelConnectionConfig | TWindmillConnectionConfig - | TAuth0ConnectionConfig; + | TAuth0ConnectionConfig + | TLdapConnectionConfig + | TTeamCityConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -170,7 +188,9 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateVercelConnectionCredentialsSchema | TValidateTerraformCloudConnectionCredentialsSchema | TValidateWindmillConnectionCredentialsSchema - | TValidateAuth0ConnectionCredentialsSchema; + | TValidateAuth0ConnectionCredentialsSchema + | TValidateLdapConnectionCredentialsSchema + | TValidateTeamCityConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; @@ -178,6 +198,10 @@ export type TListAwsConnectionKmsKeys = { destination: SecretSync.AWSParameterStore | SecretSync.AWSSecretsManager; }; +export type TListAwsConnectionIamUsers = { + connectionId: string; +}; + export type TAppConnectionCredentialsValidator = ( appConnection: TAppConnectionConfig ) => Promise; diff --git a/backend/src/services/app-connection/aws/aws-connection-fns.ts b/backend/src/services/app-connection/aws/aws-connection-fns.ts index 767cb82fb..28660173b 100644 --- a/backend/src/services/app-connection/aws/aws-connection-fns.ts +++ b/backend/src/services/app-connection/aws/aws-connection-fns.ts @@ -1,9 +1,11 @@ import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; import AWS from "aws-sdk"; +import { AxiosError } from "axios"; import { randomUUID } from "crypto"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { AwsConnectionMethod } from "./aws-connection-enums"; @@ -90,9 +92,20 @@ export const validateAwsConnectionCredentials = async (appConnection: TAwsConnec const sts = new AWS.STS(awsConfig); resp = await sts.getCallerIdentity().promise(); - } catch (e: unknown) { + } catch (error: unknown) { + logger.error(error, "Error validating AWS connection credentials"); + + let message: string; + + if (error instanceof AxiosError) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message = (error.response?.data?.message as string) || error.message || "verify credentials"; + } else { + message = (error as Error)?.message || "verify credentials"; + } + throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: `Unable to validate connection: ${message}` }); } diff --git a/backend/src/services/app-connection/aws/aws-connection-service.ts b/backend/src/services/app-connection/aws/aws-connection-service.ts index 689608b81..369116a9c 100644 --- a/backend/src/services/app-connection/aws/aws-connection-service.ts +++ b/backend/src/services/app-connection/aws/aws-connection-service.ts @@ -2,7 +2,10 @@ import AWS from "aws-sdk"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; -import { TListAwsConnectionKmsKeys } from "@app/services/app-connection/app-connection-types"; +import { + TListAwsConnectionIamUsers, + TListAwsConnectionKmsKeys +} from "@app/services/app-connection/app-connection-types"; import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; @@ -70,6 +73,23 @@ const listAwsKmsKeys = async ( return kmsKeys; }; +const listAwsIamUsers = async (appConnection: TAwsConnection) => { + const { credentials } = await getAwsConnectionConfig(appConnection); + + const iam = new AWS.IAM({ credentials }); + + const userEntries: AWS.IAM.User[] = []; + let userMarker: string | undefined; + do { + // eslint-disable-next-line no-await-in-loop + const response = await iam.listUsers({ MaxItems: 100, Marker: userMarker }).promise(); + userEntries.push(...(response.Users || [])); + userMarker = response.Marker; + } while (userMarker); + + return userEntries; +}; + export const awsConnectionService = (getAppConnection: TGetAppConnectionFunc) => { const listKmsKeys = async ( { connectionId, region, destination }: TListAwsConnectionKmsKeys, @@ -82,7 +102,16 @@ export const awsConnectionService = (getAppConnection: TGetAppConnectionFunc) => return kmsKeys; }; + const listIamUsers = async ({ connectionId }: TListAwsConnectionIamUsers, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.AWS, connectionId, actor); + + const iamUsers = await listAwsIamUsers(appConnection); + + return iamUsers; + }; + return { - listKmsKeys + listKmsKeys, + listIamUsers }; }; diff --git a/backend/src/services/app-connection/ldap/index.ts b/backend/src/services/app-connection/ldap/index.ts new file mode 100644 index 000000000..639879a08 --- /dev/null +++ b/backend/src/services/app-connection/ldap/index.ts @@ -0,0 +1,4 @@ +export * from "./ldap-connection-enums"; +export * from "./ldap-connection-fns"; +export * from "./ldap-connection-schemas"; +export * from "./ldap-connection-types"; diff --git a/backend/src/services/app-connection/ldap/ldap-connection-enums.ts b/backend/src/services/app-connection/ldap/ldap-connection-enums.ts new file mode 100644 index 000000000..9d6a3d5cc --- /dev/null +++ b/backend/src/services/app-connection/ldap/ldap-connection-enums.ts @@ -0,0 +1,7 @@ +export enum LdapConnectionMethod { + SimpleBind = "simple-bind" +} + +export enum LdapProvider { + ActiveDirectory = "active-directory" +} diff --git a/backend/src/services/app-connection/ldap/ldap-connection-fns.ts b/backend/src/services/app-connection/ldap/ldap-connection-fns.ts new file mode 100644 index 000000000..03005c7d7 --- /dev/null +++ b/backend/src/services/app-connection/ldap/ldap-connection-fns.ts @@ -0,0 +1,102 @@ +import ldap from "ldapjs"; + +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { LdapConnectionMethod } from "./ldap-connection-enums"; +import { TLdapConnectionConfig } from "./ldap-connection-types"; + +export const getLdapConnectionListItem = () => { + 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/app-connection/teamcity/index.ts b/backend/src/services/app-connection/teamcity/index.ts new file mode 100644 index 000000000..89433f440 --- /dev/null +++ b/backend/src/services/app-connection/teamcity/index.ts @@ -0,0 +1,4 @@ +export * from "./teamcity-connection-enums"; +export * from "./teamcity-connection-fns"; +export * from "./teamcity-connection-schemas"; +export * from "./teamcity-connection-types"; diff --git a/backend/src/services/app-connection/teamcity/teamcity-connection-enums.ts b/backend/src/services/app-connection/teamcity/teamcity-connection-enums.ts new file mode 100644 index 000000000..7e2f93cb1 --- /dev/null +++ b/backend/src/services/app-connection/teamcity/teamcity-connection-enums.ts @@ -0,0 +1,3 @@ +export enum TeamCityConnectionMethod { + AccessToken = "access-token" +} diff --git a/backend/src/services/app-connection/teamcity/teamcity-connection-fns.ts b/backend/src/services/app-connection/teamcity/teamcity-connection-fns.ts new file mode 100644 index 000000000..c87eb06d2 --- /dev/null +++ b/backend/src/services/app-connection/teamcity/teamcity-connection-fns.ts @@ -0,0 +1,74 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { TeamCityConnectionMethod } from "./teamcity-connection-enums"; +import { + TTeamCityConnection, + TTeamCityConnectionConfig, + TTeamCityListProjectsResponse +} from "./teamcity-connection-types"; + +export const getTeamCityInstanceUrl = async (config: TTeamCityConnectionConfig) => { + const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); + + await blockLocalAndPrivateIpAddresses(instanceUrl); + + return instanceUrl; +}; + +export const getTeamCityConnectionListItem = () => { + return { + name: "TeamCity" as const, + app: AppConnection.TeamCity as const, + methods: Object.values(TeamCityConnectionMethod) as [TeamCityConnectionMethod.AccessToken] + }; +}; + +export const validateTeamCityConnectionCredentials = async (config: TTeamCityConnectionConfig) => { + const instanceUrl = await getTeamCityInstanceUrl(config); + + const { accessToken } = config.credentials; + + try { + await request.get(`${instanceUrl}/app/rest/server`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + + return config.credentials; +}; + +export const listTeamCityProjects = async (appConnection: TTeamCityConnection) => { + const instanceUrl = await getTeamCityInstanceUrl(appConnection); + const { accessToken } = appConnection.credentials; + + const resp = await request.get( + `${instanceUrl}/app/rest/projects?fields=project(id,name,buildTypes(buildType(id,name)))`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + + // Filter out the root project. Should not be seen by users. + return resp.data.project.filter((proj) => proj.id !== "_Root"); +}; diff --git a/backend/src/services/app-connection/teamcity/teamcity-connection-schemas.ts b/backend/src/services/app-connection/teamcity/teamcity-connection-schemas.ts new file mode 100644 index 000000000..30494e2ba --- /dev/null +++ b/backend/src/services/app-connection/teamcity/teamcity-connection-schemas.ts @@ -0,0 +1,70 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { TeamCityConnectionMethod } from "./teamcity-connection-enums"; + +export const TeamCityConnectionAccessTokenCredentialsSchema = z.object({ + accessToken: z + .string() + .trim() + .min(1, "Access Token required") + .describe(AppConnections.CREDENTIALS.TEAMCITY.accessToken), + instanceUrl: z + .string() + .trim() + .url("Invalid Instance URL") + .min(1, "Instance URL required") + .describe(AppConnections.CREDENTIALS.TEAMCITY.instanceUrl) +}); + +const BaseTeamCityConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.TeamCity) }); + +export const TeamCityConnectionSchema = BaseTeamCityConnectionSchema.extend({ + method: z.literal(TeamCityConnectionMethod.AccessToken), + credentials: TeamCityConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedTeamCityConnectionSchema = z.discriminatedUnion("method", [ + BaseTeamCityConnectionSchema.extend({ + method: z.literal(TeamCityConnectionMethod.AccessToken), + credentials: TeamCityConnectionAccessTokenCredentialsSchema.pick({ + instanceUrl: true + }) + }) +]); + +export const ValidateTeamCityConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(TeamCityConnectionMethod.AccessToken) + .describe(AppConnections.CREATE(AppConnection.TeamCity).method), + credentials: TeamCityConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.TeamCity).credentials + ) + }) +]); + +export const CreateTeamCityConnectionSchema = ValidateTeamCityConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.TeamCity) +); + +export const UpdateTeamCityConnectionSchema = z + .object({ + credentials: TeamCityConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.TeamCity).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.TeamCity)); + +export const TeamCityConnectionListItemSchema = z.object({ + name: z.literal("TeamCity"), + app: z.literal(AppConnection.TeamCity), + methods: z.nativeEnum(TeamCityConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/teamcity/teamcity-connection-service.ts b/backend/src/services/app-connection/teamcity/teamcity-connection-service.ts new file mode 100644 index 000000000..afad7f572 --- /dev/null +++ b/backend/src/services/app-connection/teamcity/teamcity-connection-service.ts @@ -0,0 +1,28 @@ +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listTeamCityProjects } from "./teamcity-connection-fns"; +import { TTeamCityConnection } from "./teamcity-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const teamcityConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.TeamCity, connectionId, actor); + + try { + const projects = await listTeamCityProjects(appConnection); + return projects; + } catch (error) { + return []; + } + }; + + return { + listProjects + }; +}; diff --git a/backend/src/services/app-connection/teamcity/teamcity-connection-types.ts b/backend/src/services/app-connection/teamcity/teamcity-connection-types.ts new file mode 100644 index 000000000..737e7c70d --- /dev/null +++ b/backend/src/services/app-connection/teamcity/teamcity-connection-types.ts @@ -0,0 +1,43 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateTeamCityConnectionSchema, + TeamCityConnectionSchema, + ValidateTeamCityConnectionCredentialsSchema +} from "./teamcity-connection-schemas"; + +export type TTeamCityConnection = z.infer; + +export type TTeamCityConnectionInput = z.infer & { + app: AppConnection.TeamCity; +}; + +export type TValidateTeamCityConnectionCredentialsSchema = typeof ValidateTeamCityConnectionCredentialsSchema; + +export type TTeamCityConnectionConfig = DiscriminativePick< + TTeamCityConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TTeamCityProject = { + id: string; + name: string; +}; + +export type TTeamCityProjectWithBuildTypes = TTeamCityProject & { + buildTypes: { + buildType: { + id: string; + name: string; + }[]; + }; +}; + +export type TTeamCityListProjectsResponse = { + project: TTeamCityProjectWithBuildTypes[]; +}; diff --git a/backend/src/services/auth-token/auth-token-dal.ts b/backend/src/services/auth-token/auth-token-dal.ts index 221b691cf..ca7841d8e 100644 --- a/backend/src/services/auth-token/auth-token-dal.ts +++ b/backend/src/services/auth-token/auth-token-dal.ts @@ -47,7 +47,10 @@ export const tokenDALFactory = (db: TDbClient) => { const findTokenSessions = async (filter: Partial, tx?: Knex) => { try { - const sessions = await (tx || db.replicaNode())(TableName.AuthTokenSession).where(filter); + const sessions = await (tx || db.replicaNode())(TableName.AuthTokenSession) + .where(filter) + .orderBy("lastUsed", "desc"); + return sessions; } catch (error) { throw new DatabaseError({ name: "Find all token session", error }); diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 2468e3c8a..f26464340 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -151,6 +151,9 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu const revokeAllMySessions = async (userId: string) => tokenDAL.deleteTokenSession({ userId }); + const revokeMySessionById = async (userId: string, sessionId: string) => + tokenDAL.deleteTokenSession({ userId, id: sessionId }); + const validateRefreshToken = async (refreshToken?: string) => { const appCfg = getConfig(); if (!refreshToken) @@ -223,6 +226,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu clearTokenSessionById, getTokenSessionByUser, revokeAllMySessions, + revokeMySessionById, validateRefreshToken, fnValidateJwtIdentity, getUserTokenSessionById diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index e576d6768..0f8ba5176 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -12,6 +12,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -39,7 +40,6 @@ import { AuthTokenType, MfaMethod } from "./auth-type"; -import { removeTrailingSlash } from "@app/lib/fn"; type TAuthLoginServiceFactoryDep = { userDAL: TUserDALFactory; @@ -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-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 61b703e70..1e71f4605 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -13,7 +13,7 @@ export const projectMembershipDALFactory = (db: TDbClient) => { // special query const findAllProjectMembers = async ( projectId: string, - filter: { usernames?: string[]; username?: string; id?: string } = {} + filter: { usernames?: string[]; username?: string; id?: string; roles?: string[] } = {} ) => { try { const docs = await db @@ -31,6 +31,29 @@ export const projectMembershipDALFactory = (db: TDbClient) => { if (filter.id) { void qb.where(`${TableName.ProjectMembership}.id`, filter.id); } + if (filter.roles && filter.roles.length > 0) { + void qb.whereExists((subQuery) => { + void subQuery + .select("role") + .from(TableName.ProjectUserMembershipRole) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectRoles}.id`, + `${TableName.ProjectUserMembershipRole}.customRoleId` + ) + .whereRaw("??.?? = ??.??", [ + TableName.ProjectUserMembershipRole, + "projectMembershipId", + TableName.ProjectMembership, + "id" + ]) + .where((subQb) => { + void subQb + .whereIn(`${TableName.ProjectUserMembershipRole}.role`, filter.roles as string[]) + .orWhereIn(`${TableName.ProjectRoles}.slug`, filter.roles as string[]); + }); + }); + } }) .join( TableName.UserEncryptionKey, diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 1d6ba969a..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 ({ @@ -79,7 +82,8 @@ export const projectMembershipServiceFactory = ({ actorOrgId, actorAuthMethod, includeGroupMembers, - projectId + projectId, + roles }: TGetProjectMembershipDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -91,7 +95,7 @@ export const projectMembershipServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); - const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles }); // projectMembers[0].project if (includeGroupMembers) { @@ -388,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; @@ -465,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( { @@ -525,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-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 68819f5ae..9b8cf2ac3 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,6 +1,6 @@ import { TProjectPermission } from "@app/lib/types"; -export type TGetProjectMembershipDTO = { includeGroupMembers?: boolean } & TProjectPermission; +export type TGetProjectMembershipDTO = { includeGroupMembers?: boolean; roles?: string[] } & TProjectPermission; export type TLeaveProjectDTO = Omit; export enum ProjectUserMembershipTemporaryMode { Relative = "relative" 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/project/project-service.ts b/backend/src/services/project/project-service.ts index 9b7c29c1b..9f2de8a85 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -73,6 +73,7 @@ import { TGetProjectDTO, TGetProjectKmsKey, TGetProjectSlackConfig, + TGetProjectSshConfig, TListProjectAlertsDTO, TListProjectCasDTO, TListProjectCertificateTemplatesDTO, @@ -92,6 +93,7 @@ import { TUpdateProjectKmsDTO, TUpdateProjectNameDTO, TUpdateProjectSlackConfig, + TUpdateProjectSshConfig, TUpdateProjectVersionLimitDTO, TUpgradeProjectDTO } from "./project-types"; @@ -104,7 +106,7 @@ export const DEFAULT_PROJECT_ENVS = [ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; - projectSshConfigDAL: Pick; + projectSshConfigDAL: Pick; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; projectBotService: Pick; @@ -129,7 +131,7 @@ type TProjectServiceFactoryDep = { certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; - sshCertificateAuthorityDAL: Pick; + sshCertificateAuthorityDAL: Pick; sshCertificateAuthoritySecretDAL: Pick; sshCertificateDAL: Pick; sshCertificateTemplateDAL: Pick; @@ -1327,6 +1329,129 @@ export const projectServiceFactory = ({ return { secretManagerKmsKey: kmsKey }; }; + const getProjectSshConfig = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TGetProjectSshConfig) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: `Project with ID '${projectId}' not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + + const projectSshConfig = await projectSshConfigDAL.findOne({ + projectId: project.id + }); + + if (!projectSshConfig) { + throw new NotFoundError({ + message: `Project SSH config with ID '${project.id}' not found` + }); + } + + return projectSshConfig; + }; + + const updateProjectSshConfig = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + defaultUserSshCaId, + defaultHostSshCaId + }: TUpdateProjectSshConfig) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: `Project with ID '${projectId}' not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + let projectSshConfig = await projectSshConfigDAL.findOne({ + projectId: project.id + }); + + if (!projectSshConfig) { + throw new NotFoundError({ + message: `Project SSH config with ID '${project.id}' not found` + }); + } + + projectSshConfig = await projectSshConfigDAL.transaction(async (tx) => { + if (defaultUserSshCaId) { + const userSshCa = await sshCertificateAuthorityDAL.findOne( + { + id: defaultUserSshCaId, + projectId: project.id + }, + tx + ); + + if (!userSshCa) { + throw new NotFoundError({ + message: "User SSH CA must exist and belong to this project" + }); + } + } + + if (defaultHostSshCaId) { + const hostSshCa = await sshCertificateAuthorityDAL.findOne( + { + id: defaultHostSshCaId, + projectId: project.id + }, + tx + ); + + if (!hostSshCa) { + throw new NotFoundError({ + message: "Host SSH CA must exist and belong to this project" + }); + } + } + + const updatedProjectSshConfig = await projectSshConfigDAL.updateById( + projectSshConfig.id, + { + defaultUserSshCaId, + defaultHostSshCaId + }, + tx + ); + + return updatedProjectSshConfig; + }); + + return projectSshConfig; + }; + const getProjectSlackConfig = async ({ actorId, actor, @@ -1548,6 +1673,8 @@ export const projectServiceFactory = ({ getProjectKmsBackup, loadProjectKmsBackup, getProjectKmsKeys, + getProjectSshConfig, + updateProjectSshConfig, getProjectSlackConfig, updateProjectSlackConfig, requestProjectAccess, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 444f6309c..274189668 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -159,6 +159,13 @@ export type TListProjectSshCertificatesDTO = { limit: number; } & TProjectPermission; +export type TUpdateProjectSshConfig = { + defaultUserSshCaId?: string; + defaultHostSshCaId?: string; +} & TProjectPermission; + +export type TGetProjectSshConfig = TProjectPermission; + export type TGetProjectSlackConfig = TProjectPermission; export type TUpdateProjectSlackConfig = { diff --git a/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts new file mode 100644 index 000000000..ec4a3f807 --- /dev/null +++ b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts @@ -0,0 +1,36 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TSecretReminderRecipientsDALFactory = ReturnType; + +export const secretReminderRecipientsDALFactory = (db: TDbClient) => { + const secretReminderRecipientsOrm = ormify(db, TableName.SecretReminderRecipients); + + const findUsersBySecretId = async (secretId: string, tx?: Knex) => { + const res = await (tx || db.replicaNode())(TableName.SecretReminderRecipients) + .where({ secretId }) + .leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Project, `${TableName.SecretReminderRecipients}.projectId`, `${TableName.Project}.id`) + .leftJoin(TableName.OrgMembership, (bd) => { + void bd + .on(`${TableName.OrgMembership}.userId`, "=", `${TableName.SecretReminderRecipients}.userId`) + .andOn(`${TableName.OrgMembership}.orgId`, "=", `${TableName.Project}.orgId`); + }) + + .where(`${TableName.OrgMembership}.isActive`, true) + .select(selectAllTableCols(TableName.SecretReminderRecipients)) + .select( + db.ref("email").withSchema(TableName.Users).as("email"), + db.ref("username").withSchema(TableName.Users).as("username"), + db.ref("firstName").withSchema(TableName.Users).as("firstName"), + db.ref("lastName").withSchema(TableName.Users).as("lastName") + ); + + return res; + }; + + return { ...secretReminderRecipientsOrm, findUsersBySecretId }; +}; diff --git a/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-types.ts b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-types.ts new file mode 100644 index 000000000..49eb9bf0f --- /dev/null +++ b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-types.ts @@ -0,0 +1,8 @@ +export type TSecretReminderRecipient = { + user: { + id: string; + username: string; + email?: string | null; + }; + id: string; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 86273a4ff..687d76f33 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -10,7 +10,8 @@ export enum SecretSync { TerraformCloud = "terraform-cloud", Camunda = "camunda", Vercel = "vercel", - Windmill = "windmill" + Windmill = "windmill", + TeamCity = "teamcity" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 0b821b593..143fa4622 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -27,6 +27,7 @@ import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { TEAMCITY_SYNC_LIST_OPTION, TeamCitySyncFns } from "./teamcity"; import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; import { WINDMILL_SYNC_LIST_OPTION, WindmillSyncFns } from "./windmill"; @@ -43,7 +44,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.TerraformCloud]: TERRAFORM_CLOUD_SYNC_LIST_OPTION, [SecretSync.Camunda]: CAMUNDA_SYNC_LIST_OPTION, [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION, - [SecretSync.Windmill]: WINDMILL_SYNC_LIST_OPTION + [SecretSync.Windmill]: WINDMILL_SYNC_LIST_OPTION, + [SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -140,6 +142,8 @@ export const SecretSyncFns = { return VercelSyncFns.syncSecrets(secretSync, secretMap); case SecretSync.Windmill: return WindmillSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.TeamCity: + return TeamCitySyncFns.syncSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -199,6 +203,9 @@ export const SecretSyncFns = { case SecretSync.Windmill: secretMap = await WindmillSyncFns.getSecrets(secretSync); break; + case SecretSync.TeamCity: + secretMap = await TeamCitySyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -252,6 +259,8 @@ export const SecretSyncFns = { return VercelSyncFns.removeSecrets(secretSync, secretMap); case SecretSync.Windmill: return WindmillSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.TeamCity: + return TeamCitySyncFns.removeSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index a9099543d..fdf4dfabb 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -13,7 +13,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.TerraformCloud]: "Terraform Cloud", [SecretSync.Camunda]: "Camunda", [SecretSync.Vercel]: "Vercel", - [SecretSync.Windmill]: "Windmill" + [SecretSync.Windmill]: "Windmill", + [SecretSync.TeamCity]: "TeamCity" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -28,5 +29,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, [SecretSync.Camunda]: AppConnection.Camunda, [SecretSync.Vercel]: AppConnection.Vercel, - [SecretSync.Windmill]: AppConnection.Windmill + [SecretSync.Windmill]: AppConnection.Windmill, + [SecretSync.TeamCity]: AppConnection.TeamCity }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 3177b68b1..34ac84947 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -356,8 +356,11 @@ export const secretSyncQueueFactory = ({ }; if (Object.hasOwn(secretMap, key)) { - secretsToUpdate.push(secret); - if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination) importedSecretMap[key] = secretData; + // Only update secrets if the source value is not empty + if (value) { + secretsToUpdate.push(secret); + if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination) importedSecretMap[key] = secretData; + } } else { secretsToCreate.push(secret); importedSecretMap[key] = secretData; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 03e92a57a..716c9b44f 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -61,6 +61,12 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { + TTeamCitySync, + TTeamCitySyncInput, + TTeamCitySyncListItem, + TTeamCitySyncWithCredentials +} from "./teamcity/teamcity-sync-types"; import { TTerraformCloudSync, TTerraformCloudSyncInput, @@ -81,7 +87,8 @@ export type TSecretSync = | TTerraformCloudSync | TCamundaSync | TVercelSync - | TWindmillSync; + | TWindmillSync + | TTeamCitySync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -95,7 +102,8 @@ export type TSecretSyncWithCredentials = | TTerraformCloudSyncWithCredentials | TCamundaSyncWithCredentials | TVercelSyncWithCredentials - | TWindmillSyncWithCredentials; + | TWindmillSyncWithCredentials + | TTeamCitySyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -109,7 +117,8 @@ export type TSecretSyncInput = | TTerraformCloudSyncInput | TCamundaSyncInput | TVercelSyncInput - | TWindmillSyncInput; + | TWindmillSyncInput + | TTeamCitySyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -123,7 +132,8 @@ export type TSecretSyncListItem = | TTerraformCloudSyncListItem | TCamundaSyncListItem | TVercelSyncListItem - | TWindmillSyncListItem; + | TWindmillSyncListItem + | TTeamCitySyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/teamcity/index.ts b/backend/src/services/secret-sync/teamcity/index.ts new file mode 100644 index 000000000..add83cb20 --- /dev/null +++ b/backend/src/services/secret-sync/teamcity/index.ts @@ -0,0 +1,4 @@ +export * from "./teamcity-sync-constants"; +export * from "./teamcity-sync-fns"; +export * from "./teamcity-sync-schemas"; +export * from "./teamcity-sync-types"; diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-constants.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-constants.ts new file mode 100644 index 000000000..30f541bbc --- /dev/null +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const TEAMCITY_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "TeamCity", + destination: SecretSync.TeamCity, + connection: AppConnection.TeamCity, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts new file mode 100644 index 000000000..6dbd9bdd7 --- /dev/null +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts @@ -0,0 +1,187 @@ +import { request } from "@app/lib/config/request"; +import { getTeamCityInstanceUrl } from "@app/services/app-connection/teamcity"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; +import { + TDeleteTeamCityVariable, + TPostTeamCityVariable, + TTeamCityListVariables, + TTeamCityListVariablesResponse, + TTeamCitySyncWithCredentials +} from "@app/services/secret-sync/teamcity/teamcity-sync-types"; + +// Note: Most variables won't be returned with a value due to them being a "password" type. +// TeamCity API returns empty string for password-type variables for security reasons. +const listTeamCityVariables = async ({ instanceUrl, accessToken, project, buildConfig }: TTeamCityListVariables) => { + const { data } = await request.get( + buildConfig + ? `${instanceUrl}/app/rest/buildTypes/${encodeURIComponent(buildConfig)}/parameters` + : `${instanceUrl}/app/rest/projects/id:${encodeURIComponent(project)}/parameters`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + + // Filters for only non-inherited environment variables + // Strips out "env." from map key, but the "name" field still has the original unaltered key. + return Object.fromEntries( + data.property + .filter((variable) => !variable.inherited) + .filter((variable) => variable.name.startsWith("env.")) + .map((variable) => [ + variable.name.substring(4), + { ...variable, value: variable.value || "" } // Password values will be empty strings from the API for security + ]) + ); +}; + +// Create and update both use the same method +const updateTeamCityVariable = async ({ + instanceUrl, + accessToken, + project, + buildConfig, + key, + value +}: TPostTeamCityVariable) => { + return request.post( + buildConfig + ? `${instanceUrl}/app/rest/buildTypes/${encodeURIComponent(buildConfig)}/parameters` + : `${instanceUrl}/app/rest/projects/id:${encodeURIComponent(project)}/parameters`, + { + name: key, + value, + type: { + rawValue: "password display='hidden'" + } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); +}; + +const deleteTeamCityVariable = async ({ + instanceUrl, + accessToken, + project, + buildConfig, + key +}: TDeleteTeamCityVariable) => { + return request.delete( + buildConfig + ? `${instanceUrl}/app/rest/buildTypes/${encodeURIComponent(buildConfig)}/parameters/${encodeURIComponent(key)}` + : `${instanceUrl}/app/rest/projects/id:${encodeURIComponent(project)}/parameters/${encodeURIComponent(key)}`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); +}; + +export const TeamCitySyncFns = { + syncSecrets: async (secretSync: TTeamCitySyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { project, buildConfig } + } = secretSync; + + const instanceUrl = await getTeamCityInstanceUrl(connection); + const { accessToken } = connection.credentials; + + for await (const entry of Object.entries(secretMap)) { + const [key, { value }] = entry; + + const payload = { + instanceUrl, + accessToken, + project, + buildConfig, + key: `env.${key}`, + value + }; + + try { + // Replace every secret since TeamCity does not return secret values that we can cross-check + // No need to differenciate create / update because TeamCity uses the same method for both + await updateTeamCityVariable(payload); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (secretSync.syncOptions.disableSecretDeletion) return; + + const variables = await listTeamCityVariables({ instanceUrl, accessToken, project, buildConfig }); + + for await (const [key, variable] of Object.entries(variables)) { + if (!(key in secretMap)) { + try { + await deleteTeamCityVariable({ + key: variable.name, // We use variable.name instead of key because key is stripped of "env." prefix in listTeamCityVariables(). + instanceUrl, + accessToken, + project, + buildConfig + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + removeSecrets: async (secretSync: TTeamCitySyncWithCredentials, secretMap: TSecretMap) => { + const { + connection, + destinationConfig: { project, buildConfig } + } = secretSync; + + const instanceUrl = await getTeamCityInstanceUrl(connection); + const { accessToken } = connection.credentials; + + const variables = await listTeamCityVariables({ instanceUrl, accessToken, project, buildConfig }); + + for await (const [key, variable] of Object.entries(variables)) { + if (key in secretMap) { + try { + await deleteTeamCityVariable({ + key: variable.name, // We use variable.name instead of key because key is stripped of "env." prefix in listTeamCityVariables(). + instanceUrl, + accessToken, + project, + buildConfig + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + } + }, + getSecrets: async (secretSync: TTeamCitySyncWithCredentials) => { + const { + connection, + destinationConfig: { project, buildConfig } + } = secretSync; + + const instanceUrl = await getTeamCityInstanceUrl(connection); + const { accessToken } = connection.credentials; + + return listTeamCityVariables({ instanceUrl, accessToken, project, buildConfig }); + } +}; diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-schemas.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-schemas.ts new file mode 100644 index 000000000..21c09092f --- /dev/null +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-schemas.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const TeamCitySyncDestinationConfigSchema = z.object({ + project: z.string().trim().min(1, "Project required").describe(SecretSyncs.DESTINATION_CONFIG.TEAMCITY.project), + buildConfig: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.TEAMCITY.buildConfig) +}); + +const TeamCitySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const TeamCitySyncSchema = BaseSecretSyncSchema(SecretSync.TeamCity, TeamCitySyncOptionsConfig).extend({ + destination: z.literal(SecretSync.TeamCity), + destinationConfig: TeamCitySyncDestinationConfigSchema +}); + +export const CreateTeamCitySyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.TeamCity, + TeamCitySyncOptionsConfig +).extend({ + destinationConfig: TeamCitySyncDestinationConfigSchema +}); + +export const UpdateTeamCitySyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.TeamCity, + TeamCitySyncOptionsConfig +).extend({ + destinationConfig: TeamCitySyncDestinationConfigSchema.optional() +}); + +export const TeamCitySyncListItemSchema = z.object({ + name: z.literal("TeamCity"), + connection: z.literal(AppConnection.TeamCity), + destination: z.literal(SecretSync.TeamCity), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-types.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-types.ts new file mode 100644 index 000000000..8b3f15e0d --- /dev/null +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-types.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +import { TTeamCityConnection } from "@app/services/app-connection/teamcity"; + +import { CreateTeamCitySyncSchema, TeamCitySyncListItemSchema, TeamCitySyncSchema } from "./teamcity-sync-schemas"; + +export type TTeamCitySync = z.infer; + +export type TTeamCitySyncInput = z.infer; + +export type TTeamCitySyncListItem = z.infer; + +export type TTeamCitySyncWithCredentials = TTeamCitySync & { + connection: TTeamCityConnection; +}; + +export type TTeamCityVariable = { + name: string; + value: string; + inherited?: boolean; + type: { + rawValue: string; + }; +}; + +export type TTeamCityListVariablesResponse = { + property: (TTeamCityVariable & { value?: string })[]; + count: number; + href: string; +}; + +export type TTeamCityListVariables = { + accessToken: string; + instanceUrl: string; + project: string; + buildConfig?: string; +}; + +export type TPostTeamCityVariable = TTeamCityListVariables & { + key: string; + value: string; +}; + +export type TDeleteTeamCityVariable = TTeamCityListVariables & { + key: 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..6ab348520 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -22,6 +22,7 @@ import type { TFindSecretsByFolderIdsFilter, TGetSecretsDTO } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; +import { applyJitter } from "@app/lib/dates"; export const SecretServiceCacheKeys = { get productKey() { @@ -48,7 +49,7 @@ interface TSecretV2DalArg { keyStore: TKeyStoreFactory; } -export const SECRET_DAL_TTL = 5 * 60; +export const SECRET_DAL_TTL = () => applyJitter(10 * 60, 2 * 60); export const SECRET_DAL_VERSION_TTL = 15 * 60; export const MAX_SECRET_CACHE_BYTES = 25 * 1024 * 1024; export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { @@ -63,7 +64,8 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { const findOne = async (filter: Partial, tx?: Knex) => { try { const docs = await (tx || db)(TableName.SecretV2) - .where(filter) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(filter, TableName.SecretV2)) .leftJoin( TableName.SecretV2JnTag, `${TableName.SecretV2}.id`, @@ -79,7 +81,17 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { `${TableName.SecretV2}.id`, `${TableName.SecretRotationV2SecretMapping}.secretId` ) + .leftJoin( + TableName.SecretReminderRecipients, + `${TableName.SecretV2}.id`, + `${TableName.SecretReminderRecipients}.secretId` + ) + .leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`) .select(selectAllTableCols(TableName.SecretV2)) + .select(db.ref("id").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientId")) + .select(db.ref("username").withSchema(TableName.Users).as("reminderRecipientUsername")) + .select(db.ref("email").withSchema(TableName.Users).as("reminderRecipientEmail")) + .select(db.ref("id").withSchema(TableName.Users).as("reminderRecipientUserId")) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) @@ -103,6 +115,23 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { slug, name: slug }) + }, + { + key: "reminderRecipientId", + label: "secretReminderRecipients" as const, + mapper: ({ + reminderRecipientId, + reminderRecipientUsername, + reminderRecipientEmail, + reminderRecipientUserId + }) => ({ + user: { + id: reminderRecipientUserId, + username: reminderRecipientUsername, + email: reminderRecipientEmail + }, + id: reminderRecipientId + }) } ] }); @@ -484,6 +513,12 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) + .leftJoin( + TableName.SecretReminderRecipients, + `${TableName.SecretV2}.id`, + `${TableName.SecretReminderRecipients}.secretId` + ) + .leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) .leftJoin( TableName.SecretRotationV2SecretMapping, @@ -512,6 +547,10 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { }) as rank` ) ) + .select(db.ref("id").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientId")) + .select(db.ref("username").withSchema(TableName.Users).as("reminderRecipientUsername")) + .select(db.ref("email").withSchema(TableName.Users).as("reminderRecipientEmail")) + .select(db.ref("id").withSchema(TableName.Users).as("reminderRecipientUserId")) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) @@ -556,6 +595,23 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { isRotatedSecret: Boolean(el.rotationId) }), childrenMapper: [ + { + key: "reminderRecipientId", + label: "secretReminderRecipients" as const, + mapper: ({ + reminderRecipientId, + reminderRecipientUsername, + reminderRecipientEmail, + reminderRecipientUserId + }) => ({ + user: { + id: reminderRecipientUserId, + username: reminderRecipientUsername, + email: reminderRecipientEmail + }, + id: reminderRecipientId + }) + }, { key: "tagId", label: "tags" as const, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index f42deb8ff..6fdcadeff 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -2,7 +2,7 @@ import path from "node:path"; import RE2 from "re2"; -import { TableName, TSecretFolders, TSecretsV2 } from "@app/db/schemas"; +import { SecretType, TableName, TSecretFolders, TSecretsV2 } from "@app/db/schemas"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; @@ -12,6 +12,7 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "../secret/secret-fns"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretReminderRecipient } from "../secret-reminder-recipients/secret-reminder-recipients-types"; import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; import { TFnSecretBulkDelete, TFnSecretBulkInsert, TFnSecretBulkUpdate } from "./secret-v2-bridge-types"; @@ -353,7 +354,7 @@ export const fnSecretBulkDelete = async ({ deletedSecrets .filter(({ reminderRepeatDays }) => Boolean(reminderRepeatDays)) .map(({ id, reminderRepeatDays }) => - secretQueueService.removeSecretReminder({ secretId: id, repeatDays: reminderRepeatDays as number }) + secretQueueService.removeSecretReminder({ secretId: id, repeatDays: reminderRepeatDays as number }, tx) ) ); @@ -684,6 +685,7 @@ export const reshapeBridgeSecret = ( secretMetadata?: ResourceMetadataDTO; isRotatedSecret?: boolean; rotationId?: string; + secretReminderRecipients?: TSecretReminderRecipient[]; }, secretValueHidden: boolean ) => ({ @@ -715,9 +717,10 @@ export const reshapeBridgeSecret = ( updatedAt: secret.updatedAt, isRotatedSecret: secret.isRotatedSecret, rotationId: secret.rotationId, + secretReminderRecipients: secret.secretReminderRecipients || [], ...(secretValueHidden ? { - secretValue: INFISICAL_SECRET_VALUE_HIDDEN_MASK, + secretValue: secret.type === SecretType.Personal ? secret.value : INFISICAL_SECRET_VALUE_HIDDEN_MASK, secretValueHidden: true } : { diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index ca815c6e1..1ef4a2d41 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -544,7 +544,12 @@ export const secretV2BridgeServiceFactory = ({ id: updatedSecret[0].id, ...inputSecret }, - oldSecret: secret, + oldSecret: { + id: secret.id, + secretReminderNote: secret.reminderNote, + secretReminderRepeatDays: secret.reminderRepeatDays, + secretReminderRecipients: secret.secretReminderRecipients?.map((el) => el.user.id) + }, projectId }); @@ -957,7 +962,7 @@ export const secretV2BridgeServiceFactory = ({ const encryptedCachedSecrets = await keyStore.getItem(cacheKey); if (encryptedCachedSecrets) { try { - await keyStore.setExpiry(cacheKey, SECRET_DAL_TTL); + await keyStore.setExpiry(cacheKey, SECRET_DAL_TTL()); const cachedSecrets = secretManagerDecryptor({ cipherTextBlob: Buffer.from(encryptedCachedSecrets, "base64") }); const { secrets, imports = [] } = JSON.parse(cachedSecrets.toString("utf8")) as { secrets: typeof decryptedSecrets; @@ -1127,7 +1132,7 @@ export const secretV2BridgeServiceFactory = ({ plainText: Buffer.from(JSON.stringify(payload)) }).cipherTextBlob; if (encryptedUpdatedCachedSecrets.byteLength < MAX_SECRET_CACHE_BYTES) { - await keyStore.setItemWithExpiry(cacheKey, SECRET_DAL_TTL, encryptedUpdatedCachedSecrets.toString("base64")); + await keyStore.setItemWithExpiry(cacheKey, SECRET_DAL_TTL(), encryptedUpdatedCachedSecrets.toString("base64")); } return payload; } @@ -1174,7 +1179,7 @@ export const secretV2BridgeServiceFactory = ({ plainText: Buffer.from(JSON.stringify(payload)) }).cipherTextBlob; if (encryptedUpdatedCachedSecrets.byteLength < MAX_SECRET_CACHE_BYTES) { - await keyStore.setItemWithExpiry(cacheKey, SECRET_DAL_TTL, encryptedUpdatedCachedSecrets.toString("base64")); + await keyStore.setItemWithExpiry(cacheKey, SECRET_DAL_TTL(), encryptedUpdatedCachedSecrets.toString("base64")); } return payload; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 11149c605..f4a27d4c5 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -94,6 +94,7 @@ export type TUpdateSecretDTO = TProjectPermission & { skipMultilineEncoding?: boolean; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; + secretReminderRecipients?: string[] | null; metadata?: { source?: string; }; @@ -220,7 +221,7 @@ export type TFnSecretBulkDelete = { tx?: Knex; secretDAL: Pick; secretQueueService: { - removeSecretReminder: (data: TRemoveSecretReminderDTO) => Promise; + removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise; }; }; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index f08a5a04c..e5f3acdea 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -407,6 +407,7 @@ export const decryptSecretRaw = ( id: secret.id, user: secret.userId, tags: secret.tags?.map((el) => ({ ...el, name: el.slug })), + secretReminderRecipients: [], skipMultilineEncoding: secret.skipMultilineEncoding, secretReminderRepeatDays: secret.secretReminderRepeatDays, secretReminderNote: secret.secretReminderNote, @@ -758,7 +759,7 @@ export const fnSecretBulkDelete = async ({ deletedSecrets .filter(({ secretReminderRepeatDays }) => Boolean(secretReminderRepeatDays)) .map(({ id, secretReminderRepeatDays }) => - secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }) + secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }, tx) ) ); diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 5791c415d..6a0868741 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -1,11 +1,13 @@ /* eslint-disable no-await-in-loop */ import opentelemetry from "@opentelemetry/api"; import { AxiosError } from "axios"; +import { Knex } from "knex"; import { ProjectMembershipRole, ProjectUpgradeStatus, ProjectVersion, + SecretType, TSecretSnapshotSecretsV2, TSecretVersionsV2 } from "@app/db/schemas"; @@ -53,6 +55,7 @@ import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-sche import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns"; +import { TSecretReminderRecipientsDALFactory } from "../secret-reminder-recipients/secret-reminder-recipients-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { expandSecretReferencesFactory, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; @@ -109,6 +112,10 @@ type TSecretQueueFactoryDep = { orgService: Pick; projectUserMembershipRoleDAL: Pick; resourceMetadataDAL: Pick; + secretReminderRecipientsDAL: Pick< + TSecretReminderRecipientsDALFactory, + "delete" | "findUsersBySecretId" | "insertMany" | "transaction" + >; secretSyncQueue: Pick; }; @@ -170,6 +177,7 @@ export const secretQueueFactory = ({ projectUserMembershipRoleDAL, projectKeyDAL, resourceMetadataDAL, + secretReminderRecipientsDAL, secretSyncQueue }: TSecretQueueFactoryDep) => { const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); @@ -178,7 +186,11 @@ export const secretQueueFactory = ({ unit: "1" }); - const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { + const removeSecretReminder = async ({ deleteRecipients = true, ...dto }: TRemoveSecretReminderDTO, tx?: Knex) => { + if (deleteRecipients) { + await secretReminderRecipientsDAL.delete({ secretId: dto.secretId }, tx); + } + const appCfg = getConfig(); await queueService.stopRepeatableJob( QueueName.SecretReminder, @@ -224,7 +236,12 @@ export const secretQueueFactory = ({ .replace(":", "-"); }; - const addSecretReminder = async ({ oldSecret, newSecret, projectId }: TCreateSecretReminderDTO) => { + const addSecretReminder = async ({ + oldSecret, + newSecret, + projectId, + deleteRecipients = true + }: TCreateSecretReminderDTO) => { try { const appCfg = getConfig(); @@ -246,7 +263,8 @@ export const secretQueueFactory = ({ if (oldSecret.secretReminderRepeatDays) { await removeSecretReminder({ repeatDays: oldSecret.secretReminderRepeatDays, - secretId: oldSecret.id + secretId: oldSecret.id, + deleteRecipients }); } @@ -283,29 +301,57 @@ export const secretQueueFactory = ({ }; const handleSecretReminder = async ({ newSecret, oldSecret, projectId }: THandleReminderDTO) => { - const { secretReminderRepeatDays, secretReminderNote } = newSecret; + const { secretReminderRepeatDays, secretReminderNote, secretReminderRecipients } = newSecret; - if (newSecret.type !== "personal" && secretReminderRepeatDays !== undefined) { - if ( - (secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) || - (secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote) - ) { - await addSecretReminder({ - oldSecret, - newSecret, - projectId - }); - } else if ( - secretReminderRepeatDays === null && - secretReminderNote === null && - oldSecret.secretReminderRepeatDays - ) { - await removeSecretReminder({ - secretId: oldSecret.id, - repeatDays: oldSecret.secretReminderRepeatDays - }); + const recipientsUpdated = + secretReminderRecipients?.some( + (newId) => !oldSecret.secretReminderRecipients?.find((oldId) => newId === oldId) + ) || secretReminderRecipients?.length !== oldSecret.secretReminderRecipients?.length; + + await secretReminderRecipientsDAL.transaction(async (tx) => { + if (newSecret.type !== SecretType.Personal && secretReminderRepeatDays !== undefined) { + if ( + (secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) || + (secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote) + ) { + await addSecretReminder({ + oldSecret, + newSecret, + projectId, + deleteRecipients: false + }); + } else if ( + secretReminderRepeatDays === null && + secretReminderNote === null && + oldSecret.secretReminderRepeatDays + ) { + await removeSecretReminder({ + secretId: oldSecret.id, + repeatDays: oldSecret.secretReminderRepeatDays + }); + } } - } + + if (recipientsUpdated) { + // if no recipients, delete all existing recipients + if (!secretReminderRecipients?.length) { + const existingRecipients = await secretReminderRecipientsDAL.findUsersBySecretId(newSecret.id, tx); + if (existingRecipients) { + await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx); + } + } else { + await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx); + await secretReminderRecipientsDAL.insertMany( + secretReminderRecipients.map((r) => ({ + secretId: newSecret.id, + userId: r, + projectId + })), + tx + ); + } + } + }); }; const createManySecretsRawFn = createManySecretsRawFnFactory({ projectDAL, @@ -1071,6 +1117,8 @@ export const secretQueueFactory = ({ const secret = await secretV2BridgeDAL.findById(data.secretId); const [folder] = await folderDAL.findSecretPathByFolderIds(project.id, [secret.folderId]); + const recipients = await secretReminderRecipientsDAL.findUsersBySecretId(data.secretId); + if (!organization) { logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`); return; @@ -1088,10 +1136,14 @@ export const secretQueueFactory = ({ return; } + const selectedRecipients = recipients?.length + ? recipients.map((r) => r.email as string) + : projectMembers.map((m) => m.user.email as string); + await smtpService.sendMail({ template: SmtpTemplates.SecretReminder, subjectLine: "Infisical secret reminder", - recipients: [...projectMembers.map((m) => m.user.email)].filter((email) => email).map((email) => email as string), + recipients: selectedRecipients, substitutions: { reminderNote: data.note, // May not be present. projectName: project.name, diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index a82b04833..46ed7ef83 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -546,10 +546,13 @@ export const secretServiceFactory = ({ for await (const secret of secrets) { if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) { - await secretQueueService.removeSecretReminder({ - repeatDays: secret.secretReminderRepeatDays, - secretId: secret.id - }); + await secretQueueService.removeSecretReminder( + { + repeatDays: secret.secretReminderRepeatDays, + secretId: secret.id + }, + tx + ); } } @@ -685,6 +688,7 @@ export const secretServiceFactory = ({ ...secret, workspace: projectId, environment, + secretReminderRecipients: [], secretPath: groupedPaths[secret.folderId][0].path })) }; @@ -1073,10 +1077,13 @@ export const secretServiceFactory = ({ for await (const secret of secrets) { if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) { - await secretQueueService.removeSecretReminder({ - repeatDays: secret.secretReminderRepeatDays, - secretId: secret.id - }); + await secretQueueService.removeSecretReminder( + { + repeatDays: secret.secretReminderRepeatDays, + secretId: secret.id + }, + tx + ); } } const secretValueHidden = !hasSecretReadValueOrDescribePermission( @@ -1786,6 +1793,7 @@ export const secretServiceFactory = ({ tagIds, secretReminderNote, secretReminderRepeatDays, + secretReminderRecipients, metadata, secretComment, newSecretName, @@ -1828,6 +1836,7 @@ export const secretServiceFactory = ({ tagIds, reminderNote: secretReminderNote, reminderRepeatDays: secretReminderRepeatDays, + secretReminderRecipients, secretMetadata } ] @@ -1837,8 +1846,9 @@ export const secretServiceFactory = ({ } const secret = await secretV2BridgeService.updateSecret({ secretReminderRepeatDays, - skipMultilineEncoding, secretReminderNote, + secretReminderRecipients, + skipMultilineEncoding, tagIds, secretComment, secretPath, diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index be036cab8..30e3dfafa 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -22,9 +22,13 @@ import { SecretUpdateMode } from "../secret-v2-bridge/secret-v2-bridge-types"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; -type TPartialSecret = Pick; +type TPartialSecret = Pick & { + secretReminderRecipients?: string[] | null; +}; -type TPartialInputSecret = Pick; +type TPartialInputSecret = Pick & { + secretReminderRecipients?: string[] | null; +}; export const FailedIntegrationSyncEmailsPayloadSchema = z.object({ projectId: z.string(), @@ -258,6 +262,7 @@ export type TUpdateSecretRawDTO = TProjectPermission & { skipMultilineEncoding?: boolean; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; + secretReminderRecipients?: string[] | null; metadata?: { source?: string; }; @@ -374,7 +379,7 @@ export type TFnSecretBulkDelete = { tx?: Knex; secretDAL: Pick; secretQueueService: { - removeSecretReminder: (data: TRemoveSecretReminderDTO) => Promise; + removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise; }; }; @@ -405,11 +410,14 @@ export type TCreateSecretReminderDTO = { oldSecret: TPartialSecret; newSecret: TPartialSecret; projectId: string; + + deleteRecipients?: boolean; }; export type TRemoveSecretReminderDTO = { secretId: string; repeatDays: number; + deleteRecipients?: boolean; }; export type TBackFillSecretReferencesDTO = TProjectPermission; diff --git a/cli/go.mod b/cli/go.mod index c713417e2..52cb79f38 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -12,7 +12,7 @@ require ( github.com/fatih/semgroup v1.2.0 github.com/gitleaks/go-gitdiff v0.8.0 github.com/h2non/filetype v1.1.3 - github.com/infisical/go-sdk v0.5.8 + github.com/infisical/go-sdk v0.5.92 github.com/infisical/infisical-kmip v0.3.5 github.com/mattn/go-isatty v0.0.20 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a diff --git a/cli/go.sum b/cli/go.sum index 68bce9cd3..49566f1cc 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -277,8 +277,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/infisical/go-sdk v0.5.8 h1:bCetYLp7HWt8DnU9KPh1n8n3z5pjmunkGDB4bA3lEFs= -github.com/infisical/go-sdk v0.5.8/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= +github.com/infisical/go-sdk v0.5.92 h1:PoCnVndrd6Dbkipuxl9fFiwlD5vCKsabtQo09mo8lUE= +github.com/infisical/go-sdk v0.5.92/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= github.com/infisical/infisical-kmip v0.3.5 h1:QM3s0e18B+mYv3a9HQNjNAlbwZJBzXq5BAJM2scIeiE= github.com/infisical/infisical-kmip v0.3.5/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index 5b2bb37bb..7f74d8ee6 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -177,7 +177,6 @@ func issueCredentials(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -411,7 +410,6 @@ func signKey(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -610,23 +608,80 @@ func signKey(cmd *cobra.Command, args []string) { } func sshConnect(cmd *cobra.Command, args []string) { - util.RequireLogin() - util.RequireLocalWorkspaceFile() - - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + token, err := util.GetInfisicalToken(cmd) if err != nil { - util.HandleError(err, "Unable to authenticate") + util.HandleError(err, "Unable to parse flag") + } + + var infisicalToken string + + if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { + infisicalToken = token.Token + } else { + util.RequireLogin() + + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil { + util.HandleError(err, "Unable to authenticate") + } + + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } - if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + writeHostCaToFile, err := cmd.Flags().GetBool("write-host-ca-to-file") + if err != nil { + util.HandleError(err, "Unable to parse --write-host-ca-to-file flag") } - infisicalToken := loggedInUserDetails.UserCredentials.JTWToken - - writeHostCaToFile, err := cmd.Flags().GetBool("writeHostCaToFile") + outFilePath, err := cmd.Flags().GetString("out-file-path") if err != nil { - util.HandleError(err, "Unable to parse --writeHostCaToFile flag") + util.HandleError(err, "Unable to parse flag") + } + + hostname, _ := cmd.Flags().GetString("hostname") + loginUser, _ := cmd.Flags().GetString("login-user") + + var outputDir, privateKeyPath, publicKeyPath, signedKeyPath string + if outFilePath != "" { + if strings.HasPrefix(outFilePath, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + util.HandleError(err, "Failed to resolve home directory") + } + outFilePath = strings.Replace(outFilePath, "~", homeDir, 1) + } + + if strings.HasSuffix(outFilePath, "-cert.pub") { + signedKeyPath = outFilePath + baseName := strings.TrimSuffix(filepath.Base(outFilePath), "-cert.pub") + outputDir = filepath.Dir(outFilePath) + privateKeyPath = filepath.Join(outputDir, baseName) + publicKeyPath = filepath.Join(outputDir, baseName+".pub") + } else { + outputDir = outFilePath + info, err := os.Stat(outputDir) + if os.IsNotExist(err) { + err = os.MkdirAll(outputDir, 0755) + if err != nil { + util.HandleError(err, "Failed to create output directory") + } + } else if err != nil { + util.HandleError(err, "Failed to access output directory") + } else if !info.IsDir() { + util.PrintErrorMessageAndExit("The provided --outFilePath is not a directory") + } + fileName := "id_ed25519" + privateKeyPath = filepath.Join(outputDir, fileName) + publicKeyPath = filepath.Join(outputDir, fileName+".pub") + signedKeyPath = filepath.Join(outputDir, fileName+"-cert.pub") + } + + if privateKeyPath == "" || publicKeyPath == "" || signedKeyPath == "" { + util.PrintErrorMessageAndExit("Failed to resolve file paths for writing credentials") + } } customHeaders, err := util.GetInfisicalCustomHeadersMap() @@ -651,43 +706,75 @@ func sshConnect(cmd *cobra.Command, args []string) { util.PrintErrorMessageAndExit("You do not have access to any SSH hosts") } - // Prompt to select host - hostNames := make([]string, len(hosts)) - for i, h := range hosts { - hostNames[i] = h.Hostname + var selectedHost = hosts[0] + if hostname != "" { + foundHost := false + for _, h := range hosts { + if h.Hostname == hostname { + selectedHost = h + foundHost = true + break + } + } + if !foundHost { + util.PrintErrorMessageAndExit("Specified --hostname not found or not accessible") + } + } else { + hostNames := make([]string, len(hosts)) + for i, h := range hosts { + 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] } - hostPrompt := promptui.Select{ - Label: "Select an SSH Host", - Items: hostNames, - Size: 10, + var selectedLoginUser string + if loginUser != "" { + foundLoginUser := false + for _, m := range selectedHost.LoginMappings { + if m.LoginUser == loginUser { + selectedLoginUser = loginUser + foundLoginUser = true + break + } + } + if !foundLoginUser { + util.PrintErrorMessageAndExit("Specified --loginUser not valid for selected host") + } + } else { + if len(selectedHost.LoginMappings) == 0 { + util.PrintErrorMessageAndExit("No login users available for selected host") + } + loginUsers := make([]string, len(selectedHost.LoginMappings)) + for i, m := range selectedHost.LoginMappings { + loginUsers[i] = m.LoginUser + } + loginPrompt := promptui.Select{ + Label: "Select Login User", + Items: loginUsers, + Size: 5, + } + loginIdx, _, err := loginPrompt.Run() + if err != nil { + util.HandleError(err, "Prompt failed") + } + selectedLoginUser = selectedHost.LoginMappings[loginIdx].LoginUser } - hostIdx, _, err := hostPrompt.Run() - if err != nil { - util.HandleError(err, "Prompt failed") - } - selectedHost := hosts[hostIdx] - - // Prompt to select login user - if len(selectedHost.LoginMappings) == 0 { - util.PrintErrorMessageAndExit("No login users available for selected host") - } - - loginUsers := make([]string, len(selectedHost.LoginMappings)) - for i, m := range selectedHost.LoginMappings { - loginUsers[i] = m.LoginUser - } - - loginPrompt := promptui.Select{ - Label: "Select Login User", - Items: loginUsers, - Size: 5, - } - loginIdx, _, err := loginPrompt.Run() - if err != nil { - util.HandleError(err, "Prompt failed") - } - selectedLoginUser := selectedHost.LoginMappings[loginIdx].LoginUser // Issue SSH creds for host creds, err := infisicalClient.Ssh().IssueSshHostUserCert(selectedHost.ID, infisicalSdk.IssueSshHostUserCertOptions{ @@ -731,10 +818,27 @@ func sshConnect(cmd *cobra.Command, args []string) { util.HandleError(err, "Failed to write Host CA to known_hosts") } - fmt.Printf("📁 Wrote Host CA entry to %s\n", knownHostsPath) + fmt.Printf("Successfully wrote Host CA entry to %s\n", knownHostsPath) } } + if outFilePath != "" { + err = writeToFile(privateKeyPath, creds.PrivateKey, 0600) + if err != nil { + util.HandleError(err, "Failed to write private key") + } + err = writeToFile(publicKeyPath, creds.PublicKey, 0644) + if err != nil { + util.HandleError(err, "Failed to write public key") + } + err = writeToFile(signedKeyPath, creds.SignedKey, 0644) + if err != nil { + util.HandleError(err, "Failed to write signed cert") + } + fmt.Printf("Successfully wrote credentials to %s, %s, and %s\n", privateKeyPath, publicKeyPath, signedKeyPath) + return + } + // Load credentials into SSH agent err = addCredentialsToAgent(creds.PrivateKey, creds.SignedKey) if err != nil { @@ -769,7 +873,6 @@ func sshAddHost(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -797,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") @@ -823,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 @@ -831,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) } @@ -902,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") @@ -1006,17 +1119,22 @@ func init() { sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent") sshCmd.AddCommand(sshIssueCredentialsCmd) - sshConnectCmd.Flags().Bool("writeHostCaToFile", true, "Write Host CA public key to ~/.ssh/known_hosts as a separate entry if doesn't already exist") + sshConnectCmd.Flags().String("token", "", "Use a machine identity access token") + sshConnectCmd.Flags().Bool("write-host-ca-to-file", true, "Write Host CA public key to ~/.ssh/known_hosts as a separate entry if doesn't already exist") + sshConnectCmd.Flags().String("hostname", "", "Hostname of the SSH host to connect to") + sshConnectCmd.Flags().String("login-user", "", "Login user for the SSH connection") + sshConnectCmd.Flags().String("out-file-path", "", "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") sshCmd.AddCommand(sshConnectCmd) 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().Bool("writeUserCaToFile", false, "Write User CA public key to /etc/ssh/infisical_user_ca.pub") - sshAddHostCmd.Flags().String("userCaOutFilePath", "/etc/ssh/infisical_user_ca.pub", "Custom file path to write the User CA public key") - sshAddHostCmd.Flags().Bool("writeHostCertToFile", false, "Write SSH host certificate to /etc/ssh/ssh_host__key-cert.pub") - sshAddHostCmd.Flags().Bool("configureSshd", 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().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 `/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/cli/packages/cmd/user.go b/cli/packages/cmd/user.go index d3e6096a9..2879e4ccb 100644 --- a/cli/packages/cmd/user.go +++ b/cli/packages/cmd/user.go @@ -1,9 +1,12 @@ package cmd import ( + "encoding/base64" + "encoding/json" "errors" "fmt" "net/url" + "strings" "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" @@ -85,6 +88,57 @@ var switchCmd = &cobra.Command{ }, } +var userGetCmd = &cobra.Command{ + Use: "get", + Short: "Used to get properties of an Infisical profile", + DisableFlagsInUseLine: true, + Example: "infisical user get", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +var userGetTokenCmd = &cobra.Command{ + Use: "token", + Short: "Used to get the access token of an Infisical user", + DisableFlagsInUseLine: true, + Example: "infisical user get token", + Args: cobra.ExactArgs(0), + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + if err != nil { + util.HandleError(err, "[infisical user get token]: Unable to get logged in user token") + } + + tokenParts := strings.Split(loggedInUserDetails.UserCredentials.JTWToken, ".") + if len(tokenParts) != 3 { + util.HandleError(errors.New("invalid token format"), "[infisical user get token]: Invalid token format") + } + + payload, err := base64.RawURLEncoding.DecodeString(tokenParts[1]) + if err != nil { + util.HandleError(err, "[infisical user get token]: Unable to decode token payload") + } + + var tokenPayload struct { + TokenVersionId string `json:"tokenVersionId"` + } + if err := json.Unmarshal(payload, &tokenPayload); err != nil { + util.HandleError(err, "[infisical user get token]: Unable to parse token payload") + } + + fmt.Println("Session ID:", tokenPayload.TokenVersionId) + fmt.Println("Token:", loggedInUserDetails.UserCredentials.JTWToken) + }, +} + var updateCmd = &cobra.Command{ Use: "update", Short: "Used to update properties of an Infisical profile", @@ -185,6 +239,8 @@ var domainCmd = &cobra.Command{ func init() { updateCmd.AddCommand(domainCmd) userCmd.AddCommand(updateCmd) + userGetCmd.AddCommand(userGetTokenCmd) + userCmd.AddCommand(userGetCmd) userCmd.AddCommand(switchCmd) rootCmd.AddCommand(userCmd) } 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/app-connections/teamcity/available.mdx b/docs/api-reference/endpoints/app-connections/teamcity/available.mdx new file mode 100644 index 000000000..c5cbd3c9d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/teamcity/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/teamcity/create.mdx b/docs/api-reference/endpoints/app-connections/teamcity/create.mdx new file mode 100644 index 000000000..19d30af70 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/teamcity" +--- + + + Check out the configuration docs for [TeamCity Connections](/integrations/app-connections/teamcity) to learn how to obtain + the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/teamcity/delete.mdx b/docs/api-reference/endpoints/app-connections/teamcity/delete.mdx new file mode 100644 index 000000000..d4a5d67ae --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/teamcity/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/teamcity/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/teamcity/get-by-id.mdx new file mode 100644 index 000000000..090725826 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/teamcity/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/teamcity/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/teamcity/get-by-name.mdx new file mode 100644 index 000000000..ccb46a27d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/teamcity/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/teamcity/list.mdx b/docs/api-reference/endpoints/app-connections/teamcity/list.mdx new file mode 100644 index 000000000..e4987a876 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/teamcity" +--- diff --git a/docs/api-reference/endpoints/app-connections/teamcity/update.mdx b/docs/api-reference/endpoints/app-connections/teamcity/update.mdx new file mode 100644 index 000000000..499f4a379 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/teamcity/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/teamcity/{connectionId}" +--- + + + Check out the configuration docs for [TeamCity Connections](/integrations/app-connections/teamcity) to learn how to obtain + the required credentials. + diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/create.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/create.mdx new file mode 100644 index 000000000..69557bb80 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/aws-iam-user-secret" +--- + + + Check out the configuration docs for [AWS IAM User Secret Rotations](/documentation/platform/secret-rotation/aws-iam-user-secret) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/delete.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/delete.mdx new file mode 100644 index 000000000..457e35b42 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/aws-iam-user-secret/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-id.mdx new file mode 100644 index 000000000..3e71aa4d7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/aws-iam-user-secret/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-name.mdx new file mode 100644 index 000000000..ccc4b9e28 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/aws-iam-user-secret/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..0ade7a3d9 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/aws-iam-user-secret/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/list.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/list.mdx new file mode 100644 index 000000000..6776b2d0f --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/aws-iam-user-secret" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/rotate-secrets.mdx new file mode 100644 index 000000000..6eda840d4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/aws-iam-user-secret/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/update.mdx b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/update.mdx new file mode 100644 index 000000000..e276af8d9 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/aws-iam-user-secret/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/aws-iam-user-secret/{rotationId}" +--- + + + Check out the configuration docs for [AWS IAM User Secret Rotations](/documentation/platform/secret-rotation/aws-iam-user-secret) 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/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/api-reference/endpoints/secret-syncs/teamcity/create.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/create.mdx new file mode 100644 index 000000000..438702b34 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/teamcity" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/delete.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/delete.mdx new file mode 100644 index 000000000..f2b1af6eb --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/teamcity/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/get-by-id.mdx new file mode 100644 index 000000000..857d1d5a2 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/teamcity/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/get-by-name.mdx new file mode 100644 index 000000000..e7101c7b7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/teamcity/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/import-secrets.mdx new file mode 100644 index 000000000..961259300 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/teamcity/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/list.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/list.mdx new file mode 100644 index 000000000..d3a6a8373 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/teamcity" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/remove-secrets.mdx new file mode 100644 index 000000000..0f63752ba --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/teamcity/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/sync-secrets.mdx new file mode 100644 index 000000000..36eaa361c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/teamcity/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/teamcity/update.mdx b/docs/api-reference/endpoints/secret-syncs/teamcity/update.mdx new file mode 100644 index 000000000..820c81b21 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/teamcity/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/teamcity/{syncId}" +--- diff --git a/docs/cli/commands/ssh.mdx b/docs/cli/commands/ssh.mdx index 78712ba6f..bb7017135 100644 --- a/docs/cli/commands/ssh.mdx +++ b/docs/cli/commands/ssh.mdx @@ -7,110 +7,87 @@ description: "Generate SSH credentials with the CLI" [Infisical SSH](/documentation/platform/ssh) lets you issue SSH credentials to clients to provide short-lived, secure SSH access to infrastructure. -This command enables you to obtain SSH credentials used to access a remote host; we recommend using the `issue-credentials` sub-command to generate dynamic SSH credentials for each SSH session. +This command enables you to obtain SSH credentials used to access a remote host. We recommend using the `connect` sub-command which handles the full workflow of issuing credentials and establishing an SSH connection in one step. ### Sub-commands - - 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 connect to an SSH host using issued credentials. It will automatically issue credentials and either add them to your SSH agent or write them to disk before establishing an SSH connection. ```bash - $ infisical ssh issue-credentials --certificateTemplateId= --principals= --addToAgent + $ infisical ssh connect ``` ### Flags - - The ID of the SSH certificate template to issue SSH credentials for. + + The hostname of the SSH host to connect to. If not provided, you will be prompted to select from available hosts. - - A comma-separated list of principals (i.e. usernames like `ec2-user` or hostnames) to issue SSH credentials for. + + The login user for the SSH connection. If not provided, you will be prompted to select from available login users. - - Whether to add issued SSH credentials to the SSH agent. - - Default value: `false` - - Note that either the `--outFilePath` or `--addToAgent` flag must be set for the sub-command to execute successfully. + + 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 saved to the current working directory where the command is run. - - 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. + + 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 issue SSH credentials. + Use a machine identity access token + - - This command is used to sign an existing SSH public key against a certificate template; the command outputs the corresponding signed SSH certificate. + + 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 sign-key --certificateTemplateId= --publicKey= --principals= --outFilePath= + $ infisical ssh add-host --projectId= --hostname= ``` - - 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. + ### Flags + + Project ID the host belongs to (required) - - The path to the public key file to sign. + + Hostname of the SSH host (required) + + + Alias for the SSH host (optional) + + + Write User CA public key to `/etc/ssh/infisical_user_ca.pub` - Note that either the `--publicKey` or `--publicKeyFilePath` flag must be set for the sub-command to execute successfully. + Default value: `false` - - A comma-separated list of principals (i.e. usernames like `ec2-user` or hostnames) to issue SSH credentials for. + + Custom file path to write the User CA public key + + Default value: `/etc/ssh/infisical_user_ca.pub` - - 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. + + Write SSH host certificate to `/etc/ssh/ssh_host__key-cert.pub` + + Default value: `false` - - The certificate type to issue SSH credentials for. - - Default value: `user` - - Available options: `user` or `host` + + Update `TrustedUserCAKeys`, `HostKey`, and `HostCertificate` in the `/etc/ssh/sshd_config` file + + Default value: `false` + + Note: This flag requires both --write-user-ca-to-file and --write-host-cert-to-file to be set - - 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. + + Force overwrite of existing certificate files as part of `--write-user-ca-to-file` and `--write-host-cert-to-file` + + Default value: `false` - An authenticated token to use to issue SSH credentials. + Use a machine identity access token - \ No newline at end of file + + diff --git a/docs/cli/commands/user.mdx b/docs/cli/commands/user.mdx index 38a92bbab..43a6111e1 100644 --- a/docs/cli/commands/user.mdx +++ b/docs/cli/commands/user.mdx @@ -8,22 +8,46 @@ infisical user ``` ## Description + This command allows you to manage the current logged in users on the CLI -### Sub-commands - - Use this command to switch between profiles that are currently logged into the CLI +### Sub-commands + + + Use this command to switch between profiles that are currently logged into the CLI + +```bash +infisical user switch +``` - ```bash - infisical user switch - ``` With this command, you can modify the backend API that is utilized for all requests associated with a specific profile. For instance, you have the option to point the profile to use either the Infisical Cloud or your own self-hosted Infisical instance. - ```bash - infisical user update domain +```bash +infisical user update domain +``` + + + + + Use this command to get your current Infisical access token and session information. This command requires you to be logged in. + + The command will display: + + - Your session ID + - Your full JWT access token + + ```bash + infisical user get token + ``` + + Example output: + + ```bash + Session ID: abc123-xyz-456 + Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ``` diff --git a/docs/documentation/platform/access-controls/assume-privilege.mdx b/docs/documentation/platform/access-controls/assume-privilege.mdx new file mode 100644 index 000000000..a38fd65f0 --- /dev/null +++ b/docs/documentation/platform/access-controls/assume-privilege.mdx @@ -0,0 +1,40 @@ +--- +title: "Assume Privileges" +description: "Learn how to temporarily assume the privileges of a user or machine identity within a project." +--- + +This feature allows authorized users to temporarily take on the permissions of another user or identity. It helps administrators and access managers test and verify permissions before granting access, ensuring everything is set up correctly. +It also reduces back-and-forth with end users when troubleshooting permission-related issues. + +## How It Works + +When an authorized user activates assume privileges mode, they temporarily inherit the target user or identity’s permissions for up to one hour. +During this time, they can perform actions within the system with the same level of access as the target user. + +- **Permission-based**: Only permissions are inherited, not the full identity +- **Time-limited**: Access automatically expires after one hour +- **Audited**: All actions are logged under the original user's account. This means any action taken during the session will be recorded under the entity assuming the privileges, not the target entity. +- **Authorization required**: Only users with the specific **assume privilege** permission can use this feature +- **Scoped to a single project**: You can only assume privileges for one project at a time + +## How to Assume Privileges + + + + Click on the user or identity you want to assume. + + ![Access control page](/images/platform/access-controls/assume-privileges/access-control.png) + + + + Click **Assume Privilege**, then type `assume` to confirm and start your session. + + ![Access control detail page](/images/platform/access-controls/assume-privileges/access-control-detail.png) + + + + You will see a yellow banner indicating that your assume privilege session is active. You can exit at any time by clicking **Exit**. + + ![session start](/images/platform/access-controls/assume-privileges/session-start.png) + + \ No newline at end of file diff --git a/docs/documentation/platform/github-org-sync.mdx b/docs/documentation/platform/github-org-sync.mdx new file mode 100644 index 000000000..00c9bf4c4 --- /dev/null +++ b/docs/documentation/platform/github-org-sync.mdx @@ -0,0 +1,56 @@ +--- +title: "GitHub Team Sync" +description: "Learn how to automatically synchronize your GitHub teams with Infisical Groups." +--- + +## Overview + +The GitHub Organization Synchronization feature streamlines user and group management by automatically syncing users belonging to your specified GitHub organization with corresponding groups within Infisical. This integration ensures that users logging in via GitHub are automatically added to or removed from Infisical groups based on their team memberships within your GitHub organization. + +## Configuration + +To enable and configure GitHub Organization Synchronization, follow these steps: + + + + 1. Navigate to **Organization Settings** and select the **Security Tab**. + ![config](../../images/platform/external-syncs/github-org-sync-section.png) + 2. Click the **Configure** button and provide the name of your GitHub Organization. + ![config-modal](../../images/platform/external-syncs/github-org-sync-config-modal.png) + + + Toggle ON GitHub Organization sync to activate sync. + ![toggle-on](../../images/platform/external-syncs/github-org-sync-active.png) + + + Connecting the Infisical OAuth application grants it permission to **read:org** details. This approval is done by selecting your organization during the GitHub OAuth login process. + + 1. Initiate the login process via the GitHub OAuth flow. + ![oauth-flow-start](../../images/platform/external-syncs/github-org-sync-oauth-flow-start.png) + 2. Select the organization you have connected. + 3. Grant access to Infisical oauth application to your configured organization. Infisical shown here is an organization, just for walkthrough. + ![grant-access](../../images/platform/external-syncs/github-org-sync-oauth.png) + + + This action only needs to be done once and authorizes the Infisical OAuth app to read organization details, including team information. + The following users don't need to select organization in GitHub on login anymore. + + + + + +## Working + +Once configured, the GitHub Organization Synchronization feature functions as follows: + +When a user logs in via the GitHub OAuth flow and selects the configured organization, the system will then automatically synchronize the teams they are a part of in GitHub with corresponding groups in Infisical. + +## Troubleshooting + + + If you encounter an error related to this, it indicates that you need to approve the Infisical OAuth application within your GitHub organization. + + You can verify the application's approval status by navigating to **https://github.com/organizations/__your-organization__/settings/oauth_application_policy**. Replace `__your-organization__` with the actual name of your GitHub organization. + + ![check-approval](../../images/platform/external-syncs/github-org-sync-approved-oauth-apps.png) + diff --git a/docs/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/aws-iam-user-secret.mdx b/docs/documentation/platform/secret-rotation/aws-iam-user-secret.mdx new file mode 100644 index 000000000..1e8eb3950 --- /dev/null +++ b/docs/documentation/platform/secret-rotation/aws-iam-user-secret.mdx @@ -0,0 +1,191 @@ +--- +title: "AWS IAM User" +description: "Learn how to automatically rotate Access Key Id and Secret Key of AWS IAM Users." +--- + +Infisical's AWS IAM User secret rotation capability lets you update the **Access key** and **Secret access key** credentials of a target IAM user from within Infisical +at a specified interval or on-demand. + +## Prerequisites + +- Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Rotation** permissions +- Make sure to add the following permissions to your IAM Role/IAM User Permission policy set used by your AWS Connection: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:ListAccessKeys", + "iam:CreateAccessKey", + "iam:UpdateAccessKey", + "iam:DeleteAccessKey", + "iam:ListUsers" + ], + "Resource": "*" + } + ] + } + ``` + +## Workflow + +The typical workflow for using the AWS IAM User rotation strategy consists of four steps: + +1. Creating the target IAM user whose credentials you wish to rotate. +2. Configuring the rotation strategy in Infisical with the credentials of the managing IAM user. +3. Pressing the **Rotate** button in the Infisical dashboard to trigger the rotation of the target IAM user's credentials. The strategy can also be configured to rotate the credentials automatically at a specified interval. + +In the following steps, we explore the end-to-end workflow for setting up this strategy in Infisical. + + + + To begin, create an IAM user whose credentials you wish to rotate. If you already have an IAM user, + then you can skip this step. + + + + + 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 **AWS IAM User Secret** option. + ![Select AWS IAM User Secret](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-option.png) + + 3. Select the **AWS Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-configuration.png) + + - **AWS Connection** - the connection that will perform the rotation of the specified application's Client Secret. + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. + + 4. Select the AWS IAM user and the region of the user whose credentials you want to rotate. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-parameters.png) + + 5. Specify the secret names that the AWS IAM access key credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-secrets-mapping.png) + + - **Access Key ID** - the name of the secret that the AWS access key ID will be mapped to. + - **Secret Access Key** - the name of the secret that the rotated secret access key will be mapped to. + + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-details.png) + + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. + + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-confirm.png) + + 8. Your **AWS IAM User** credentials are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-created.png) + + + To create an AWS IAM User Rotation, make an API request to the [Create AWS IAM User Rotation](/api-reference/endpoints/secret-rotations/aws-iam-user-secret/create) API endpoint. + + You will first need the **User Name** of the AWS IAM user you want to rotate the secret for. This can be obtained from the IAM console, on Users tab. + ![Users](/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-user-names.png) + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/aws-iam-user-secret \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-aws-rotation", + "projectId": "9602cfc5-20b9-4c35-a056-dd7372db0f25", + "description": "My rotation strategy description", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": true, + "rotationInterval": 2, + "rotateAtUtc": { + "hours": 11.5, + "minutes": 29.5 + }, + "parameters": { + "userName": "testUser", + "region": "us-east-1" + }, + "secretsMapping": { + "accessKeyId": "AWS_ACCESS_KEY_ID", + "secretAccessKey": "AWS_SECRET_ACCESS_KEY" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-aws-rotation", + "description": "My rotation strategy description", + "secretsMapping": { + "accessKeyId": "AWS_ACCESS_KEY_ID", + "secretAccessKey": "AWS_SECRET_ACCESS_KEY" + }, + "isAutoRotationEnabled": true, + "activeIndex": 0, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 123, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": null, + "nextRotationAt": "2023-11-07T05:31:56Z", + "isLastRotationManual": true, + "connection": { + "app": "aws", + "name": "my-aws-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "9602cfc5-20b9-4c35-a056-dd7372db0f25", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 11.5, + "minutes": 29.5 + }, + "lastRotationMessage": null, + "type": "aws-iam-user-secret", + "parameters": { + "userName": "testUser", + "region": "us-east-1" + } + } + } + ``` + + + + + +**FAQ** + + + + There are a few reasons for why this might happen: + - The strategy configuration is invalid (e.g. the managing IAM user's credentials are incorrect, the target AWS region is incorrect, etc.) + - The managing IAM user is insufficently permissioned to rotate the credentials of the target IAM user. For instance, you may have setup + [paths](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) for the managing IAM user and the policy does not have the necessary + permissions to rotate the credentials. + + diff --git a/docs/documentation/platform/secret-rotation/aws-iam.mdx b/docs/documentation/platform/secret-rotation/aws-iam.mdx deleted file mode 100644 index c524abfbc..000000000 --- a/docs/documentation/platform/secret-rotation/aws-iam.mdx +++ /dev/null @@ -1,143 +0,0 @@ ---- -title: "AWS IAM User" -description: "Learn how to automatically rotate Access Key Id and Secret Key of AWS IAM Users." ---- - -Infisical's AWS IAM User secret rotation capability lets you update the **Access key** and **Secret access key** credentials of a target IAM user from within Infisical -at a specified interval or on-demand. - -## Workflow - -The typical workflow for using the AWS IAM User rotation strategy consists of four steps: - -1. Creating the target IAM user whose credentials you wish to rotate. -2. Creating the managing IAM user used by Infisical to rotate the credentials of the target IAM user. -3. Configuring the rotation strategy in Infisical with the credentials of the managing IAM user. -4. Pressing the **Rotate** button in the Infisical dashboard to trigger the rotation of the target IAM user's credentials. The strategy can also be configured to rotate the credentials automatically at a specified interval. - -In the following steps, we explore the end-to-end workflow for setting up this strategy in Infisical. - - - - To begin, create an IAM user whose credentials you wish to rotate. If you already have an IAM user, - then you can skip this step. - - - Next, create another IAM user to be used by Infisical to rotate the credentials of the IAM user in the previous step. - - 2.1. In your AWS console, head to IAM > Access management > Users and press **Create user**. - - ![iam user secret rotation create user](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-user.png) - - 2.2. Next, give the user a username like **infisical-rotation-manager** and press **Next**. - - ![iam user secret rotation username](../../../images/platform/secret-rotation/aws-iam/rotation-manager-username.png) - - 2.3. Next, in the **Set permissions** step, select **Attach policies directly** and then press **Create policy**. - - ![iam user secret rotation create policy](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-policy.png) - - 2.4. Next, in the **Policy editor**, paste the following JSON and press **Next**: - - ```json - { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "VisualEditor0", - "Effect": "Allow", - "Action": [ - "iam:DeleteAccessKey", - "iam:GetAccessKeyLastUsed", - "iam:CreateAccessKey" - ], - "Resource": "*" - } - ] - } - ``` - - - The IAM policy above uses the wildcard option in Resource: "*". - - You may want to restrict the policy to a specific path, and make any adjustments as necessary, to control access for the managing user in production. - - Read more about this [here](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/). - - - In the **Review and create** step, give the policy a name like **infisical-rotation-manager**, press **Create policy** to finish creating the policy. - - ![iam user secret rotation policy review](../../../images/platform/secret-rotation/aws-iam/rotation-manager-policy-review.png) - - 2.5. Back in the **Set permissions** step from step 2.3, refresh the policy list and search for the policy you just created from step 2.4. - - Select the policy and press **Next**. - - ![iam user secret rotation attach policy](../../../images/platform/secret-rotation/aws-iam/rotation-manager-attach-policy.png) - - In the **Review and create** step, press **Create user** to finish creating the IAM user. - - ![iam user secret rotation manager user review](../../../images/platform/secret-rotation/aws-iam/rotation-manager-user-review.png) - - 2.5. Having created the user, head to its Security credentials > Access keys and press **Create access key**. - - Follow the subsequent steps to create the **access key** and **secret access key** credential pair for the user. - - ![iam user secret rotation manager create access key](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-access-key.png) - - At the end of the flow, copy the **Access key** and **Secret access key** to use when configuring the AWS IAM User rotation strategy back in Infisical next. - - ![iam user secret rotation manager access keys](../../../images/platform/secret-rotation/aws-iam/rotation-manager-access-keys.png) - - - 3.1. Back in Infisical, head to the Project > Secrets > Environment and path where you want the rotated AWS IAM credentials to appear and create two placeholder secrets. - - In this example, we'll create two secrets called `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY`. - - ![iam user secret rotation secrets](../../../images/platform/secret-rotation/aws-iam/rotation-config-secrets.png) - - 3.2. Next, in the **Secret Rotation** tab, press on the **AWS IAM** tile to configure the AWS IAM User rotation strategy. - - ![iam user secret rotation select aws iam user method](../../../images/platform/secret-rotation/aws-iam/rotations-select-aws-iam-user.png) - - 3.3. Input the configuration details for the AWS IAM User rotation strategy obtained from steps 1 and 2: - - ![iam user secret rotation config 1](../../../images/platform/secret-rotation/aws-iam/rotation-config-1.png) - - Here's some guidance on each field: - - - Manager User Access Key: The managing IAM user's access key from step 2.5. - - Manager User Secret Key: The managing IAM user's secret access key from step 2.5. - - Manager User AWS Region: The [AWS region](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html) for Infisical to make requests to such as `us-east-1`. - - IAM Username: The IAM username of the user from step 1. - - Next, specify the output secret mappings configuration for the rotated AWS IAM credentials; this is the secrets whose values will be replaced with new credentials after each rotation. - Here, you can also specify a rotation interval for the credentials to be automatically rotated periodically. - - In this example, we want to map the output of the rotated AWS IAM credentials to the secrets that we created in step 3.1 (i.e. `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY`). - - ![iam user secret rotation config 2](../../../images/platform/secret-rotation/aws-iam/rotation-config-2.png) - - Finally, press **Submit** to create the secret rotation strategy. - - - You should now see the AWS IAM User rotation strategy listed in the **Secret Rotation** tab. - - To manually trigger a rotation, you can press the **Rotate** button on the strategy. - Once triggered, the secrets in step 3.1 should be updated with new rotated credential values. - - ![iam user secret rotations aws iam user](../../../images/platform/secret-rotation/aws-iam/rotations-aws-iam-user.png) - - - -**FAQ** - - - - There are a few reasons for why this might happen: - - - The strategy configuration is invalid (e.g. the managing IAM user's credentials are incorrect, the target IAM username is incorrect, etc.). - - The managing IAM user is insufficently permissioned to rotate the credentials of the target IAM user. For instance, you may have setup [paths](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) for the managing IAM user and the policy does not have the necessary permissions to rotate the credentials. - - The target IAM user already has 2 access keys configured in AWS; you should delete one of the access keys to allow for rotation. - - \ No newline at end of file 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/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx index ae1e43df5..2bc433f75 100644 --- a/docs/documentation/platform/ssh.mdx +++ b/docs/documentation/platform/ssh.mdx @@ -10,10 +10,10 @@ Infisical SSH can be configured to provide users on your team short-lived, secur and improves upon traditional SSH key-based authentication by mitigating private key compromise, static key management, unauthorized access, and SSH key sprawl. -The following entities and concepts are important to understand when using Infisical SSH: +The following entities are important to understand when configuring and using Infisical SSH: - Administrator: An individual on your team who is responsible for configuring Infisical SSH. -- Users: Other individuals on your team that need access to the remote host. +- Users: Other individuals that gain access to remote hosts through Infisical SSH. - Host: A remote machine (e.g. EC2 instance, GCP VM, Azure VM, on-prem Linux server, Raspberry Pi, VMware VM, etc.) that users need SSH access to that is registered with Infisical SSH. ## Workflow @@ -72,7 +72,7 @@ we will register a remote host with Infisical through a [machine identity](/docu Next, use the `infisical ssh add-host` command to register the remote host with Infisical. As part of this command, input the ID of the Infisical SSH project you created in step 1 for the `--projectId` flag and the hostname of the remote host for the `--hostname` flag. ```bash - sudo infisical ssh add-host --projectId= --hostname= --token="$INFISICAL_TOKEN" --writeUserCaToFile --writeHostCertToFile --configureSshd + sudo infisical ssh add-host --projectId= --hostname= --token="$INFISICAL_TOKEN" --write-user-ca-to-file --write-host-cert-to-file --configure-sshd ``` @@ -136,44 +136,66 @@ Once Infisical SSH is configured by an administrator, users can SSH to the remot Follow the instructions [here](/cli/overview) to install the Infisical CLI onto your local machine. - - Run the `infisical login` command to authenticate with Infisical. - - ```bash - infisical login - ``` - - Run the `infisical ssh connect` command to connect to a remote host. + The `infisical ssh connect` command can be used in either interactive or non-interactive mode to connect to a remote host. - ```bash - infisical ssh connect - ``` + + + In interactive mode, you'll first need to authenticate with Infisical by running: - You'll be prompted to select an SSH Host from a list of accessible hosts; this is based on project membership and login mappings configured on hosts by - the administrator. + ```bash + infisical login + ``` - ```bash - Use the arrow keys to navigate: ↓ ↑ → ← - ? Select an SSH Host: - ▸ ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com - ``` + Then simply run: - After selecting a host, you'll be prompted to select a login user from a list of allowed login users: + ```bash + infisical ssh connect + ``` - ```bash - ? Select Login User: - ▸ ec2-user - ``` + You'll be prompted to select an SSH Host from a list of accessible hosts; this is based on project membership and login mappings configured on hosts by + the administrator. - If successful, you should be able to SSH to the remote host. + ```bash + Use the arrow keys to navigate: ↓ ↑ → ← + ? Select an SSH Host: + ▸ ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com + ``` - ```bash - ✔ ec2-54-199-104-116.ap-northeast-1.compute.amazonaws.com - ✔ ec2-user - ✔ SSH credentials successfully added to agent - Connecting to ec2-user@ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com... - ``` + After selecting a host, you'll be prompted to select a login user from a list of allowed login users: + + ```bash + ? Select Login User: + ▸ ec2-user + ``` + + If successful, you should be able to SSH to the remote host. + + ```bash + ✔ ec2-54-199-104-116.ap-northeast-1.compute.amazonaws.com + ✔ ec2-user + ✔ SSH credentials successfully added to agent + Connecting to ec2-user@ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com... + ``` + + + For CI/CD pipelines or automation scenarios, you can use the non-interactive mode with an Infisical token: + + ```bash + infisical ssh connect \ + --hostname ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com \ + --login-user ec2-user \ + --out-file-path ~/.ssh/id_rsa-cert.pub \ + --token + ``` + + This will: + - Connect to the specified hostname + - Use the specified login user + - Write the SSH credentials to the specified path instead of adding them to the SSH agent + - Authenticate using the provided Infisical token + + diff --git a/docs/images/app-connections/aws/iam-role-secret-rotation-permissions.png b/docs/images/app-connections/aws/iam-role-secret-rotation-permissions.png new file mode 100644 index 000000000..6d99922f8 Binary files /dev/null and b/docs/images/app-connections/aws/iam-role-secret-rotation-permissions.png differ diff --git a/docs/images/app-connections/general/add-connection.png b/docs/images/app-connections/general/add-connection.png index 97718065a..ad9d54716 100644 Binary files a/docs/images/app-connections/general/add-connection.png and b/docs/images/app-connections/general/add-connection.png differ 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/app-connections/teamcity/teamcity-app-connection-created.png b/docs/images/app-connections/teamcity/teamcity-app-connection-created.png new file mode 100644 index 000000000..698894139 Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-app-connection-created.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-app-connection-modal.png b/docs/images/app-connections/teamcity/teamcity-app-connection-modal.png new file mode 100644 index 000000000..9e602dc1b Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-app-connection-modal.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-app-connection-option.png b/docs/images/app-connections/teamcity/teamcity-app-connection-option.png new file mode 100644 index 000000000..52d001126 Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-app-connection-option.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-main-page.png b/docs/images/app-connections/teamcity/teamcity-main-page.png new file mode 100644 index 000000000..4ee08d774 Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-main-page.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-token-copy.png b/docs/images/app-connections/teamcity/teamcity-token-copy.png new file mode 100644 index 000000000..1aa363104 Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-token-copy.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-token-created.png b/docs/images/app-connections/teamcity/teamcity-token-created.png new file mode 100644 index 000000000..c909f4107 Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-token-created.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-token-page.png b/docs/images/app-connections/teamcity/teamcity-token-page.png new file mode 100644 index 000000000..1730a99ef Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-token-page.png differ diff --git a/docs/images/app-connections/teamcity/teamcity-token-popup.png b/docs/images/app-connections/teamcity/teamcity-token-popup.png new file mode 100644 index 000000000..0e18d37ea Binary files /dev/null and b/docs/images/app-connections/teamcity/teamcity-token-popup.png differ diff --git a/docs/images/platform/access-controls/assume-privileges/access-control-detail.png b/docs/images/platform/access-controls/assume-privileges/access-control-detail.png new file mode 100644 index 000000000..e0844b8f4 Binary files /dev/null and b/docs/images/platform/access-controls/assume-privileges/access-control-detail.png differ diff --git a/docs/images/platform/access-controls/assume-privileges/access-control.png b/docs/images/platform/access-controls/assume-privileges/access-control.png new file mode 100644 index 000000000..aa6974cdd Binary files /dev/null and b/docs/images/platform/access-controls/assume-privileges/access-control.png differ diff --git a/docs/images/platform/access-controls/assume-privileges/session-start.png b/docs/images/platform/access-controls/assume-privileges/session-start.png new file mode 100644 index 000000000..1aab112c4 Binary files /dev/null and b/docs/images/platform/access-controls/assume-privileges/session-start.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-active.png b/docs/images/platform/external-syncs/github-org-sync-active.png new file mode 100644 index 000000000..bb5ce1ca3 Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-active.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-approved-oauth-apps.png b/docs/images/platform/external-syncs/github-org-sync-approved-oauth-apps.png new file mode 100644 index 000000000..d65d5a43f Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-approved-oauth-apps.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-config-modal.png b/docs/images/platform/external-syncs/github-org-sync-config-modal.png new file mode 100644 index 000000000..b856048e3 Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-config-modal.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-oauth-flow-start.png b/docs/images/platform/external-syncs/github-org-sync-oauth-flow-start.png new file mode 100644 index 000000000..9810e3ddf Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-oauth-flow-start.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-oauth.png b/docs/images/platform/external-syncs/github-org-sync-oauth.png new file mode 100644 index 000000000..68b13c3a7 Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-oauth.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-section.png b/docs/images/platform/external-syncs/github-org-sync-section.png new file mode 100644 index 000000000..dad1fa425 Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-section.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-configuration.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-configuration.png new file mode 100644 index 000000000..0e530600b Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-configuration.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-confirm.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-confirm.png new file mode 100644 index 000000000..545e8625a Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-confirm.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-created.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-created.png new file mode 100644 index 000000000..51f28107e Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-created.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-details.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-details.png new file mode 100644 index 000000000..272c93958 Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-details.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-option.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-option.png new file mode 100644 index 000000000..92fcc22cd Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-option.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-parameters.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-parameters.png new file mode 100644 index 000000000..1ccfa4d6c Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-parameters.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-secrets-mapping.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-secrets-mapping.png new file mode 100644 index 000000000..83d7157dd Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-user-names.png b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-user-names.png new file mode 100644 index 000000000..b8fa47ae3 Binary files /dev/null and b/docs/images/secret-rotations-v2/aws-iam-user-secret/aws-iam-user-secret-user-names.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/images/secret-syncs/general/secret-sync-tab.png b/docs/images/secret-syncs/general/secret-sync-tab.png index dad8c2426..5317fabf0 100644 Binary files a/docs/images/secret-syncs/general/secret-sync-tab.png and b/docs/images/secret-syncs/general/secret-sync-tab.png differ diff --git a/docs/images/secret-syncs/teamcity/select-teamcity-option.png b/docs/images/secret-syncs/teamcity/select-teamcity-option.png new file mode 100644 index 000000000..261f53163 Binary files /dev/null and b/docs/images/secret-syncs/teamcity/select-teamcity-option.png differ diff --git a/docs/images/secret-syncs/teamcity/teamcity-sync-created.png b/docs/images/secret-syncs/teamcity/teamcity-sync-created.png new file mode 100644 index 000000000..871b94db9 Binary files /dev/null and b/docs/images/secret-syncs/teamcity/teamcity-sync-created.png differ diff --git a/docs/images/secret-syncs/teamcity/teamcity-sync-destination.png b/docs/images/secret-syncs/teamcity/teamcity-sync-destination.png new file mode 100644 index 000000000..bc546fc3b Binary files /dev/null and b/docs/images/secret-syncs/teamcity/teamcity-sync-destination.png differ diff --git a/docs/images/secret-syncs/teamcity/teamcity-sync-details.png b/docs/images/secret-syncs/teamcity/teamcity-sync-details.png new file mode 100644 index 000000000..02b890926 Binary files /dev/null and b/docs/images/secret-syncs/teamcity/teamcity-sync-details.png differ diff --git a/docs/images/secret-syncs/teamcity/teamcity-sync-options.png b/docs/images/secret-syncs/teamcity/teamcity-sync-options.png new file mode 100644 index 000000000..d9ef23fb0 Binary files /dev/null and b/docs/images/secret-syncs/teamcity/teamcity-sync-options.png differ diff --git a/docs/images/secret-syncs/teamcity/teamcity-sync-review.png b/docs/images/secret-syncs/teamcity/teamcity-sync-review.png new file mode 100644 index 000000000..753b7ba17 Binary files /dev/null and b/docs/images/secret-syncs/teamcity/teamcity-sync-review.png differ diff --git a/docs/images/secret-syncs/teamcity/teamcity-sync-source.png b/docs/images/secret-syncs/teamcity/teamcity-sync-source.png new file mode 100644 index 000000000..dc69e1db8 Binary files /dev/null and b/docs/images/secret-syncs/teamcity/teamcity-sync-source.png differ diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx index 4944b0c34..195f98247 100644 --- a/docs/integrations/app-connections/aws.mdx +++ b/docs/integrations/app-connections/aws.mdx @@ -146,6 +146,34 @@ Infisical supports two methods for connecting to AWS. + + + + Use the following custom policy to grant the minimum permissions required by Infisical to rotate secrets to AWS Access Keys: + + ![IAM Role Secret Rotation Permissions](/images/app-connections/aws/iam-role-secret-rotation-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:ListAccessKeys", + "iam:CreateAccessKey", + "iam:UpdateAccessKey", + "iam:DeleteAccessKey", + "iam:ListUsers" + ], + "Resource": "*" + } + ] + } + ``` + + + @@ -293,6 +321,34 @@ Infisical supports two methods for connecting to AWS. + + + + Use the following custom policy to grant the minimum permissions required by Infisical to rotate secrets to AWS Access Keys: + + ![IAM Role Secret Rotation Permissions](/images/app-connections/aws/iam-role-secret-rotation-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:ListAccessKeys", + "iam:CreateAccessKey", + "iam:UpdateAccessKey", + "iam:DeleteAccessKey", + "iam:ListUsers" + ], + "Resource": "*" + } + ] + } + ``` + + + 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/integrations/app-connections/teamcity.mdx b/docs/integrations/app-connections/teamcity.mdx new file mode 100644 index 000000000..1ffafe637 --- /dev/null +++ b/docs/integrations/app-connections/teamcity.mdx @@ -0,0 +1,119 @@ +--- +title: "TeamCity Connection" +description: "Learn how to configure a TeamCity Connection for Infisical." +--- + +Infisical supports connecting to TeamCity using an Access Token to securely sync your secrets to TeamCity. + +## Setup TeamCity Connection in Infisical + + + + Navigate to the TeamCity **Profile** page by clicking on your profile icon in the bottom-left corner. + ![TeamCity Main Page](/images/app-connections/teamcity/teamcity-main-page.png) + + + Select the **Access Tokens** tab from the left sidebar navigation menu. + ![TeamCity Token Page](/images/app-connections/teamcity/teamcity-token-page.png) + + + Click the **Create access token** button and provide a name for your token (e.g., "Infisical Integration"). You may set an expiration date or leave it blank for no expiry. + The permission scope can either be **Same as current user** or **Limit per project**. + + If you're choosing **Limit per project**, make sure you select the relevant project and enable the permissions relevant to your use case: + + + + - View build configuration settings + - Edit project + + + + ![TeamCity Token Popup](/images/app-connections/teamcity/teamcity-token-popup.png) + + + Setting your permission scope to **Same as current user** will allow your integration to access multiple projects as long as the current user has read and write access to them. + + + If you configure an expiry date for your access token, you must manually rotate to a new token before the expiration date to prevent service interruption. + + + + After creation, a modal with the Access Token will be displayed. Copy this token immediately and store it securely, as you won't be able to view it again after closing this dialog. + ![TeamCity Token Copy Popup](/images/app-connections/teamcity/teamcity-token-copy.png) + + + You should now see your newly created token in the list of access tokens. + ![TeamCity Token Created](/images/app-connections/teamcity/teamcity-token-created.png) + + + + + 1. Navigate to App Connections + + In your Infisical dashboard, go to **Organization Settings** and select the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + 2. Add Connection + + Click the **+ Add Connection** button and select the **TeamCity Connection** option from the available integrations. + ![Select TeamCity Connection](/images/app-connections/teamcity/teamcity-app-connection-option.png) + 3. Fill the TeamCity Connection Modal + + Complete the TeamCity Connection form by entering: + - A descriptive name for the connection + - The Access Token you generated in steps 3-4 + - The URL of your TeamCity instance + - An optional description for future reference + + ![TeamCity Connection Modal](/images/app-connections/teamcity/teamcity-app-connection-modal.png) + 4. Connection Created + + After clicking Create, your **TeamCity Connection** is established and ready to use with your Infisical projects. + ![TeamCity Connection Created](/images/app-connections/teamcity/teamcity-app-connection-created.png) + + + To create a TeamCity Connection, make an API request to the [Create TeamCity + Connection](/api-reference/endpoints/app-connections/teamcity/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/teamcity \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-teamcity-connection", + "method": "access-token", + "credentials": { + "accessToken": "...", + "instanceUrl": "https://yourcompany.teamcity.com" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-teamcity-connection", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2025-04-23T19:46:34.831Z", + "updatedAt": "2025-04-23T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "7c2d371dec195f82a6a0d5b41c970a229cfcaf88e894a5b6395e2dbd0280661f", + "app": "teamcity", + "method": "access-token", + "credentials": { + "instanceUrl": "https://yourcompany.teamcity.com" + } + } + } + ``` + + + + diff --git a/docs/integrations/cloud/teamcity.mdx b/docs/integrations/cloud/teamcity.mdx index 3e713cc6a..0b58f1b80 100644 --- a/docs/integrations/cloud/teamcity.mdx +++ b/docs/integrations/cloud/teamcity.mdx @@ -3,43 +3,6 @@ title: "TeamCity" description: "How to sync secrets from Infisical to TeamCity" --- -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a TeamCity Access Token in Profile > Access Tokens - - ![integrations teamcity dashboard](../../images/integrations/teamcity/integrations-teamcity-dashboard.png) - ![integrations teamcity token](../../images/integrations/teamcity/integrations-teamcity-token.png) - - - For this integration to work, the TeamCity Access Token must either have the - **Same as current user** account-wide permission enabled or, if **Limit per project** - is selected, then it must at minimum have the **View build configuration settings** and **Edit project** permissions enabled. - - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the TeamCity tile and input your TeamCity Access Token and Server URL to grant Infisical access to your TeamCity account. - - ![integrations teamcity authorization](../../images/integrations/teamcity/integrations-teamcity-auth.png) - - - - Select which Infisical environment secrets you want to sync to which TeamCity project (and optionally build configuration) and press create integration to start syncing secrets to TeamCity. - - ![integrations teamcity](../../images/integrations/teamcity/integrations-teamcity-create.png) - - - Infisical integrates with both TeamCity's project-level and build configuration-level environment variables. - - To sync secrets to a specific build configuration in a TeamCity project, you can select a build configuration from the **TeamCity Build Config** dropdown; otherwise, leaving it empty will sync secrets to TeamCity at the project-level. - - - ![integrations teamcity](../../images/integrations/teamcity/integrations-teamcity.png) - - + + The TeamCity Native Integration will be deprecated in 2026. Please migrate to our new [TeamCity Sync](../secret-syncs/teamcity). + diff --git a/docs/integrations/cloud/windmill.mdx b/docs/integrations/cloud/windmill.mdx index d0b2b9643..7d4c2cc82 100644 --- a/docs/integrations/cloud/windmill.mdx +++ b/docs/integrations/cloud/windmill.mdx @@ -3,39 +3,6 @@ title: "Windmill" description: "How to sync secrets from Infisical to Windmill" --- -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a [Windmill](https://www.windmill.dev/) access token in Access Tokens - - ![integrations windmill dashboard](../../images/integrations/windmill/integrations-windmill-dashboard.png) - ![integrations windmill token](../../images/integrations/windmill/integrations-windmill-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Windmill tile and input your Windmill access token to grant Infisical access to your Windmill account. - - ![integrations windmill authorization](../../images/integrations/windmill/integrations-windmill-auth.png) - - - - Select which Infisical environment secrets you want to sync to which Windmill workspace and press create integration to start syncing secrets to Windmill. - - ![integrations windmill](../../images/integrations/windmill/integrations-windmill-create.png) - ![integrations windmill](../../images/integrations/windmill/integrations-windmill.png) - - - Secrets synced to Windmill are subject to the [ownership path - prefix](https://www.windmill.dev/docs/core_concepts/roles_and_permissions) - convention of Windmill. Accordingly, all secrets must be prefixed with either - `u/` or `f/` for user-based and folder-based secret along with the name of the - secret. Put differently, you must use the full path of the secret as its name - in Infisical to be considered valid such as `u/user/FOO/BAR`. - - - \ No newline at end of file + + The Windmill Native Integration will be deprecated in 2026. Please migrate to our new [Windmill Sync](../secret-syncs/windmill). + diff --git a/docs/integrations/secret-syncs/teamcity.mdx b/docs/integrations/secret-syncs/teamcity.mdx new file mode 100644 index 000000000..af4c8d76a --- /dev/null +++ b/docs/integrations/secret-syncs/teamcity.mdx @@ -0,0 +1,152 @@ +--- +title: "TeamCity Sync" +description: "Learn how to configure a TeamCity Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [TeamCity Connection](/integrations/app-connections/teamcity) with the required **Secret Sync** permissions + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **TeamCity** option. + ![Select TeamCity](/images/secret-syncs/teamcity/select-teamcity-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/teamcity/teamcity-sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/teamcity/teamcity-sync-destination.png) + + - **TeamCity Connection**: The TeamCity Connection to authenticate with. + - **Project**: The TeamCity project to sync secrets to. + - **Build Configuration**: The build configuration to sync secrets to. + + + Not including a Build Configuration will sync secrets to the project. + + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/teamcity/teamcity-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over TeamCity when keys conflict. + - **Import Secrets (Prioritize TeamCity)**: Imports secrets from the destination endpoint before syncing, prioritizing values from TeamCity over Infisical when keys conflict. + + + Infisical only syncs secrets from within the target scope; inherited secrets will not be imported. + + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your TeamCity Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/teamcity/teamcity-sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your TeamCity Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/teamcity/teamcity-sync-review.png) + + 8. If enabled, your TeamCity Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/teamcity/teamcity-sync-created.png) + + + + To create a **TeamCity Sync**, make an API request to the [Create TeamCity Sync](/api-reference/endpoints/secret-syncs/teamcity/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/teamcity \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-teamcity-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "project": "TestProject", + "buildConfig": "TestBuildConfig" + } + }' + ``` + + + The **Project** and **Build Config** parameters must use project and build configuration IDs, not their names. + + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-teamcity-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "teamcity", + "name": "my-teamcity-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "teamcity", + "destinationConfig": { + "project": "TestProject", + "buildConfig": "TestBuildConfig" + } + } + } + ``` + + diff --git a/docs/mint.json b/docs/mint.json index b5523747f..9753d6a68 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -160,6 +160,7 @@ }, "documentation/platform/access-controls/additional-privileges", "documentation/platform/access-controls/temporary-access", + "documentation/platform/access-controls/assume-privilege", "documentation/platform/access-controls/access-requests", "documentation/platform/access-controls/project-access-requests", "documentation/platform/pr-workflows", @@ -181,7 +182,11 @@ "documentation/platform/secret-rotation/auth0-client-secret", "documentation/platform/secret-rotation/azure-client-secret", "documentation/platform/secret-rotation/postgres-credentials", - "documentation/platform/secret-rotation/mssql-credentials" + "documentation/platform/secret-rotation/mssql-credentials", + "documentation/platform/secret-rotation/aws-iam-user-secret", + "documentation/platform/secret-rotation/ldap-password", + "documentation/platform/secret-rotation/mssql-credentials", + "documentation/platform/secret-rotation/postgres-credentials" ] }, { @@ -298,7 +303,8 @@ "documentation/platform/scim/jumpcloud", "documentation/platform/scim/group-mappings" ] - } + }, + "documentation/platform/github-org-sync" ] }, { @@ -434,8 +440,10 @@ "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", "integrations/app-connections/terraform-cloud", "integrations/app-connections/vercel", "integrations/app-connections/windmill" @@ -459,6 +467,7 @@ "integrations/secret-syncs/gcp-secret-manager", "integrations/secret-syncs/github", "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/teamcity", "integrations/secret-syncs/terraform-cloud", "integrations/secret-syncs/vercel", "integrations/secret-syncs/windmill" @@ -568,9 +577,7 @@ }, { "group": "Others", - "pages": [ - "integrations/external/backstage" - ] + "pages": ["integrations/external/backstage"] }, { "group": "", @@ -884,6 +891,32 @@ "api-reference/endpoints/secret-rotations/azure-client-secret/update" ] }, + { + "group": "AWS IAM User Secret", + "pages": [ + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/create", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/delete", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-id", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-name", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/list", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/rotate-secrets", + "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": [ @@ -1048,6 +1081,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": [ @@ -1072,6 +1117,18 @@ "api-reference/endpoints/app-connections/postgres/delete" ] }, + { + "group": "TeamCity", + "pages": [ + "api-reference/endpoints/app-connections/teamcity/list", + "api-reference/endpoints/app-connections/teamcity/available", + "api-reference/endpoints/app-connections/teamcity/get-by-id", + "api-reference/endpoints/app-connections/teamcity/get-by-name", + "api-reference/endpoints/app-connections/teamcity/create", + "api-reference/endpoints/app-connections/teamcity/update", + "api-reference/endpoints/app-connections/teamcity/delete" + ] + }, { "group": "Terraform Cloud", "pages": [ @@ -1237,6 +1294,20 @@ "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] }, + { + "group": "TeamCity", + "pages": [ + "api-reference/endpoints/secret-syncs/teamcity/list", + "api-reference/endpoints/secret-syncs/teamcity/get-by-id", + "api-reference/endpoints/secret-syncs/teamcity/get-by-name", + "api-reference/endpoints/secret-syncs/teamcity/create", + "api-reference/endpoints/secret-syncs/teamcity/update", + "api-reference/endpoints/secret-syncs/teamcity/delete", + "api-reference/endpoints/secret-syncs/teamcity/sync-secrets", + "api-reference/endpoints/secret-syncs/teamcity/import-secrets", + "api-reference/endpoints/secret-syncs/teamcity/remove-secrets" + ] + }, { "group": "Terraform Cloud", "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/ViewAwsIamUserSecretRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAwsIamUserSecretRotationGeneratedCredentials.tsx new file mode 100644 index 000000000..b07a615ff --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAwsIamUserSecretRotationGeneratedCredentials.tsx @@ -0,0 +1,42 @@ +import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay"; +import { TAwsIamUserSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation"; + +import { ViewRotationGeneratedCredentialsDisplay } from "./shared"; + +type Props = { + generatedCredentialsResponse: TAwsIamUserSecretRotationGeneratedCredentialsResponse; +}; + +export const ViewAwsIamUserSecretRotationGeneratedCredentials = ({ + generatedCredentialsResponse: { generatedCredentials, activeIndex } +}: Props) => { + const inactiveIndex = activeIndex === 0 ? 1 : 0; + + const activeCredentials = generatedCredentials[activeIndex]; + const inactiveCredentials = generatedCredentials[inactiveIndex]; + + return ( + + + {activeCredentials?.accessKeyId} + + + {activeCredentials?.secretAccessKey} + + + } + inactiveCredentials={ + <> + + {inactiveCredentials?.accessKeyId} + + + {inactiveCredentials?.secretAccessKey} + + + } + /> + ); +}; 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 bc69ec20b..a8a00e17f 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -5,8 +5,15 @@ import { format } from "date-fns"; import { ViewAuth0ClientSecretRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAuth0ClientSecretRotationGeneratedCredentials"; import { ViewAzureClientSecretRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewAzureClientSecretRotationGeneratedCredentials"; +import { ViewLdapPasswordRotationGeneratedCredentials } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewLdapPasswordRotationGeneratedCredentials"; import { Modal, ModalContent, Spinner } from "@app/components/v2"; -import { 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, @@ -14,6 +21,7 @@ import { } from "@app/hooks/api/secretRotationsV2"; import { ViewSqlCredentialsRotationGeneratedCredentials } from "./shared"; +import { ViewAwsIamUserSecretRotationGeneratedCredentials } from "./ViewAwsIamUserSecretRotationGeneratedCredentials"; type Props = { secretRotation?: TSecretRotationV2; @@ -75,13 +83,46 @@ const Content = ({ secretRotation }: ContentProps) => { /> ); break; + case SecretRotation.LdapPassword: + Component = ( + + ); + break; + case SecretRotation.AwsIamUserSecret: + Component = ( + + ); + break; default: 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/AwsIamUserSecretRotationParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/AwsIamUserSecretRotationParametersFields.tsx new file mode 100644 index 000000000..525bde198 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/AwsIamUserSecretRotationParametersFields.tsx @@ -0,0 +1,84 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { AwsRegionSelect } from "@app/components/secret-syncs/forms/SecretSyncDestinationFields/shared"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { TAwsIamUserSecret, useListAwsConnectionIamUsers } from "@app/hooks/api/appConnections/aws"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +export const AwsIamUserSecretRotationParametersFields = () => { + const { control, watch } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AwsIamUserSecret; + } + >(); + + const connectionId = watch("connection.id"); + + const { data: clients, isPending: isClientsPending } = useListAwsConnectionIamUsers({ + connectionId + }); + + return ( + <> + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the IAM user you're looking for?{" "} + +
+ + } + > + client.UserName === value) ?? ""} + onChange={(option) => { + onChange((option as SingleValue)?.UserName ?? ""); + }} + options={clients} + placeholder="Select an IAM user..." + getOptionLabel={(option) => + (option as SingleValue)?.UserName ?? "" + } + getOptionValue={(option) => + (option as SingleValue)?.UserName ?? "" + } + /> +
+ )} + /> + ( + + + + )} + /> + + ); +}; 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 fc33de2bb..a871eb54f 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx @@ -4,14 +4,18 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2Form } from "../schemas"; import { Auth0ClientSecretRotationParametersFields } from "./Auth0ClientSecretRotationParametersFields"; +import { AwsIamUserSecretRotationParametersFields } from "./AwsIamUserSecretRotationParametersFields"; import { AzureClientSecretRotationParametersFields } from "./AzureClientSecretRotationParametersFields"; +import { LdapPasswordRotationParametersFields } from "./LdapPasswordRotationParametersFields"; import { SqlCredentialsRotationParametersFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationParametersFields, - [SecretRotation.AzureClientSecret]: AzureClientSecretRotationParametersFields + [SecretRotation.AzureClientSecret]: AzureClientSecretRotationParametersFields, + [SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields, + [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationParametersFields }; export const SecretRotationV2ParametersFields = () => { diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AwsIamUserSecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AwsIamUserSecretRotationReviewFields.tsx new file mode 100644 index 000000000..d84c0753f --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/AwsIamUserSecretRotationReviewFields.tsx @@ -0,0 +1,30 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +import { SecretRotationReviewSection } from "./shared"; + +export const AwsIamUserSecretRotationReviewFields = () => { + const { watch } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AwsIamUserSecret; + } + >(); + + const [parameters, { accessKeyId, secretAccessKey }] = watch(["parameters", "secretsMapping"]); + + return ( + <> + + {parameters.region} + {parameters.userName} + + + {accessKeyId} + {secretAccessKey} + + + ); +}; 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 60bdd32fe..2bfdc16fd 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx @@ -7,14 +7,18 @@ import { getRotateAtLocal } from "@app/helpers/secretRotationsV2"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { Auth0ClientSecretRotationReviewFields } from "./Auth0ClientSecretRotationReviewFields"; +import { AwsIamUserSecretRotationReviewFields } from "./AwsIamUserSecretRotationReviewFields"; import { AzureClientSecretRotationReviewFields } from "./AzureClientSecretRotationReviewFields"; +import { LdapPasswordRotationReviewFields } from "./LdapPasswordRotationReviewFields"; import { SqlCredentialsRotationReviewFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationReviewFields, - [SecretRotation.AzureClientSecret]: AzureClientSecretRotationReviewFields + [SecretRotation.AzureClientSecret]: AzureClientSecretRotationReviewFields, + [SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields, + [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationReviewFields }; export const SecretRotationV2ReviewFields = () => { diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AwsIamUserSecretRotationSecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AwsIamUserSecretRotationSecretsMappingFields.tsx new file mode 100644 index 000000000..2c6432122 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/AwsIamUserSecretRotationSecretsMappingFields.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 AwsIamUserSecretRotationSecretsMappingFields = () => { + const { control } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.AwsIamUserSecret; + } + >(); + + const { rotationOption } = useSecretRotationV2Option(SecretRotation.AwsIamUserSecret); + + const items = [ + { + name: "Access Key ID", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.accessKeyId" + /> + ) + }, + { + name: "Secret Access Key", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.secretAccessKey" + /> + ) + } + ]; + + return ; +}; 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 0c2213557..9da51272b 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx @@ -4,14 +4,18 @@ import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2Form } from "../schemas"; import { Auth0ClientSecretRotationSecretsMappingFields } from "./Auth0ClientSecretRotationSecretsMappingFields"; +import { AwsIamUserSecretRotationSecretsMappingFields } from "./AwsIamUserSecretRotationSecretsMappingFields"; import { AzureClientSecretRotationSecretsMappingFields } from "./AzureClientSecretRotationSecretsMappingFields"; +import { LdapPasswordRotationSecretsMappingFields } from "./LdapPasswordRotationSecretsMappingFields"; import { SqlCredentialsRotationSecretsMappingFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationSecretsMappingFields, - [SecretRotation.AzureClientSecret]: AzureClientSecretRotationSecretsMappingFields + [SecretRotation.AzureClientSecret]: AzureClientSecretRotationSecretsMappingFields, + [SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields, + [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationSecretsMappingFields }; export const SecretRotationV2SecretsMappingFields = () => { diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema.ts new file mode 100644 index 000000000..a8ead3bed --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +export const AwsIamUserSecretRotationSchema = z + .object({ + type: z.literal(SecretRotation.AwsIamUserSecret), + parameters: z.object({ + userName: z.string().trim().min(1, "User Name required"), + region: z.string().trim().optional() + }), + secretsMapping: z.object({ + accessKeyId: z.string().trim().min(1, "Access Key ID required"), + secretAccessKey: z.string().trim().min(1, "Secret Access Key required") + }) + }) + .merge(BaseSecretRotationSchema); diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts index 794377810..b0484ae67 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts @@ -1,7 +1,9 @@ import { z } from "zod"; import { Auth0ClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/auth0-client-secret-rotation-schema"; +import { AwsIamUserSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema"; import { AzureClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema"; +import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema"; import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema"; import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema"; @@ -9,7 +11,9 @@ const SecretRotationUnionSchema = z.discriminatedUnion("type", [ Auth0ClientSecretRotationSchema, AzureClientSecretRotationSchema, PostgresCredentialsRotationSchema, - MsSqlCredentialsRotationSchema + MsSqlCredentialsRotationSchema, + LdapPasswordRotationSchema, + AwsIamUserSecretRotationSchema ]); export const SecretRotationV2FormSchema = SecretRotationUnionSchema; 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/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index b2008b4f6..47afc6f04 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -12,6 +12,7 @@ import { DatabricksSyncFields } from "./DatabricksSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; +import { TeamCitySyncFields } from "./TeamCitySyncFields"; import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; import { VercelSyncFields } from "./VercelSyncFields"; import { WindmillSyncFields } from "./WindmillSyncFields"; @@ -46,6 +47,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Windmill: return ; + case SecretSync.TeamCity: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TeamCitySyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TeamCitySyncFields.tsx new file mode 100644 index 000000000..4f2718089 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TeamCitySyncFields.tsx @@ -0,0 +1,128 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { + TTeamCityProjectWithBuildTypes, + useTeamCityConnectionListProjects +} from "@app/hooks/api/appConnections/teamcity"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const TeamCitySyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.TeamCity } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: projects, isLoading: isProjectsLoading } = useTeamCityConnectionListProjects( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + // For Build Config dropdown + const selectedProjectId = useWatch({ name: "destinationConfig.project", control }); + const selectedProject = projects?.find((proj) => proj.id === selectedProjectId); + + const buildTypes = selectedProject?.buildTypes?.buildType || []; + + return ( + <> + { + setValue("destinationConfig.project", ""); + setValue("destinationConfig.buildConfig", ""); + }} + /> + + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + proj.id === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue("destinationConfig.buildConfig", ""); + }} + options={projects} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + + ( + +
+ Don't see the configuration you're looking for?{" "} + +
+ + } + > + buildType.id === value) ?? null} + onChange={(option) => { + const selectedOption = option as SingleValue<{ id: string; name: string }>; + onChange(selectedOption?.id ?? ""); + }} + options={buildTypes} + isClearable + placeholder="Select a build configuration..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + + + Not selecting a Build Configuration will sync your secrets to the entire project. + + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 79c437d27..e2c285b6f 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -43,6 +43,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Camunda: case SecretSync.Vercel: case SecretSync.Windmill: + case SecretSync.TeamCity: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 99182f207..da9651535 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -22,6 +22,7 @@ import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; import { WindmillSyncReviewFields } from "./WindmillSyncReviewFields"; @@ -88,6 +89,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Windmill: DestinationFieldsComponent = ; break; + case SecretSync.TeamCity: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TeamCitySyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TeamCitySyncReviewFields.tsx new file mode 100644 index 000000000..277ebe1b4 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TeamCitySyncReviewFields.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const TeamCitySyncReviewFields = () => { + const { watch } = useFormContext(); + const project = watch("destinationConfig.project"); + const buildConfig = watch("destinationConfig.buildConfig"); + + return ( + <> + {project} + {buildConfig} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index d58e60b19..50221dc39 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -9,6 +9,7 @@ import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-s import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { GitHubSyncDestinationSchema } from "./github-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; +import { TeamCitySyncDestinationSchema } from "./teamcity-sync-destination-schema"; import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; import { WindmillSyncDestinationSchema } from "./windmill-sync-destination-schema"; @@ -25,7 +26,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ TerraformCloudSyncDestinationSchema, CamundaSyncDestinationSchema, VercelSyncDestinationSchema, - WindmillSyncDestinationSchema + WindmillSyncDestinationSchema, + TeamCitySyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/schemas/teamcity-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/teamcity-sync-destination-schema.ts new file mode 100644 index 000000000..e2cd60050 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/teamcity-sync-destination-schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const TeamCitySyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.TeamCity), + destinationConfig: z.object({ + project: z.string().trim().min(1, "Project required"), + buildConfig: z.string().trim().optional() + }) + }) +); diff --git a/frontend/src/components/v2/ConfirmActionModal/ConfirmActionModal.tsx b/frontend/src/components/v2/ConfirmActionModal/ConfirmActionModal.tsx new file mode 100644 index 000000000..c6bbec305 --- /dev/null +++ b/frontend/src/components/v2/ConfirmActionModal/ConfirmActionModal.tsx @@ -0,0 +1,113 @@ +import { ReactNode, useEffect, useState } from "react"; + +import { useToggle } from "@app/hooks"; + +import { Button } from "../Button"; +import { FormControl } from "../FormControl"; +import { Input } from "../Input"; +import { Modal, ModalClose, ModalContent } from "../Modal"; + +type Props = { + isOpen?: boolean; + onClose?: () => void; + onChange?: (isOpen: boolean) => void; + confirmKey: string; + title: string; + subTitle?: string; + onConfirmed: () => Promise; + buttonText?: string; + formContent?: ReactNode; + children?: ReactNode; + confirmationMessage?: ReactNode; +}; + +export const ConfirmActionModal = ({ + isOpen, + onClose, + onChange, + confirmKey, + onConfirmed, + title, + subTitle = "This action is irreversible.", + buttonText = "Yes", + formContent, + confirmationMessage, + children +}: Props): JSX.Element => { + const [inputData, setInputData] = useState(""); + const [isLoading, setIsLoading] = useToggle(); + + useEffect(() => { + setInputData(""); + }, [isOpen]); + + const onDelete = async () => { + setIsLoading.on(); + try { + await onConfirmed(); + } finally { + setIsLoading.off(); + } + }; + + return ( + { + setInputData(""); + if (onChange) onChange(isOpenState); + }} + > + + + + + +
+ } + onClose={onClose} + > + {formContent} +
{ + evt.preventDefault(); + if (confirmKey === inputData) onDelete(); + }} + > + + {confirmationMessage || ( + <> + Type {confirmKey} to perform this action + + )} +
+ } + className="mb-0" + > + setInputData(e.target.value)} + placeholder={`Type ${confirmKey} here`} + /> + + {children} + + + + ); +}; diff --git a/frontend/src/components/v2/ConfirmActionModal/index.tsx b/frontend/src/components/v2/ConfirmActionModal/index.tsx new file mode 100644 index 000000000..8c292343f --- /dev/null +++ b/frontend/src/components/v2/ConfirmActionModal/index.tsx @@ -0,0 +1 @@ +export { ConfirmActionModal } from "./ConfirmActionModal"; diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index 49c10805c..f17c5763f 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -14,7 +14,7 @@ export const PageHeader = ({ title, description, children, className }: Props) =

{title}

-
{children}
+
{children}
{description}
diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index 96f79e65f..c8b8f2ee6 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -93,6 +93,7 @@ export const SecretInput = forwardRef( onFocus={(evt) => { onFocus?.(evt); setIsSecretFocused.on(); + evt.currentTarget.select(); }} disabled={isDisabled} spellCheck={false} diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 9dcf72e40..ede71e324 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -6,6 +6,7 @@ export * from "./Breadcrumb"; export * from "./Button"; export * from "./Card"; export * from "./Checkbox"; +export * from "./ConfirmActionModal"; export * from "./ContentLoader"; export * from "./DatePicker"; export * from "./DeleteActionModal"; diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 1c2de52e2..2dbfaacb7 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -35,7 +35,8 @@ export enum OrgPermissionSubjects { AppConnections = "app-connections", Kmip = "kmip", Gateway = "gateway", - SecretShare = "secret-share" + SecretShare = "secret-share", + GithubOrgSync = "github-org-sync" } export enum OrgPermissionAdminConsoleAction { @@ -93,6 +94,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Settings] | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Scim] + | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] diff --git a/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx b/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx index f398eb0db..a824f6fa6 100644 --- a/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx +++ b/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx @@ -14,12 +14,13 @@ export const useProjectPermission = () => { strict: false, select: (el) => el?.projectId }); + if (!projectId) { throw new Error("useProjectPermission to be used within "); } const { - data: { permission, membership } + data: { permission, membership, assumedPrivilegeDetails } } = useSuspenseQuery({ queryKey: roleQueryKeys.getUserProjectPermissions({ workspaceId: projectId }), queryFn: () => fetchUserProjectPermissions({ workspaceId: projectId }), @@ -29,6 +30,7 @@ export const useProjectPermission = () => { const ability = evaluatePermissionsAbility(rule); return { permission: ability, + assumedPrivilegeDetails: data.assumedPrivilegeDetails, membership: { ...data.membership, roles: data.membership.roles.map(({ role }) => role) @@ -42,5 +44,5 @@ export const useProjectPermission = () => { [] ); - return { permission, membership, hasProjectRole }; + return { permission, membership, hasProjectRole, assumedPrivilegeDetails }; }; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index a327913d9..f7ba69e17 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -58,7 +58,8 @@ export enum ProjectPermissionIdentityActions { Create = "create", Edit = "edit", Delete = "delete", - GrantPrivileges = "grant-privileges" + GrantPrivileges = "grant-privileges", + AssumePrivileges = "assume-privileges" } export enum ProjectPermissionMemberActions { @@ -66,7 +67,8 @@ export enum ProjectPermissionMemberActions { Create = "create", Edit = "edit", Delete = "delete", - GrantPrivileges = "grant-privileges" + GrantPrivileges = "grant-privileges", + AssumePrivileges = "assume-privileges" } export enum ProjectPermissionGroupActions { @@ -247,7 +249,7 @@ export type ProjectPermissionSet = ] | [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Tags] - | [ProjectPermissionActions, ProjectPermissionSub.Member] + | [ProjectPermissionMemberActions, ProjectPermissionSub.Member] | [ProjectPermissionActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] @@ -258,7 +260,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ - ProjectPermissionActions, + ProjectPermissionIdentityActions, ( | ProjectPermissionSub.Identity | (ForcedSubject & IdentityManagementSubjectFields) diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 674dd2836..35d481b82 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 { @@ -13,9 +20,11 @@ import { GcpConnectionMethod, GitHubConnectionMethod, HumanitecConnectionMethod, + LdapConnectionMethod, MsSqlConnectionMethod, PostgresConnectionMethod, TAppConnection, + TeamCityConnectionMethod, TerraformCloudConnectionMethod, VercelConnectionMethod, WindmillConnectionMethod @@ -48,7 +57,9 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.MsSql]: { name: "Microsoft SQL Server", image: "MsSql.png" }, [AppConnection.Camunda]: { name: "Camunda", image: "Camunda.png" }, [AppConnection.Windmill]: { name: "Windmill", image: "Windmill.png" }, - [AppConnection.Auth0]: { name: "Auth0", image: "Auth0.png", size: 40 } + [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" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -77,10 +88,13 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: return { name: "Username & Password", icon: faLock }; + case TeamCityConnectionMethod.AccessToken: case WindmillConnectionMethod.AccessToken: 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 3a6abbb5b..fd96e5a83 100644 --- a/frontend/src/helpers/secretRotationsV2.ts +++ b/frontend/src/helpers/secretRotationsV2.ts @@ -24,6 +24,16 @@ export const SECRET_ROTATION_MAP: Record< name: "Azure Client Secret", image: "Microsoft Azure.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", + size: 50 } }; @@ -31,7 +41,9 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: true, [SecretRotation.MsSqlCredentials]: true, [SecretRotation.Auth0ClientSecret]: false, - [SecretRotation.AzureClientSecret]: true + [SecretRotation.AzureClientSecret]: true, + [SecretRotation.LdapPassword]: false, + [SecretRotation.AwsIamUserSecret]: true }; export const getRotateAtLocal = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"]) => { diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 291b6f80a..009c2804b 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -39,6 +39,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, [SecretSync.Camunda]: AppConnection.Camunda, [SecretSync.Vercel]: AppConnection.Vercel, - [SecretSync.Windmill]: AppConnection.Windmill + [SecretSync.Windmill]: AppConnection.Windmill, + [SecretSync.TeamCity]: AppConnection.TeamCity }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< 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/aws/queries.tsx b/frontend/src/hooks/api/appConnections/aws/queries.tsx index 87965507b..895ed35ee 100644 --- a/frontend/src/hooks/api/appConnections/aws/queries.tsx +++ b/frontend/src/hooks/api/appConnections/aws/queries.tsx @@ -4,15 +4,20 @@ import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "@app/hooks/api/appConnections"; import { + TAwsConnectionIamUser, TAwsConnectionKmsKey, + TAwsConnectionListIamUsersResponse, TAwsConnectionListKmsKeysResponse, + TListAwsConnectionIamUsers, TListAwsConnectionKmsKeys } from "./types"; const awsConnectionKeys = { all: [...appConnectionKeys.all, "aws"] as const, listKmsKeys: (params: TListAwsConnectionKmsKeys) => - [...awsConnectionKeys.all, "kms-keys", params] as const + [...awsConnectionKeys.all, "kms-keys", params] as const, + listIamUsers: (params: TListAwsConnectionIamUsers) => + [...awsConnectionKeys.all, "iam-users", params] as const }; export const useListAwsConnectionKmsKeys = ( @@ -40,3 +45,28 @@ export const useListAwsConnectionKmsKeys = ( ...options }); }; + +export const useListAwsConnectionIamUsers = ( + { connectionId }: TListAwsConnectionIamUsers, + options?: Omit< + UseQueryOptions< + TAwsConnectionIamUser[], + unknown, + TAwsConnectionIamUser[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: awsConnectionKeys.listIamUsers({ connectionId }), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/aws/${connectionId}/users` + ); + + return data.iamUsers; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/aws/types.ts b/frontend/src/hooks/api/appConnections/aws/types.ts index 7661b131d..d2c7c39cb 100644 --- a/frontend/src/hooks/api/appConnections/aws/types.ts +++ b/frontend/src/hooks/api/appConnections/aws/types.ts @@ -14,3 +14,18 @@ export type TAwsConnectionKmsKey = { export type TAwsConnectionListKmsKeysResponse = { kmsKeys: TAwsConnectionKmsKey[]; }; + +export type TListAwsConnectionIamUsers = { + connectionId: string; +}; + +export type TAwsConnectionIamUser = { + arn: string; + UserName: string; +}; + +export type TAwsConnectionListIamUsersResponse = { + iamUsers: TAwsConnectionIamUser[]; +}; + +export type TAwsIamUserSecret = TAwsConnectionIamUser; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index ebbcec1ec..daa0eb1c3 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -13,5 +13,7 @@ export enum AppConnection { MsSql = "mssql", Camunda = "camunda", Windmill = "windmill", - Auth0 = "auth0" + Auth0 = "auth0", + LDAP = "ldap", + TeamCity = "teamcity" } diff --git a/frontend/src/hooks/api/appConnections/teamcity/index.ts b/frontend/src/hooks/api/appConnections/teamcity/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/teamcity/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/teamcity/queries.tsx b/frontend/src/hooks/api/appConnections/teamcity/queries.tsx new file mode 100644 index 000000000..9d117c265 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/teamcity/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TTeamCityProjectWithBuildTypes } from "./types"; + +const teamcityConnectionKeys = { + all: [...appConnectionKeys.all, "teamcity"] as const, + listProjects: (connectionId: string) => + [...teamcityConnectionKeys.all, "projects", connectionId] as const +}; + +export const useTeamCityConnectionListProjects = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TTeamCityProjectWithBuildTypes[], + unknown, + TTeamCityProjectWithBuildTypes[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: teamcityConnectionKeys.listProjects(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/teamcity/${connectionId}/projects` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/teamcity/types.ts b/frontend/src/hooks/api/appConnections/teamcity/types.ts new file mode 100644 index 000000000..a7adab034 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/teamcity/types.ts @@ -0,0 +1,13 @@ +export type TTeamCityProject = { + id: string; + name: string; +}; + +export type TTeamCityProjectWithBuildTypes = TTeamCityProject & { + buildTypes: { + buildType: { + id: string; + name: string; + }[]; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index b46aa4f6a..9e9100d57 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -72,6 +72,14 @@ export type TAuth0ConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Auth0; }; +export type TLdapConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.LDAP; +}; + +export type TTeamCityConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.TeamCity; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -87,7 +95,8 @@ export type TAppConnectionOption = | TMsSqlConnectionOption | TCamundaConnectionOption | TWindmillConnectionOption - | TAuth0ConnectionOption; + | TAuth0ConnectionOption + | TTeamCityConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -105,4 +114,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 047342fb1..d441e26e0 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -10,8 +10,10 @@ 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"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; import { TWindmillConnection } from "./windmill-connection"; @@ -26,8 +28,10 @@ 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"; export * from "./terraform-cloud-connection"; export * from "./vercel-connection"; export * from "./windmill-connection"; @@ -47,7 +51,9 @@ export type TAppConnection = | TMsSqlConnection | TCamundaConnection | TWindmillConnection - | TAuth0Connection; + | TAuth0Connection + | TLdapConnection + | TTeamCityConnection; export type TAvailableAppConnection = Pick; @@ -90,4 +96,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/appConnections/types/teamcity-connection.ts b/frontend/src/hooks/api/appConnections/types/teamcity-connection.ts new file mode 100644 index 000000000..972df6c96 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/teamcity-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum TeamCityConnectionMethod { + AccessToken = "access-token" +} + +export type TTeamCityConnection = TRootAppConnection & { app: AppConnection.TeamCity } & { + method: TeamCityConnectionMethod.AccessToken; + credentials: { + accessToken: string; + instanceUrl: 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/oidcConfig/types.ts b/frontend/src/hooks/api/oidcConfig/types.ts index 7c41d3400..a814947c7 100644 --- a/frontend/src/hooks/api/oidcConfig/types.ts +++ b/frontend/src/hooks/api/oidcConfig/types.ts @@ -19,5 +19,6 @@ export type OIDCConfigData = { export enum OIDCJWTSignatureAlgorithm { RS256 = "RS256", HS256 = "HS256", - RS512 = "RS512" + RS512 = "RS512", + EDDSA = "EdDSA" } diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 3353a0e96..a406b6d45 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -10,6 +10,7 @@ import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext/type import { groupBy } from "@app/lib/fn/array"; import { omit } from "@app/lib/fn/object"; +import { ActorType } from "../auditLogs/enums"; import { OrgUser, TProjectMembership } from "../users/types"; import { TGetUserOrgPermissionsDTO, @@ -137,6 +138,12 @@ export const fetchUserProjectPermissions = async ({ data: { permissions: PackRule>>[]; membership: Omit & { roles: { role: string }[] }; + assumedPrivilegeDetails?: { + actorId: string; + actorType: ActorType; + actorEmail: string; + actorName: string; + }; }; }>(`/api/v1/workspace/${workspaceId}/permissions`, {}); diff --git a/frontend/src/hooks/api/secretRotationsV2/enums.ts b/frontend/src/hooks/api/secretRotationsV2/enums.ts index 3a362f50b..3b38c1d49 100644 --- a/frontend/src/hooks/api/secretRotationsV2/enums.ts +++ b/frontend/src/hooks/api/secretRotationsV2/enums.ts @@ -2,7 +2,9 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", Auth0ClientSecret = "auth0-client-secret", - AzureClientSecret = "azure-client-secret" + AzureClientSecret = "azure-client-secret", + LdapPassword = "ldap-password", + AwsIamUserSecret = "aws-iam-user-secret" } export enum SecretRotationStatus { diff --git a/frontend/src/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation.ts new file mode 100644 index 000000000..23397506c --- /dev/null +++ b/frontend/src/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation.ts @@ -0,0 +1,38 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { + TSecretRotationV2Base, + TSecretRotationV2GeneratedCredentialsResponseBase +} from "@app/hooks/api/secretRotationsV2/types/shared"; + +export type TAwsIamUserSecretRotation = TSecretRotationV2Base & { + type: SecretRotation.AwsIamUserSecret; + parameters: { + region?: string; + userName: string; + }; + secretsMapping: { + accessKeyId: string; + secretAccessKey: string; + }; +}; + +export type TAwsIamUserSecretRotationGeneratedCredentials = { + accessKeyId: string; + secretAccessKey: string; +}; + +export type TAwsIamUserSecretRotationGeneratedCredentialsResponse = + TSecretRotationV2GeneratedCredentialsResponseBase< + SecretRotation.AwsIamUserSecret, + TAwsIamUserSecretRotationGeneratedCredentials + >; + +export type TAwsIamUserSecretRotationOption = { + name: string; + type: SecretRotation.AwsIamUserSecret; + connection: AppConnection.AWS; + template: { + secretsMapping: TAwsIamUserSecretRotation["secretsMapping"]; + }; +}; diff --git a/frontend/src/hooks/api/secretRotationsV2/types/index.ts b/frontend/src/hooks/api/secretRotationsV2/types/index.ts index 4a4d9fc75..a1ef0bd15 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/index.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/index.ts @@ -4,11 +4,21 @@ import { TAuth0ClientSecretRotationGeneratedCredentialsResponse, TAuth0ClientSecretRotationOption } from "@app/hooks/api/secretRotationsV2/types/auth0-client-secret-rotation"; +import { + TAwsIamUserSecretRotation, + TAwsIamUserSecretRotationGeneratedCredentialsResponse, + TAwsIamUserSecretRotationOption +} from "@app/hooks/api/secretRotationsV2/types/aws-iam-user-secret-rotation"; import { TAzureClientSecretRotation, TAzureClientSecretRotationGeneratedCredentialsResponse, TAzureClientSecretRotationOption } from "@app/hooks/api/secretRotationsV2/types/azure-client-secret-rotation"; +import { + TLdapPasswordRotation, + TLdapPasswordRotationGeneratedCredentialsResponse, + TLdapPasswordRotationOption +} from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation"; import { TMsSqlCredentialsRotation, TMsSqlCredentialsRotationGeneratedCredentialsResponse @@ -26,6 +36,8 @@ export type TSecretRotationV2 = ( | TMsSqlCredentialsRotation | TAuth0ClientSecretRotation | TAzureClientSecretRotation + | TLdapPasswordRotation + | TAwsIamUserSecretRotation ) & { secrets: (SecretV3RawSanitized | null)[]; }; @@ -33,7 +45,9 @@ export type TSecretRotationV2 = ( export type TSecretRotationV2Option = | TSqlCredentialsRotationOption | TAuth0ClientSecretRotationOption - | TAzureClientSecretRotationOption; + | TAzureClientSecretRotationOption + | TLdapPasswordRotationOption + | TAwsIamUserSecretRotationOption; export type TListSecretRotationV2Options = { secretRotationOptions: TSecretRotationV2Option[] }; @@ -43,7 +57,9 @@ export type TViewSecretRotationGeneratedCredentialsResponse = | TPostgresCredentialsRotationGeneratedCredentialsResponse | TMsSqlCredentialsRotationGeneratedCredentialsResponse | TAuth0ClientSecretRotationGeneratedCredentialsResponse - | TAzureClientSecretRotationGeneratedCredentialsResponse; + | TAzureClientSecretRotationGeneratedCredentialsResponse + | TLdapPasswordRotationGeneratedCredentialsResponse + | TAwsIamUserSecretRotationGeneratedCredentialsResponse; export type TCreateSecretRotationV2DTO = DiscriminativePick< TSecretRotationV2, @@ -91,6 +107,8 @@ export type TSecretRotationOptionMap = { [SecretRotation.MsSqlCredentials]: TSqlCredentialsRotationOption; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationOption; [SecretRotation.AzureClientSecret]: TAzureClientSecretRotationOption; + [SecretRotation.LdapPassword]: TLdapPasswordRotationOption; + [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationOption; }; export type TSecretRotationGeneratedCredentialsResponseMap = { @@ -98,4 +116,6 @@ export type TSecretRotationGeneratedCredentialsResponseMap = { [SecretRotation.MsSqlCredentials]: TMsSqlCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationGeneratedCredentialsResponse; [SecretRotation.AzureClientSecret]: TAzureClientSecretRotationGeneratedCredentialsResponse; + [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/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index b0d3fd7bb..450df773a 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -10,7 +10,8 @@ export enum SecretSync { TerraformCloud = "terraform-cloud", Camunda = "camunda", Vercel = "vercel", - Windmill = "windmill" + Windmill = "windmill", + TeamCity = "teamcity" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 21fda56c7..e9ac538fe 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -10,6 +10,7 @@ import { TDatabricksSync } from "./databricks-sync"; import { TGcpSync } from "./gcp-sync"; import { TGitHubSync } from "./github-sync"; import { THumanitecSync } from "./humanitec-sync"; +import { TTeamCitySync } from "./teamcity-sync"; import { TTerraformCloudSync } from "./terraform-cloud-sync"; import { TVercelSync } from "./vercel-sync"; import { TWindmillSync } from "./windmill-sync"; @@ -32,7 +33,8 @@ export type TSecretSync = | TTerraformCloudSync | TCamundaSync | TVercelSync - | TWindmillSync; + | TWindmillSync + | TTeamCitySync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/teamcity-sync.ts b/frontend/src/hooks/api/secretSyncs/types/teamcity-sync.ts new file mode 100644 index 000000000..17f218d06 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/teamcity-sync.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TTeamCitySync = TRootSecretSync & { + destination: SecretSync.TeamCity; + destinationConfig: { + project: string; + buildConfig?: string; + }; + connection: { + app: AppConnection.TeamCity; + name: string; + id: string; + }; +}; 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/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index b9d9f159b..0774275f9 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,6 +1,7 @@ export { useAddUserToWsE2EE, useAddUserToWsNonE2EE, + useRevokeMySessionById, useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "./mutation"; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index c5cf6c27b..1b873b31c 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -171,3 +171,16 @@ export const useResendOrgMemberInvitation = () => { } }); }; + +export const useRevokeMySessionById = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (sessionId: string) => { + const { data } = await apiRequest.delete(`/api/v2/users/me/sessions/${sessionId}`); + return data; + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: userKeys.mySessions }); + } + }); +}; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index c0f5f027d..c4defb68d 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -4,7 +4,8 @@ export { useLeaveProject, useMigrateProjectToV3, useRequestProjectAccess, - useUpdateGroupWorkspaceRole + useUpdateGroupWorkspaceRole, + useUpdateProjectSshConfig } from "./mutations"; export { useAddIdentityToWorkspace, @@ -14,6 +15,7 @@ export { useDeleteUserFromWorkspace, useDeleteWorkspace, useDeleteWsEnvironment, + useGetProjectSshConfig, useGetUpgradeProjectStatus, useGetUserWorkspaceMemberships, useGetUserWorkspaces, diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx index 56f83f601..ea7376d3d 100644 --- a/frontend/src/hooks/api/workspace/mutations.tsx +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -4,7 +4,11 @@ import { apiRequest } from "@app/config/request"; import { userKeys } from "../users/query-keys"; import { workspaceKeys } from "./query-keys"; -import { TUpdateWorkspaceGroupRoleDTO } from "./types"; +import { + TProjectSshConfig, + TUpdateProjectSshConfigDTO, + TUpdateWorkspaceGroupRoleDTO +} from "./types"; export const useAddGroupToWorkspace = () => { const queryClient = useQueryClient(); @@ -117,3 +121,20 @@ export const useRequestProjectAccess = () => { } }); }; + +export const useUpdateProjectSshConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ projectId, defaultUserSshCaId, defaultHostSshCaId }) => { + return apiRequest.patch(`/api/v1/workspace/${projectId}/ssh-config`, { + defaultUserSshCaId, + defaultHostSshCaId + }); + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getProjectSshConfig(projectId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index ca8feb6b6..0a2bf491b 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -34,6 +34,7 @@ import { TListProjectIdentitiesDTO, ToggleAutoCapitalizationDTO, ToggleDeleteProjectProtectionDTO, + TProjectSshConfig, TSearchProjectsDTO, TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceUserRoleDTO, @@ -430,9 +431,13 @@ export const useDeleteWsEnvironment = () => { }); }; -export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?: boolean) => { +export const useGetWorkspaceUsers = ( + workspaceId: string, + includeGroupMembers?: boolean, + roles?: string[] +) => { return useQuery({ - queryKey: workspaceKeys.getWorkspaceUsers(workspaceId), + queryKey: workspaceKeys.getWorkspaceUsers(workspaceId, includeGroupMembers, roles), queryFn: async () => { const { data: { users } @@ -440,7 +445,11 @@ export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?: `/api/v1/workspace/${workspaceId}/users`, { params: { - includeGroupMembers + includeGroupMembers, + roles: + roles && roles.length > 0 + ? roles.map((role) => encodeURIComponent(role)).join(",") + : undefined } } ); @@ -887,3 +896,17 @@ export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: strin enabled: Boolean(workspaceId) }); }; + +export const useGetProjectSshConfig = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getProjectSshConfig(projectId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/workspace/${projectId}/ssh-config` + ); + + return data; + }, + enabled: Boolean(projectId) + }); +}; diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 539ed2ac7..05e9c7588 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -15,7 +15,8 @@ export const workspaceKeys = { type ? ["workspaces", { type }] : (["workspaces"] as const), getWorkspaceAuditLogs: (workspaceId: string) => [{ workspaceId }, "workspace-audit-logs"] as const, - getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const, + getWorkspaceUsers: (workspaceId: string, includeGroupMembers?: boolean, roles?: string[]) => + [{ workspaceId, includeGroupMembers, roles }, "workspace-users"] as const, getWorkspaceUserDetails: (workspaceId: string, membershipId: string) => [{ workspaceId, membershipId }, "workspace-user-details"] as const, getWorkspaceIdentityMemberships: (workspaceId: string) => @@ -69,5 +70,6 @@ export const workspaceKeys = { projectId: string; }) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const, getWorkspaceSshCertificateTemplates: (projectId: string) => - [{ projectId }, "workspace-ssh-certificate-templates"] as const + [{ projectId }, "workspace-ssh-certificate-templates"] as const, + getProjectSshConfig: (projectId: string) => [{ projectId }, "project-ssh-config"] as const }; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 814a920c2..ddcf383fb 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -184,3 +184,18 @@ export type TSearchProjectsDTO = { orderBy?: ProjectIdentityOrderBy; orderDirection?: OrderByDirection; }; + +export type TProjectSshConfig = { + id: string; + createdAt: string; + updatedAt: string; + projectId: string; + defaultUserSshCaId: string | null; + defaultHostSshCaId: string | null; +}; + +export type TUpdateProjectSshConfigDTO = { + projectId: string; + defaultUserSshCaId?: string; + defaultHostSshCaId?: string; +}; diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index 7aed0a6e8..a862c31a3 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, Outlet, useRouterState } from "@tanstack/react-router"; import { motion } from "framer-motion"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { BreadcrumbContainer, Menu, @@ -11,7 +12,13 @@ import { MenuItem, TBreadcrumbFormat } from "@app/components/v2"; -import { useSubscription, useWorkspace } from "@app/context"; +import { + useProjectPermission, + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount, @@ -19,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. @@ -29,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(); @@ -62,6 +71,7 @@ export const ProjectLayout = () => { return ( <>
+ {assumedPrivilegeDetails && }
{ )} */} - {/* - {({ isActive }) => ( - - Certificate Authorities - - )} - */} + {(isAllowed) => + isAllowed && ( + + {({ isActive }) => ( + + Certificate Authorities + + )} + + ) + } + )} {isSecretManager && ( diff --git a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx new file mode 100644 index 000000000..7af215116 --- /dev/null +++ b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx @@ -0,0 +1,49 @@ +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button } from "@app/components/v2"; +import { useProjectPermission, useWorkspace } from "@app/context"; +import { useRemoveAssumeProjectPrivilege } from "@app/hooks/api"; +import { ActorType } from "@app/hooks/api/auditLogs/enums"; + +export const AssumePrivilegeModeBanner = () => { + 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/lib/fn/string.ts b/frontend/src/lib/fn/string.ts index f4c4a43db..6b2842701 100644 --- a/frontend/src/lib/fn/string.ts +++ b/frontend/src/lib/fn/string.ts @@ -11,3 +11,65 @@ export const formatReservedPaths = (secretPath: string) => { export const camelCaseToSpaces = (input: string) => { return input.replace(/([a-z])([A-Z])/g, "$1 $2"); }; + +export const formatSessionUserAgent = (userAgent: string) => { + const result = { + os: "Unknown", + browser: "Unknown", + device: "Desktop" + }; + + // Operating System detection + if (userAgent.includes("Windows")) { + result.os = "Windows"; + } else if ( + userAgent.includes("Mac OS") || + userAgent.includes("Macintosh") || + userAgent.includes("macOS") + ) { + result.os = "macOS"; + } else if (userAgent.includes("Linux") && !userAgent.includes("Android")) { + result.os = "Linux"; + } else if (userAgent.includes("Android")) { + result.os = "Android"; + result.device = "Mobile"; + } else if ( + userAgent.includes("iOS") || + userAgent.includes("iPhone") || + userAgent.includes("iPad") + ) { + result.os = "iOS"; + result.device = userAgent.includes("iPad") ? "Tablet" : "Mobile"; + } + + // Browser detection + if (userAgent.includes("Firefox/")) { + result.browser = "Firefox"; + } else if (userAgent.includes("Edge/") || userAgent.includes("Edg/")) { + result.browser = "Edge"; + } else if (userAgent.includes("Brave/") || userAgent.includes("Brave ")) { + result.browser = "Brave"; + } else if ( + userAgent.includes("Chrome/") && + !userAgent.includes("Chromium/") && + !userAgent.includes("Edg/") + ) { + result.browser = "Chrome"; + } else if ( + userAgent.includes("Safari/") && + !userAgent.includes("Chrome/") && + !userAgent.includes("Chromium/") + ) { + result.browser = "Safari"; + } else if (userAgent.includes("Opera/") || userAgent.includes("OPR/")) { + result.browser = "Opera"; + } else if (userAgent.includes("Trident/") || userAgent.includes("MSIE")) { + result.browser = "Internet Explorer"; + } + + if (userAgent.toLowerCase() === "cli") { + result.browser = "CLI"; + } + + return result; +}; 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 615d03b60..03005ce05 100644 --- a/frontend/src/pages/middlewares/authenticate.tsx +++ b/frontend/src/pages/middlewares/authenticate.tsx @@ -1,7 +1,9 @@ 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"; @@ -24,6 +26,16 @@ export const Route = createFileRoute("/_authenticate")({ title: "Access Restricted", text: " You need to log in to access this page. Please log in to continue." }); + + // persist current URL in session storage so that we can come back to this after successful login + sessionStorage.setItem( + SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL, + JSON.stringify({ + expiry: formatISO(addSeconds(new Date(), 60)), + data: window.location.href + }) + ); + throw redirect({ to: "/login" }); 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 f5a56ee42..fc4477813 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -19,8 +19,10 @@ 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"; import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; import { VercelConnectionForm } from "./VercelConnectionForm"; import { WindmillConnectionForm } from "./WindmillConnectionForm"; @@ -92,6 +94,10 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Auth0: return ; + case AppConnection.LDAP: + return ; + case AppConnection.TeamCity: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -158,6 +164,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)} + /> + + )} + /> +
+
+ + ( + +