diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index e07c8a9da..a4e5c150a 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -55,6 +55,8 @@ USER non-root-user ## FROM base AS backend-build +ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ + WORKDIR /app # Install all required dependencies for build @@ -84,6 +86,8 @@ RUN npm run build # Production stage FROM base AS backend-runner +ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ + WORKDIR /app # Install all required dependencies for runtime @@ -112,6 +116,11 @@ RUN mkdir frontend-build FROM base AS production RUN apt-get update && apt-get install -y \ + build-essential \ + autoconf \ + automake \ + libtool \ + libssl-dev \ ca-certificates \ bash \ curl \ @@ -171,6 +180,7 @@ ENV NODE_ENV production ENV STANDALONE_BUILD true ENV STANDALONE_MODE true ENV NODE_OPTIONS="--max-old-space-size=1024" +ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ WORKDIR /backend diff --git a/backend/package-lock.json b/backend/package-lock.json index cb9efa148..7534ac1bd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "backend", "version": "1.0.0", - "hasInstallScript": true, "license": "ISC", "dependencies": { "@aws-sdk/client-elasticache": "^3.637.0", @@ -34,11 +33,12 @@ "@gitbeaker/rest": "^42.5.0", "@google-cloud/kms": "^4.5.0", "@infisical/quic": "^1.0.8", - "@node-saml/passport-saml": "^5.0.1", + "@node-saml/passport-saml": "^5.1.0", "@octokit/auth-app": "^7.1.1", "@octokit/core": "^5.2.1", "@octokit/plugin-paginate-graphql": "^4.0.1", "@octokit/plugin-retry": "^5.0.5", + "@octokit/request": "8.4.1", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", "@octopusdeploy/api-client": "^3.4.1", @@ -9574,20 +9574,20 @@ } }, "node_modules/@node-saml/node-saml": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.0.1.tgz", - "integrity": "sha512-YQzFPEC+CnsfO9AFYnwfYZKIzOLx3kITaC1HrjHVLTo6hxcQhc+LgHODOMvW4VCV95Gwrz1MshRUWCPzkDqmnA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.1.0.tgz", + "integrity": "sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==", "license": "MIT", "dependencies": { "@types/debug": "^4.1.12", - "@types/qs": "^6.9.11", + "@types/qs": "^6.9.18", "@types/xml-encryption": "^1.2.4", "@types/xml2js": "^0.4.14", "@xmldom/is-dom-node": "^1.0.1", "@xmldom/xmldom": "^0.8.10", - "debug": "^4.3.4", - "xml-crypto": "^6.0.1", - "xml-encryption": "^3.0.2", + "debug": "^4.4.0", + "xml-crypto": "^6.1.2", + "xml-encryption": "^3.1.0", "xml2js": "^0.6.2", "xmlbuilder": "^15.1.1", "xpath": "^0.0.34" @@ -9597,9 +9597,9 @@ } }, "node_modules/@node-saml/node-saml/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -9636,14 +9636,14 @@ } }, "node_modules/@node-saml/passport-saml": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@node-saml/passport-saml/-/passport-saml-5.0.1.tgz", - "integrity": "sha512-fMztg3zfSnjLEgxvpl6HaDMNeh0xeQX4QHiF9e2Lsie2dc4qFE37XYbQZhVmn8XJ2awPpSWLQ736UskYgGU8lQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@node-saml/passport-saml/-/passport-saml-5.1.0.tgz", + "integrity": "sha512-pBm+iFjv9eihcgeJuSUs4c0AuX1QEFdHwP8w1iaWCfDzXdeWZxUBU5HT2bY2S4dvNutcy+A9hYsH7ZLBGtgwDg==", "license": "MIT", "dependencies": { - "@node-saml/node-saml": "^5.0.1", - "@types/express": "^4.17.21", - "@types/passport": "^1.0.16", + "@node-saml/node-saml": "^5.1.0", + "@types/express": "^4.17.23", + "@types/passport": "^1.0.17", "@types/passport-strategy": "^0.2.38", "passport": "^0.7.0", "passport-strategy": "^1.0.0" @@ -9778,18 +9778,6 @@ "node": ">= 18" } }, - "node_modules/@octokit/auth-app/node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dependencies": { - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@octokit/auth-app/node_modules/@octokit/openapi-types": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", @@ -9836,11 +9824,6 @@ "node": "14 || >=16.14" } }, - "node_modules/@octokit/auth-app/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==" - }, "node_modules/@octokit/auth-oauth-app": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-8.1.1.tgz", @@ -9856,18 +9839,6 @@ "node": ">= 18" } }, - "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dependencies": { - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/openapi-types": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", @@ -9906,11 +9877,6 @@ "@octokit/openapi-types": "^22.2.0" } }, - "node_modules/@octokit/auth-oauth-app/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==" - }, "node_modules/@octokit/auth-oauth-device": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-7.1.1.tgz", @@ -9925,18 +9891,6 @@ "node": ">= 18" } }, - "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dependencies": { - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/openapi-types": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", @@ -9975,11 +9929,6 @@ "@octokit/openapi-types": "^22.2.0" } }, - "node_modules/@octokit/auth-oauth-device/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==" - }, "node_modules/@octokit/auth-oauth-user": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-5.1.1.tgz", @@ -9995,18 +9944,6 @@ "node": ">= 18" } }, - "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dependencies": { - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/openapi-types": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", @@ -10045,11 +9982,6 @@ "@octokit/openapi-types": "^22.2.0" } }, - "node_modules/@octokit/auth-oauth-user/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==" - }, "node_modules/@octokit/auth-token": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", @@ -10103,32 +10035,38 @@ "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@octokit/core/node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "license": "ISC" + }, "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==", + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", + "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", "license": "MIT", "dependencies": { - "@octokit/types": "^13.1.0", - "universal-user-agent": "^6.0.0" + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" }, "engines": { "node": ">= 18" } }, "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==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", "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==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", "license": "MIT", "dependencies": { - "@octokit/openapi-types": "^24.2.0" + "@octokit/openapi-types": "^25.1.0" } }, "node_modules/@octokit/graphql": { @@ -10160,6 +10098,12 @@ "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@octokit/graphql/node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "license": "ISC" + }, "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", @@ -10182,18 +10126,6 @@ "node": ">= 18" } }, - "node_modules/@octokit/oauth-methods/node_modules/@octokit/endpoint": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", - "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", - "dependencies": { - "@octokit/types": "^13.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@octokit/oauth-methods/node_modules/@octokit/openapi-types": { "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", @@ -10232,11 +10164,6 @@ "@octokit/openapi-types": "^22.2.0" } }, - "node_modules/@octokit/oauth-methods/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==" - }, "node_modules/@octokit/openapi-types": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-19.1.0.tgz", @@ -10377,31 +10304,54 @@ } }, "node_modules/@octokit/request-error/node_modules/@octokit/openapi-types": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", - "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==" + "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/request-error/node_modules/@octokit/types": { - "version": "13.6.1", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.6.1.tgz", - "integrity": "sha512-PHZE9Z+kWXb23Ndik8MKPirBPziOc0D2/3KH1P+6jK5nGWe96kadZuE4jev2/Jq7FvIfTlT2Ltg8Fv2x1v0a5g==", + "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": "^22.2.0" + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/request/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/request/node_modules/@octokit/openapi-types": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", - "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==" + "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/request/node_modules/@octokit/types": { - "version": "13.6.1", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.6.1.tgz", - "integrity": "sha512-PHZE9Z+kWXb23Ndik8MKPirBPziOc0D2/3KH1P+6jK5nGWe96kadZuE4jev2/Jq7FvIfTlT2Ltg8Fv2x1v0a5g==", + "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": "^22.2.0" + "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@octokit/request/node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "license": "ISC" + }, "node_modules/@octokit/rest": { "version": "20.0.2", "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.0.2.tgz", @@ -13351,9 +13301,10 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -13523,9 +13474,10 @@ } }, "node_modules/@types/passport": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", - "integrity": "sha512-FD0qD5hbPWQzaM0wHUnJ/T0BBCJBxCeemtnCwc/ThhTg3x9jfrAcRUmj5Dopza+MfFS9acTe3wk7rcVnRIp/0A==", + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "license": "MIT", "dependencies": { "@types/express": "*" } @@ -18287,7 +18239,8 @@ "node_modules/fast-content-type-parse": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", - "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==" + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", + "license": "MIT" }, "node_modules/fast-copy": { "version": "3.0.1", @@ -24775,6 +24728,12 @@ "jsonwebtoken": "^9.0.2" } }, + "node_modules/octokit-auth-probot/node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "license": "ISC" + }, "node_modules/odbc": { "version": "2.4.9", "resolved": "https://registry.npmjs.org/odbc/-/odbc-2.4.9.tgz", @@ -30704,9 +30663,10 @@ "integrity": "sha512-G5o6f95b5BggDGuUfKDApKaCgNYy2x7OdHY0zSMF081O0EJobw+1130VONhrA7ezGSV2FNOGyM+KQpQZAr9bIQ==" }, "node_modules/universal-user-agent": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", - "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==" + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" }, "node_modules/universalify": { "version": "2.0.1", @@ -31953,9 +31913,9 @@ "license": "MIT" }, "node_modules/xml-crypto": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.0.1.tgz", - "integrity": "sha512-v05aU7NS03z4jlZ0iZGRFeZsuKO1UfEbbYiaeRMiATBFs6Jq9+wqKquEMTn4UTrYZ9iGD8yz3KT4L9o2iF682w==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz", + "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", "license": "MIT", "dependencies": { "@xmldom/is-dom-node": "^1.0.1", diff --git a/backend/package.json b/backend/package.json index 01bb0c42a..f84db13fc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -153,11 +153,12 @@ "@gitbeaker/rest": "^42.5.0", "@google-cloud/kms": "^4.5.0", "@infisical/quic": "^1.0.8", - "@node-saml/passport-saml": "^5.0.1", + "@node-saml/passport-saml": "^5.1.0", "@octokit/auth-app": "^7.1.1", "@octokit/core": "^5.2.1", "@octokit/plugin-paginate-graphql": "^4.0.1", "@octokit/plugin-retry": "^5.0.5", + "@octokit/request": "8.4.1", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", "@octopusdeploy/api-client": "^3.4.1", diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index c0e18a763..111f42520 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -99,6 +99,7 @@ const main = async () => { (el) => !el.tableName.includes("_migrations") && !el.tableName.includes("audit_logs_") && + !el.tableName.includes("active_locks") && el.tableName !== "intermediate_audit_logs" ); diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index bcc50ee6c..adf9489d4 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -12,10 +12,13 @@ import { TCertificateAuthorityCrlServiceFactory } from "@app/ee/services/certifi import { TCertificateEstServiceFactory } from "@app/ee/services/certificate-est/certificate-est-service"; import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-types"; import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-types"; +import { TEventBusService } from "@app/ee/services/event/event-bus-service"; +import { TServerSentEventsService } from "@app/ee/services/event/event-sse-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 { TIdentityAuthTemplateServiceFactory } from "@app/ee/services/identity-auth-template"; 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"; import { TKmipClientDALFactory } from "@app/ee/services/kmip/kmip-client-dal"; @@ -296,6 +299,9 @@ declare module "fastify" { internalCertificateAuthority: TInternalCertificateAuthorityServiceFactory; pkiTemplate: TPkiTemplatesServiceFactory; reminder: TReminderServiceFactory; + bus: TEventBusService; + sse: TServerSentEventsService; + identityAuthTemplate: TIdentityAuthTemplateServiceFactory; }; // 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 185a32356..f645cb8f2 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -494,6 +494,11 @@ import { TAccessApprovalPoliciesEnvironmentsInsert, TAccessApprovalPoliciesEnvironmentsUpdate } from "@app/db/schemas/access-approval-policies-environments"; +import { + TIdentityAuthTemplates, + TIdentityAuthTemplatesInsert, + TIdentityAuthTemplatesUpdate +} from "@app/db/schemas/identity-auth-templates"; import { TIdentityLdapAuths, TIdentityLdapAuthsInsert, @@ -878,6 +883,11 @@ declare module "knex/types/tables" { TIdentityProjectAdditionalPrivilegeInsert, TIdentityProjectAdditionalPrivilegeUpdate >; + [TableName.IdentityAuthTemplate]: KnexOriginal.CompositeTableType< + TIdentityAuthTemplates, + TIdentityAuthTemplatesInsert, + TIdentityAuthTemplatesUpdate + >; [TableName.AccessApprovalPolicy]: KnexOriginal.CompositeTableType< TAccessApprovalPolicies, diff --git a/backend/src/db/migrations/20250730162101_add-start-from-reminder.ts b/backend/src/db/migrations/20250730162101_add-start-from-reminder.ts new file mode 100644 index 000000000..427e7fb5a --- /dev/null +++ b/backend/src/db/migrations/20250730162101_add-start-from-reminder.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Reminder, "fromDate"))) { + await knex.schema.alterTable(TableName.Reminder, (t) => { + t.timestamp("fromDate", { useTz: true }).nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Reminder, "fromDate")) { + await knex.schema.alterTable(TableName.Reminder, (t) => { + t.dropColumn("fromDate"); + }); + } +} diff --git a/backend/src/db/migrations/20250801170240_add-identity-auth-template.ts b/backend/src/db/migrations/20250801170240_add-identity-auth-template.ts new file mode 100644 index 000000000..60974a661 --- /dev/null +++ b/backend/src/db/migrations/20250801170240_add-identity-auth-template.ts @@ -0,0 +1,36 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityAuthTemplate))) { + await knex.schema.createTable(TableName.IdentityAuthTemplate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("templateFields").notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.string("name", 64).notNullable(); + t.string("authMethod").notNullable(); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.IdentityAuthTemplate); + } + if (!(await knex.schema.hasColumn(TableName.IdentityLdapAuth, "templateId"))) { + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + t.uuid("templateId").nullable(); + t.foreign("templateId").references("id").inTable(TableName.IdentityAuthTemplate).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.IdentityLdapAuth, "templateId")) { + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + t.dropForeign(["templateId"]); + t.dropColumn("templateId"); + }); + } + await knex.schema.dropTableIfExists(TableName.IdentityAuthTemplate); + await dropOnUpdateTrigger(knex, TableName.IdentityAuthTemplate); +} diff --git a/backend/src/db/schemas/identity-auth-templates.ts b/backend/src/db/schemas/identity-auth-templates.ts new file mode 100644 index 000000000..efe8ccb8c --- /dev/null +++ b/backend/src/db/schemas/identity-auth-templates.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 IdentityAuthTemplatesSchema = z.object({ + id: z.string().uuid(), + templateFields: zodBuffer, + orgId: z.string().uuid(), + name: z.string(), + authMethod: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityAuthTemplates = z.infer; +export type TIdentityAuthTemplatesInsert = Omit, TImmutableDBKeys>; +export type TIdentityAuthTemplatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-ldap-auths.ts b/backend/src/db/schemas/identity-ldap-auths.ts index e8d0658d5..3e4d88649 100644 --- a/backend/src/db/schemas/identity-ldap-auths.ts +++ b/backend/src/db/schemas/identity-ldap-auths.ts @@ -25,7 +25,8 @@ export const IdentityLdapAuthsSchema = z.object({ allowedFields: z.unknown().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - accessTokenPeriod: z.coerce.number().default(0) + accessTokenPeriod: z.coerce.number().default(0), + templateId: z.string().uuid().nullable().optional() }); export type TIdentityLdapAuths = z.infer; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 55ec12faa..855934b28 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -91,6 +91,7 @@ export enum TableName { IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege", + IdentityAuthTemplate = "identity_auth_templates", // used by both identity and users IdentityMetadata = "identity_metadata", ResourceMetadata = "resource_metadata", diff --git a/backend/src/db/schemas/reminders.ts b/backend/src/db/schemas/reminders.ts index 6656ad077..f9d5d8b42 100644 --- a/backend/src/db/schemas/reminders.ts +++ b/backend/src/db/schemas/reminders.ts @@ -14,7 +14,8 @@ export const RemindersSchema = z.object({ repeatDays: z.number().nullable().optional(), nextReminderDate: z.date(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + fromDate: z.date().nullable().optional() }); export type TReminders = z.infer; diff --git a/backend/src/ee/routes/v1/identity-template-router.ts b/backend/src/ee/routes/v1/identity-template-router.ts new file mode 100644 index 000000000..b30069643 --- /dev/null +++ b/backend/src/ee/routes/v1/identity-template-router.ts @@ -0,0 +1,391 @@ +import { z } from "zod"; + +import { IdentityAuthTemplatesSchema } from "@app/db/schemas/identity-auth-templates"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + IdentityAuthTemplateMethod, + TEMPLATE_SUCCESS_MESSAGES, + TEMPLATE_VALIDATION_MESSAGES +} from "@app/ee/services/identity-auth-template/identity-auth-template-enums"; +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 ldapTemplateFieldsSchema = z.object({ + url: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.URL_REQUIRED), + bindDN: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.BIND_DN_REQUIRED), + bindPass: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.BIND_PASSWORD_REQUIRED), + searchBase: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.LDAP.SEARCH_BASE_REQUIRED), + ldapCaCertificate: z.string().trim().optional() +}); + +export const registerIdentityTemplateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Create identity auth template", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + name: z + .string() + .trim() + .min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_NAME_REQUIRED) + .max(64, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_NAME_MAX_LENGTH), + authMethod: z.nativeEnum(IdentityAuthTemplateMethod), + templateFields: ldapTemplateFieldsSchema + }), + response: { + 200: IdentityAuthTemplatesSchema.extend({ + templateFields: z.record(z.string(), z.unknown()) + }) + } + }, + handler: async (req) => { + const template = await server.services.identityAuthTemplate.createTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.body.name, + authMethod: req.body.authMethod, + templateFields: req.body.templateFields + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_CREATE, + metadata: { + templateId: template.id, + name: template.name + } + } + }); + + return template; + } + }); + + server.route({ + method: "PATCH", + url: "/:templateId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Update identity auth template", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_ID_REQUIRED) + }), + body: z.object({ + name: z + .string() + .trim() + .min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_NAME_REQUIRED) + .max(64, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_NAME_MAX_LENGTH) + .optional(), + templateFields: ldapTemplateFieldsSchema.partial().optional() + }), + response: { + 200: IdentityAuthTemplatesSchema.extend({ + templateFields: z.record(z.string(), z.unknown()) + }) + } + }, + handler: async (req) => { + const template = await server.services.identityAuthTemplate.updateTemplate({ + templateId: req.params.templateId, + name: req.body.name, + templateFields: req.body.templateFields, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_UPDATE, + metadata: { + templateId: template.id, + name: template.name + } + } + }); + + return template; + } + }); + + server.route({ + method: "DELETE", + url: "/:templateId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Delete identity auth template", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_ID_REQUIRED) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req) => { + const template = await server.services.identityAuthTemplate.deleteTemplate({ + templateId: req.params.templateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_DELETE, + metadata: { + templateId: template.id, + name: template.name + } + } + }); + + return { message: TEMPLATE_SUCCESS_MESSAGES.DELETED }; + } + }); + + server.route({ + method: "GET", + url: "/:templateId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Get identity auth template by ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string().min(1, TEMPLATE_VALIDATION_MESSAGES.TEMPLATE_ID_REQUIRED) + }), + response: { + 200: IdentityAuthTemplatesSchema.extend({ + templateFields: ldapTemplateFieldsSchema + }) + } + }, + handler: async (req) => { + const template = await server.services.identityAuthTemplate.getTemplate({ + templateId: req.params.templateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return template; + } + }); + + server.route({ + method: "GET", + url: "/search", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "List identity auth templates", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + limit: z.coerce.number().positive().max(100).default(5).optional(), + offset: z.coerce.number().min(0).default(0).optional(), + search: z.string().optional() + }), + response: { + 200: z.object({ + templates: IdentityAuthTemplatesSchema.extend({ + templateFields: ldapTemplateFieldsSchema + }).array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { templates, totalCount } = await server.services.identityAuthTemplate.listTemplates({ + limit: req.query.limit, + offset: req.query.offset, + search: req.query.search, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { templates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Get identity auth templates by authentication method", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + authMethod: z.nativeEnum(IdentityAuthTemplateMethod) + }), + response: { + 200: IdentityAuthTemplatesSchema.extend({ + templateFields: ldapTemplateFieldsSchema + }).array() + } + }, + handler: async (req) => { + const templates = await server.services.identityAuthTemplate.getTemplatesByAuthMethod({ + authMethod: req.query.authMethod, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return templates; + } + }); + + server.route({ + method: "GET", + url: "/:templateId/usage", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Get template usage by template ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string() + }), + response: { + 200: z + .object({ + identityId: z.string(), + identityName: z.string() + }) + .array() + } + }, + handler: async (req) => { + const templates = await server.services.identityAuthTemplate.findTemplateUsages({ + templateId: req.params.templateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return templates; + } + }); + + server.route({ + method: "POST", + url: "/:templateId/delete-usage", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + description: "Unlink identity auth template usage", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + templateId: z.string() + }), + body: z.object({ + identityIds: z.string().array() + }), + response: { + 200: z + .object({ + authId: z.string(), + identityId: z.string(), + identityName: z.string() + }) + .array() + } + }, + handler: async (req) => { + const templates = await server.services.identityAuthTemplate.unlinkTemplateUsage({ + templateId: req.params.templateId, + identityIds: req.body.identityIds, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return templates; + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 8f3b69dfa..ab9503f58 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -13,6 +13,7 @@ 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 { registerIdentityTemplateRouter } from "./identity-template-router"; import { registerKmipRouter } from "./kmip-router"; import { registerKmipSpecRouter } from "./kmip-spec-router"; import { registerLdapRouter } from "./ldap-router"; @@ -125,6 +126,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerExternalKmsRouter, { prefix: "/external-kms" }); + await server.register(registerIdentityTemplateRouter, { prefix: "/identity-templates" }); await server.register(registerProjectTemplateRouter, { prefix: "/project-templates" }); diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/gitlab-secret-scanning-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/gitlab-secret-scanning-router.ts new file mode 100644 index 000000000..c5e832d54 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/gitlab-secret-scanning-router.ts @@ -0,0 +1,16 @@ +import { registerSecretScanningEndpoints } from "@app/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints"; +import { + CreateGitLabDataSourceSchema, + GitLabDataSourceSchema, + UpdateGitLabDataSourceSchema +} from "@app/ee/services/secret-scanning-v2/gitlab"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +export const registerGitLabSecretScanningRouter = async (server: FastifyZodProvider) => + registerSecretScanningEndpoints({ + type: SecretScanningDataSource.GitLab, + server, + responseSchema: GitLabDataSourceSchema, + createSchema: CreateGitLabDataSourceSchema, + updateSchema: UpdateGitLabDataSourceSchema + }); diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts index 2258f9c82..81e393f7c 100644 --- a/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts @@ -1,3 +1,4 @@ +import { registerGitLabSecretScanningRouter } from "@app/ee/routes/v2/secret-scanning-v2-routers/gitlab-secret-scanning-router"; import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; import { registerBitbucketSecretScanningRouter } from "./bitbucket-secret-scanning-router"; @@ -10,5 +11,6 @@ export const SECRET_SCANNING_REGISTER_ROUTER_MAP: Record< (server: FastifyZodProvider) => Promise > = { [SecretScanningDataSource.GitHub]: registerGitHubSecretScanningRouter, - [SecretScanningDataSource.Bitbucket]: registerBitbucketSecretScanningRouter + [SecretScanningDataSource.Bitbucket]: registerBitbucketSecretScanningRouter, + [SecretScanningDataSource.GitLab]: registerGitLabSecretScanningRouter }; diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts index 0a672437d..e22b9afe2 100644 --- a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts @@ -4,6 +4,7 @@ import { SecretScanningConfigsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { BitbucketDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/bitbucket"; import { GitHubDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/github"; +import { GitLabDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/gitlab"; import { SecretScanningFindingStatus, SecretScanningScanStatus @@ -24,7 +25,8 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SecretScanningDataSourceOptionsSchema = z.discriminatedUnion("type", [ GitHubDataSourceListItemSchema, - BitbucketDataSourceListItemSchema + BitbucketDataSourceListItemSchema, + GitLabDataSourceListItemSchema ]); export const registerSecretScanningV2Router = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index 2df779795..b58f58164 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -1,8 +1,10 @@ // weird commonjs-related error in the CI requires us to do the import like this import knex from "knex"; +import { v4 as uuidv4 } from "uuid"; import { TDbClient } from "@app/db"; import { TableName, TAuditLogs } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; import { DatabaseError, GatewayTimeoutError } from "@app/lib/errors"; import { ormify, selectAllTableCols, TOrmify } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; @@ -150,43 +152,70 @@ export const auditLogDALFactory = (db: TDbClient) => { // delete all audit log that have expired const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async (tx) => { - const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000; - const MAX_RETRY_ON_FAILURE = 3; + const runPrune = async (dbClient: knex.Knex) => { + const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000; + const MAX_RETRY_ON_FAILURE = 3; - const today = new Date(); - let deletedAuditLogIds: { id: string }[] = []; - let numberOfRetryOnFailure = 0; - let isRetrying = false; + const today = new Date(); + let deletedAuditLogIds: { id: string }[] = []; + let numberOfRetryOnFailure = 0; + let isRetrying = false; - logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); - do { - try { - const findExpiredLogSubQuery = (tx || db)(TableName.AuditLog) - .where("expiresAt", "<", today) - .where("createdAt", "<", today) // to use audit log partition - .orderBy(`${TableName.AuditLog}.createdAt`, "desc") - .select("id") - .limit(AUDIT_LOG_PRUNE_BATCH_SIZE); + logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); + do { + try { + const findExpiredLogSubQuery = dbClient(TableName.AuditLog) + .where("expiresAt", "<", today) + .where("createdAt", "<", today) // to use audit log partition + .orderBy(`${TableName.AuditLog}.createdAt`, "desc") + .select("id") + .limit(AUDIT_LOG_PRUNE_BATCH_SIZE); - // eslint-disable-next-line no-await-in-loop - deletedAuditLogIds = await (tx || db)(TableName.AuditLog) - .whereIn("id", findExpiredLogSubQuery) - .del() - .returning("id"); - numberOfRetryOnFailure = 0; // reset - } catch (error) { - numberOfRetryOnFailure += 1; - logger.error(error, "Failed to delete audit log on pruning"); - } finally { - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => { - setTimeout(resolve, 10); // time to breathe for db - }); - } - isRetrying = numberOfRetryOnFailure > 0; - } while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); - logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`); + // eslint-disable-next-line no-await-in-loop + deletedAuditLogIds = await dbClient(TableName.AuditLog) + .whereIn("id", findExpiredLogSubQuery) + .del() + .returning("id"); + numberOfRetryOnFailure = 0; // reset + } catch (error) { + numberOfRetryOnFailure += 1; + logger.error(error, "Failed to delete audit log on pruning"); + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 10); // time to breathe for db + }); + } + isRetrying = numberOfRetryOnFailure > 0; + } while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); + logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`); + }; + + if (tx) { + await runPrune(tx); + } else { + const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes + await db.transaction(async (trx) => { + await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); + await runPrune(trx); + }); + } }; - return { ...auditLogOrm, pruneAuditLog, find }; + const create: TAuditLogDALFactory["create"] = async (tx) => { + const config = getConfig(); + + if (config.DISABLE_AUDIT_LOG_STORAGE) { + return { + ...tx, + id: uuidv4(), + createdAt: new Date(), + updatedAt: new Date() + }; + } + + return auditLogOrm.create(tx); + }; + + return { ...auditLogOrm, create, pruneAuditLog, find }; }; diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index be58c4c43..9e0f5f998 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -1,7 +1,8 @@ import { AxiosError, RawAxiosRequestHeaders } from "axios"; -import { SecretKeyEncoding } from "@app/db/schemas"; -import { getConfig } from "@app/lib/config/env"; +import { ProjectType, SecretKeyEncoding } from "@app/db/schemas"; +import { TEventBusService } from "@app/ee/services/event/event-bus-service"; +import { TopicName, toPublishableEvent } from "@app/ee/services/event/types"; import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto/cryptography"; import { logger } from "@app/lib/logger"; @@ -21,6 +22,7 @@ type TAuditLogQueueServiceFactoryDep = { queueService: TQueueServiceFactory; projectDAL: Pick; licenseService: Pick; + eventBusService: TEventBusService; }; export type TAuditLogQueueServiceFactory = { @@ -36,133 +38,17 @@ export const auditLogQueueServiceFactory = async ({ queueService, projectDAL, licenseService, - auditLogStreamDAL + auditLogStreamDAL, + eventBusService }: TAuditLogQueueServiceFactoryDep): Promise => { - const appCfg = getConfig(); - const pushToLog = async (data: TCreateAuditLogDTO) => { - if (appCfg.USE_PG_QUEUE && appCfg.SHOULD_INIT_PG_QUEUE) { - await queueService.queuePg(QueueJobs.AuditLog, data, { - retryLimit: 10, - retryBackoff: true - }); - } else { - await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { - removeOnFail: { - count: 3 - }, - removeOnComplete: true - }); - } - }; - - if (appCfg.SHOULD_INIT_PG_QUEUE) { - await queueService.startPg( - QueueJobs.AuditLog, - async ([job]) => { - const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; - let { orgId } = job.data; - const MS_IN_DAY = 24 * 60 * 60 * 1000; - let project; - - if (!orgId) { - // it will never be undefined for both org and project id - // TODO(akhilmhdh): use caching here in dal to avoid db calls - project = await projectDAL.findById(projectId as string); - orgId = project.orgId; - } - - const plan = await licenseService.getPlan(orgId); - if (plan.auditLogsRetentionDays === 0) { - // skip inserting if audit log retention is 0 meaning its not supported - return; - } - - // For project actions, set TTL to project-level audit log retention config - // This condition ensures that the plan's audit log retention days cannot be bypassed - const ttlInDays = - project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays - ? project.auditLogsRetentionDays - : plan.auditLogsRetentionDays; - - const ttl = ttlInDays * MS_IN_DAY; - - const auditLog = await auditLogDAL.create({ - actor: actor.type, - actorMetadata: actor.metadata, - userAgent, - projectId, - projectName: project?.name, - ipAddress, - orgId, - eventType: event.type, - expiresAt: new Date(Date.now() + ttl), - eventMetadata: event.metadata, - userAgentType - }); - - const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; - await Promise.allSettled( - logStreams.map( - async ({ - url, - encryptedHeadersTag, - encryptedHeadersIV, - encryptedHeadersKeyEncoding, - encryptedHeadersCiphertext - }) => { - const streamHeaders = - encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag - ? (JSON.parse( - crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, - iv: encryptedHeadersIV, - tag: encryptedHeadersTag, - ciphertext: encryptedHeadersCiphertext - }) - ) as LogStreamHeaders[]) - : []; - - const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - - if (streamHeaders.length) - streamHeaders.forEach(({ key, value }) => { - headers[key] = value; - }); - - try { - const response = await request.post( - url, - { ...providerSpecificPayload(url), ...auditLog }, - { - headers, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - } - ); - return response; - } catch (error) { - logger.error( - `Failed to stream audit log [url=${url}] for org [orgId=${orgId}] [error=${(error as AxiosError).message}]` - ); - return error; - } - } - ) - ); + await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { + removeOnFail: { + count: 3 }, - { - batchSize: 1, - workerCount: 30, - pollingIntervalSeconds: 0.5 - } - ); - } + removeOnComplete: true + }); + }; queueService.start(QueueName.AuditLog, async (job) => { const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; @@ -178,88 +64,97 @@ export const auditLogQueueServiceFactory = async ({ } const plan = await licenseService.getPlan(orgId); - if (plan.auditLogsRetentionDays === 0) { - // skip inserting if audit log retention is 0 meaning its not supported - return; + + // skip inserting if audit log retention is 0 meaning its not supported + if (plan.auditLogsRetentionDays !== 0) { + // For project actions, set TTL to project-level audit log retention config + // This condition ensures that the plan's audit log retention days cannot be bypassed + const ttlInDays = + project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays + ? project.auditLogsRetentionDays + : plan.auditLogsRetentionDays; + + const ttl = ttlInDays * MS_IN_DAY; + + const auditLog = await auditLogDAL.create({ + actor: actor.type, + actorMetadata: actor.metadata, + userAgent, + projectId, + projectName: project?.name, + ipAddress, + orgId, + eventType: event.type, + expiresAt: new Date(Date.now() + ttl), + eventMetadata: event.metadata, + userAgentType + }); + + const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; + await Promise.allSettled( + logStreams.map( + async ({ + url, + encryptedHeadersTag, + encryptedHeadersIV, + encryptedHeadersKeyEncoding, + encryptedHeadersCiphertext + }) => { + const streamHeaders = + encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag + ? (JSON.parse( + crypto + .encryption() + .symmetric() + .decryptWithRootEncryptionKey({ + keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, + iv: encryptedHeadersIV, + tag: encryptedHeadersTag, + ciphertext: encryptedHeadersCiphertext + }) + ) as LogStreamHeaders[]) + : []; + + const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + + if (streamHeaders.length) + streamHeaders.forEach(({ key, value }) => { + headers[key] = value; + }); + + try { + const response = await request.post( + url, + { ...providerSpecificPayload(url), ...auditLog }, + { + headers, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + } + ); + return response; + } catch (error) { + logger.error( + `Failed to stream audit log [url=${url}] for org [orgId=${orgId}] [error=${(error as AxiosError).message}]` + ); + return error; + } + } + ) + ); } - // For project actions, set TTL to project-level audit log retention config - // This condition ensures that the plan's audit log retention days cannot be bypassed - const ttlInDays = - project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays - ? project.auditLogsRetentionDays - : plan.auditLogsRetentionDays; + const publishable = toPublishableEvent(event); - const ttl = ttlInDays * MS_IN_DAY; - - const auditLog = await auditLogDAL.create({ - actor: actor.type, - actorMetadata: actor.metadata, - userAgent, - projectId, - projectName: project?.name, - ipAddress, - orgId, - eventType: event.type, - expiresAt: new Date(Date.now() + ttl), - eventMetadata: event.metadata, - userAgentType - }); - - const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; - await Promise.allSettled( - logStreams.map( - async ({ - url, - encryptedHeadersTag, - encryptedHeadersIV, - encryptedHeadersKeyEncoding, - encryptedHeadersCiphertext - }) => { - const streamHeaders = - encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag - ? (JSON.parse( - crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, - iv: encryptedHeadersIV, - tag: encryptedHeadersTag, - ciphertext: encryptedHeadersCiphertext - }) - ) as LogStreamHeaders[]) - : []; - - const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; - - if (streamHeaders.length) - streamHeaders.forEach(({ key, value }) => { - headers[key] = value; - }); - - try { - const response = await request.post( - url, - { ...providerSpecificPayload(url), ...auditLog }, - { - headers, - // request timeout - timeout: AUDIT_LOG_STREAM_TIMEOUT, - // connection timeout - signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) - } - ); - return response; - } catch (error) { - logger.error( - `Failed to stream audit log [url=${url}] for org [orgId=${orgId}] [error=${(error as AxiosError).message}]` - ); - return error; - } - } - ) - ); + if (publishable) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: publishable.data + }); + } }); return { 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 818bb1f99..11d045eb2 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -161,6 +161,9 @@ export enum EventType { CREATE_IDENTITY = "create-identity", UPDATE_IDENTITY = "update-identity", DELETE_IDENTITY = "delete-identity", + MACHINE_IDENTITY_AUTH_TEMPLATE_CREATE = "machine-identity-auth-template-create", + MACHINE_IDENTITY_AUTH_TEMPLATE_UPDATE = "machine-identity-auth-template-update", + MACHINE_IDENTITY_AUTH_TEMPLATE_DELETE = "machine-identity-auth-template-delete", LOGIN_IDENTITY_UNIVERSAL_AUTH = "login-identity-universal-auth", ADD_IDENTITY_UNIVERSAL_AUTH = "add-identity-universal-auth", UPDATE_IDENTITY_UNIVERSAL_AUTH = "update-identity-universal-auth", @@ -830,6 +833,30 @@ interface LoginIdentityUniversalAuthEvent { }; } +interface MachineIdentityAuthTemplateCreateEvent { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_CREATE; + metadata: { + templateId: string; + name: string; + }; +} + +interface MachineIdentityAuthTemplateUpdateEvent { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_UPDATE; + metadata: { + templateId: string; + name: string; + }; +} + +interface MachineIdentityAuthTemplateDeleteEvent { + type: EventType.MACHINE_IDENTITY_AUTH_TEMPLATE_DELETE; + metadata: { + templateId: string; + name: string; + }; +} + interface AddIdentityUniversalAuthEvent { type: EventType.ADD_IDENTITY_UNIVERSAL_AUTH; metadata: { @@ -1325,6 +1352,7 @@ interface AddIdentityLdapAuthEvent { accessTokenTrustedIps?: Array; allowedFields?: TAllowedFields[]; url: string; + templateId?: string | null; }; } @@ -1338,6 +1366,7 @@ interface UpdateIdentityLdapAuthEvent { accessTokenTrustedIps?: Array; allowedFields?: TAllowedFields[]; url?: string; + templateId?: string | null; }; } @@ -3439,6 +3468,9 @@ export type Event = | UpdateIdentityEvent | DeleteIdentityEvent | LoginIdentityUniversalAuthEvent + | MachineIdentityAuthTemplateCreateEvent + | MachineIdentityAuthTemplateUpdateEvent + | MachineIdentityAuthTemplateDeleteEvent | AddIdentityUniversalAuthEvent | UpdateIdentityUniversalAuthEvent | DeleteIdentityUniversalAuthEvent diff --git a/backend/src/ee/services/event/event-bus-service.ts b/backend/src/ee/services/event/event-bus-service.ts new file mode 100644 index 000000000..63f3b9fae --- /dev/null +++ b/backend/src/ee/services/event/event-bus-service.ts @@ -0,0 +1,83 @@ +import Redis from "ioredis"; +import { z } from "zod"; + +import { logger } from "@app/lib/logger"; + +import { EventSchema, TopicName } from "./types"; + +export const eventBusFactory = (redis: Redis) => { + const publisher = redis.duplicate(); + // Duplicate the publisher to create a subscriber. + // This is necessary because Redis does not allow a single connection to both publish and subscribe. + const subscriber = publisher.duplicate(); + + const init = async (topics: TopicName[] = Object.values(TopicName)) => { + subscriber.on("error", (e) => { + logger.error(e, "Event Bus subscriber error"); + }); + + publisher.on("error", (e) => { + logger.error(e, "Event Bus publisher error"); + }); + + await subscriber.subscribe(...topics); + }; + + /** + * Publishes an event to the specified topic. + * @param topic - The topic to publish the event to. + * @param event - The event data to publish. + */ + const publish = async >(topic: TopicName, event: T) => { + const json = JSON.stringify(event); + + return publisher.publish(topic, json, (err) => { + if (err) { + return logger.error(err, `Error publishing to channel ${topic}`); + } + }); + }; + + /** + * @param fn - The function to call when a message is received. + * It should accept the parsed event data as an argument. + * @template T - The type of the event data, which should match the schema defined in EventSchema. + * @returns A function that can be called to unsubscribe from the event bus. + */ + const subscribe = >(fn: (data: T) => Promise | void) => { + // Not using async await cause redis client's `on` method does not expect async listeners. + const listener = (channel: string, message: string) => { + try { + const parsed = JSON.parse(message) as T; + const thenable = fn(parsed); + + // If the function returns a Promise, catch any errors that occur during processing. + if (thenable instanceof Promise) { + thenable.catch((error) => { + logger.error(error, `Error processing message from channel ${channel}`); + }); + } + } catch (error) { + logger.error(error, `Error parsing message data from channel ${channel}`); + } + }; + subscriber.on("message", listener); + + return () => { + subscriber.off("message", listener); + }; + }; + + const close = async () => { + try { + await publisher.quit(); + await subscriber.quit(); + } catch (error) { + logger.error(error, "Error closing event bus connections"); + } + }; + + return { init, publish, subscribe, close }; +}; + +export type TEventBusService = ReturnType; diff --git a/backend/src/ee/services/event/event-sse-service.ts b/backend/src/ee/services/event/event-sse-service.ts new file mode 100644 index 000000000..f8c025e77 --- /dev/null +++ b/backend/src/ee/services/event/event-sse-service.ts @@ -0,0 +1,164 @@ +/* eslint-disable no-continue */ +import { subject } from "@casl/ability"; +import Redis from "ioredis"; + +import { KeyStorePrefixes } from "@app/keystore/keystore"; +import { logger } from "@app/lib/logger"; + +import { TEventBusService } from "./event-bus-service"; +import { createEventStreamClient, EventStreamClient, IEventStreamClientOpts } from "./event-sse-stream"; +import { EventData, RegisteredEvent, toBusEventName } from "./types"; + +const AUTH_REFRESH_INTERVAL = 60 * 1000; +const HEART_BEAT_INTERVAL = 15 * 1000; + +export const sseServiceFactory = (bus: TEventBusService, redis: Redis) => { + let heartbeatInterval: NodeJS.Timeout | null = null; + + const clients = new Set(); + + heartbeatInterval = setInterval(() => { + for (const client of clients) { + if (client.stream.closed) continue; + void client.ping(); + } + }, HEART_BEAT_INTERVAL); + + const refreshInterval = setInterval(() => { + for (const client of clients) { + if (client.stream.closed) continue; + void client.refresh(); + } + }, AUTH_REFRESH_INTERVAL); + + const removeActiveConnection = async (projectId: string, identityId: string, connectionId: string) => { + const set = KeyStorePrefixes.ActiveSSEConnectionsSet(projectId, identityId); + const key = KeyStorePrefixes.ActiveSSEConnections(projectId, identityId, connectionId); + + await Promise.all([redis.lrem(set, 0, connectionId), redis.del(key)]); + }; + + const getActiveConnectionsCount = async (projectId: string, identityId: string) => { + const set = KeyStorePrefixes.ActiveSSEConnectionsSet(projectId, identityId); + const connections = await redis.lrange(set, 0, -1); + + if (connections.length === 0) { + return 0; // No active connections + } + + const keys = connections.map((c) => KeyStorePrefixes.ActiveSSEConnections(projectId, identityId, c)); + + const values = await redis.mget(...keys); + + // eslint-disable-next-line no-plusplus + for (let i = 0; i < values.length; i++) { + if (values[i] === null) { + // eslint-disable-next-line no-await-in-loop + await removeActiveConnection(projectId, identityId, connections[i]); + } + } + + return redis.llen(set); + }; + + const onDisconnect = async (client: EventStreamClient) => { + try { + client.close(); + clients.delete(client); + await removeActiveConnection(client.auth.projectId, client.auth.actorId, client.id); + } catch (error) { + logger.error(error, "Error during SSE stream disconnection"); + } + }; + + function filterEventsForClient(client: EventStreamClient, event: EventData, registered: RegisteredEvent[]) { + const eventType = toBusEventName(event.data.eventType); + const match = registered.find((r) => r.event === eventType); + if (!match) return; + + const item = event.data.payload; + + if (Array.isArray(item)) { + if (item.length === 0) return; + + const baseSubject = { + eventType, + environment: undefined as string | undefined, + secretPath: undefined as string | undefined + }; + + const filtered = item.filter((ev) => { + baseSubject.secretPath = ev.secretPath ?? "/"; + baseSubject.environment = ev.environment; + + return client.matcher.can("subscribe", subject(event.type, baseSubject)); + }); + + if (filtered.length === 0) return; + + return client.send({ + ...event, + data: { + ...event.data, + payload: filtered + } + }); + } + + // For single item + const baseSubject = { + eventType, + secretPath: item.secretPath ?? "/", + environment: item.environment + }; + + if (client.matcher.can("subscribe", subject(event.type, baseSubject))) { + client.send(event); + } + } + + const subscribe = async ( + opts: IEventStreamClientOpts & { + onClose?: () => void; + } + ) => { + const client = createEventStreamClient(redis, opts); + + // Set up event listener on event bus + const unsubscribe = bus.subscribe((event) => { + if (event.type !== opts.type) return; + filterEventsForClient(client, event, opts.registered); + }); + + client.stream.on("close", () => { + unsubscribe(); + void onDisconnect(client); // This will never throw + }); + + await client.open(); + + clients.add(client); + + return client; + }; + + const close = () => { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + } + + if (refreshInterval) { + clearInterval(refreshInterval); + } + + for (const client of clients) { + client.close(); + } + + clients.clear(); + }; + + return { subscribe, close, getActiveConnectionsCount }; +}; + +export type TServerSentEventsService = ReturnType; diff --git a/backend/src/ee/services/event/event-sse-stream.ts b/backend/src/ee/services/event/event-sse-stream.ts new file mode 100644 index 000000000..d73eccb42 --- /dev/null +++ b/backend/src/ee/services/event/event-sse-stream.ts @@ -0,0 +1,178 @@ +/* eslint-disable no-underscore-dangle */ +import { Readable } from "node:stream"; + +import { MongoAbility, PureAbility } from "@casl/ability"; +import { MongoQuery } from "@ucast/mongo2js"; +import Redis from "ioredis"; +import { nanoid } from "nanoid"; + +import { ProjectType } from "@app/db/schemas"; +import { ProjectPermissionSet } from "@app/ee/services/permission/project-permission"; +import { KeyStorePrefixes } from "@app/keystore/keystore"; +import { conditionsMatcher } from "@app/lib/casl"; +import { logger } from "@app/lib/logger"; + +import { EventData, RegisteredEvent } from "./types"; + +export const getServerSentEventsHeaders = () => + ({ + "Cache-Control": "no-cache", + "Content-Type": "text/event-stream", + Connection: "keep-alive", + "X-Accel-Buffering": "no" + }) as const; + +type TAuthInfo = { + actorId: string; + projectId: string; + permission: MongoAbility; +}; + +export interface IEventStreamClientOpts { + type: ProjectType; + registered: RegisteredEvent[]; + onAuthRefresh: (info: TAuthInfo) => Promise | void; + getAuthInfo: () => Promise | TAuthInfo; +} + +interface EventMessage { + time?: string | number; + type: string; + data?: unknown; +} + +function serializeSseEvent(chunk: EventMessage): string { + let payload = ""; + + if (chunk.time) payload += `id: ${chunk.time}\n`; + if (chunk.type) payload += `event: ${chunk.type}\n`; + if (chunk.data) payload += `data: ${JSON.stringify(chunk)}\n`; + + return `${payload}\n`; +} + +export type EventStreamClient = { + id: string; + stream: Readable; + open: () => Promise; + send: (data: EventMessage | EventData) => void; + ping: () => Promise; + refresh: () => Promise; + close: () => void; + get auth(): TAuthInfo; + signal: AbortSignal; + abort: () => void; + matcher: PureAbility; +}; + +export function createEventStreamClient(redis: Redis, options: IEventStreamClientOpts): EventStreamClient { + const rules = options.registered.map((r) => ({ + subject: options.type, + action: "subscribe", + conditions: { + eventType: r.event, + secretPath: r.conditions?.secretPath ?? "/", + environment: r.conditions?.environmentSlug + } + })); + + const id = `sse-${nanoid()}`; + const control = new AbortController(); + const matcher = new PureAbility(rules, { conditionsMatcher }); + + let auth: TAuthInfo | undefined; + + const stream = new Readable({ + objectMode: true + }); + + // We will manually push data to the stream + stream._read = () => {}; + + const send = (data: EventMessage | EventData) => { + const chunk = serializeSseEvent(data); + if (!stream.push(chunk)) { + logger.debug("Backpressure detected: dropped manual event"); + } + }; + + stream.on("error", (error: Error) => stream.destroy(error)); + + const open = async () => { + auth = await options.getAuthInfo(); + await options.onAuthRefresh(auth); + + const { actorId, projectId } = auth; + const set = KeyStorePrefixes.ActiveSSEConnectionsSet(projectId, actorId); + const key = KeyStorePrefixes.ActiveSSEConnections(projectId, actorId, id); + + await Promise.all([redis.rpush(set, id), redis.set(key, "1", "EX", 60)]); + }; + + const ping = async () => { + if (!auth) return; // Avoid race condition if ping is called before open + + const { actorId, projectId } = auth; + const key = KeyStorePrefixes.ActiveSSEConnections(projectId, actorId, id); + + await redis.set(key, "1", "EX", 60); + + stream.push("1"); + }; + + const close = () => { + if (stream.closed) return; + stream.push(null); + stream.destroy(); + }; + + /** + * Refreshes the connection's auth permissions + * Must be called atleast once when connection is opened + */ + const refresh = async () => { + try { + auth = await options.getAuthInfo(); + await options.onAuthRefresh(auth); + } catch (error) { + if (error instanceof Error) { + send({ + type: "error", + data: { + ...error + } + }); + return close(); + } + stream.emit("error", error); + } + }; + + const abort = () => { + try { + control.abort(); + } catch (error) { + logger.debug(error, "Error aborting SSE stream"); + } + }; + + return { + id, + stream, + open, + send, + ping, + refresh, + close, + signal: control.signal, + abort, + matcher, + get auth() { + if (!auth) { + throw new Error("Auth info not set"); + } + + return auth; + } + }; +} diff --git a/backend/src/ee/services/event/types.ts b/backend/src/ee/services/event/types.ts new file mode 100644 index 000000000..721e6ea13 --- /dev/null +++ b/backend/src/ee/services/event/types.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; + +import { ProjectType } from "@app/db/schemas"; +import { Event, EventType } from "@app/ee/services/audit-log/audit-log-types"; + +export enum TopicName { + CoreServers = "infisical::core-servers" +} + +export enum BusEventName { + CreateSecret = "secret:create", + UpdateSecret = "secret:update", + DeleteSecret = "secret:delete" +} + +type PublisableEventTypes = + | EventType.CREATE_SECRET + | EventType.CREATE_SECRETS + | EventType.DELETE_SECRET + | EventType.DELETE_SECRETS + | EventType.UPDATE_SECRETS + | EventType.UPDATE_SECRET; + +export function toBusEventName(input: EventType) { + switch (input) { + case EventType.CREATE_SECRET: + case EventType.CREATE_SECRETS: + return BusEventName.CreateSecret; + case EventType.UPDATE_SECRET: + case EventType.UPDATE_SECRETS: + return BusEventName.UpdateSecret; + case EventType.DELETE_SECRET: + case EventType.DELETE_SECRETS: + return BusEventName.DeleteSecret; + default: + return null; + } +} + +const isBulkEvent = (event: Event): event is Extract } }> => { + return event.type.endsWith("-secrets"); // Feels so wrong +}; + +export const toPublishableEvent = (event: Event) => { + const name = toBusEventName(event.type); + + if (!name) return null; + + const e = event as Extract; + + if (isBulkEvent(e)) { + return { + name, + isBulk: true, + data: { + eventType: e.type, + payload: e.metadata.secrets.map((s) => ({ + environment: e.metadata.environment, + secretPath: e.metadata.secretPath, + ...s + })) + } + } as const; + } + + return { + name, + isBulk: false, + data: { + eventType: e.type, + payload: { + ...e.metadata, + environment: e.metadata.environment + } + } + } as const; +}; + +export const EventName = z.nativeEnum(BusEventName); + +const EventSecretPayload = z.object({ + secretPath: z.string().optional(), + secretId: z.string(), + secretKey: z.string(), + environment: z.string() +}); + +export type EventSecret = z.infer; + +export const EventSchema = z.object({ + datacontenttype: z.literal("application/json").optional().default("application/json"), + type: z.nativeEnum(ProjectType), + source: z.string(), + time: z + .string() + .optional() + .default(() => new Date().toISOString()), + data: z.discriminatedUnion("eventType", [ + z.object({ + specversion: z.number().optional().default(1), + eventType: z.enum([EventType.CREATE_SECRET, EventType.UPDATE_SECRET, EventType.DELETE_SECRET]), + payload: EventSecretPayload + }), + z.object({ + specversion: z.number().optional().default(1), + eventType: z.enum([EventType.CREATE_SECRETS, EventType.UPDATE_SECRETS, EventType.DELETE_SECRETS]), + payload: EventSecretPayload.array() + }) + // Add more event types as needed + ]) +}); + +export type EventData = z.infer; + +export const EventRegisterSchema = z.object({ + event: EventName, + conditions: z + .object({ + secretPath: z.string().optional().default("/"), + environmentSlug: z.string() + }) + .optional() +}); + +export type RegisteredEvent = z.infer; diff --git a/backend/src/ee/services/identity-auth-template/identity-auth-template-dal.ts b/backend/src/ee/services/identity-auth-template/identity-auth-template-dal.ts new file mode 100644 index 000000000..736f0f033 --- /dev/null +++ b/backend/src/ee/services/identity-auth-template/identity-auth-template-dal.ts @@ -0,0 +1,83 @@ +/* eslint-disable no-case-declarations */ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { buildFindFilter, ormify } from "@app/lib/knex"; + +import { IdentityAuthTemplateMethod } from "./identity-auth-template-enums"; + +export type TIdentityAuthTemplateDALFactory = ReturnType; + +export const identityAuthTemplateDALFactory = (db: TDbClient) => { + const identityAuthTemplateOrm = ormify(db, TableName.IdentityAuthTemplate); + + const findByOrgId = async ( + orgId: string, + { limit, offset, search, tx }: { limit?: number; offset?: number; search?: string; tx?: Knex } = {} + ) => { + let query = (tx || db.replicaNode())(TableName.IdentityAuthTemplate).where({ orgId }); + let countQuery = (tx || db.replicaNode())(TableName.IdentityAuthTemplate).where({ orgId }); + + if (search) { + const searchFilter = `%${search.toLowerCase()}%`; + query = query.whereRaw("LOWER(name) LIKE ?", [searchFilter]); + countQuery = countQuery.whereRaw("LOWER(name) LIKE ?", [searchFilter]); + } + + query = query.orderBy("createdAt", "desc"); + + if (limit !== undefined) { + query = query.limit(limit); + } + if (offset !== undefined) { + query = query.offset(offset); + } + + const docs = await query; + + const [{ count }] = (await countQuery.count("* as count")) as [{ count: string | number }]; + + return { docs, totalCount: Number(count) }; + }; + + const findByAuthMethod = async (authMethod: string, orgId: string, tx?: Knex) => { + const query = (tx || db.replicaNode())(TableName.IdentityAuthTemplate) + .where({ authMethod, orgId }) + .orderBy("createdAt", "desc"); + const docs = await query; + return docs; + }; + + const findTemplateUsages = async (templateId: string, authMethod: string, tx?: Knex) => { + switch (authMethod) { + case IdentityAuthTemplateMethod.LDAP: + const query = (tx || db.replicaNode())(TableName.IdentityLdapAuth) + .join(TableName.Identity, `${TableName.IdentityLdapAuth}.identityId`, `${TableName.Identity}.id`) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ templateId }, TableName.IdentityLdapAuth)) + .select( + db.ref("identityId").withSchema(TableName.IdentityLdapAuth), + db.ref("name").withSchema(TableName.Identity).as("identityName") + ); + const docs = await query; + return docs; + default: + return []; + } + }; + + const findByIdAndOrgId = async (id: string, orgId: string, tx?: Knex) => { + const query = (tx || db.replicaNode())(TableName.IdentityAuthTemplate).where({ id, orgId }); + const doc = await query; + return doc?.[0]; + }; + + return { + ...identityAuthTemplateOrm, + findByOrgId, + findByAuthMethod, + findTemplateUsages, + findByIdAndOrgId + }; +}; diff --git a/backend/src/ee/services/identity-auth-template/identity-auth-template-enums.ts b/backend/src/ee/services/identity-auth-template/identity-auth-template-enums.ts new file mode 100644 index 000000000..c5b47b158 --- /dev/null +++ b/backend/src/ee/services/identity-auth-template/identity-auth-template-enums.ts @@ -0,0 +1,22 @@ +export enum IdentityAuthTemplateMethod { + LDAP = "ldap" +} + +export const TEMPLATE_VALIDATION_MESSAGES = { + TEMPLATE_NAME_REQUIRED: "Template name is required", + TEMPLATE_NAME_MAX_LENGTH: "Template name must be at most 64 characters long", + AUTH_METHOD_REQUIRED: "Auth method is required", + TEMPLATE_ID_REQUIRED: "Template ID is required", + LDAP: { + URL_REQUIRED: "LDAP URL is required", + BIND_DN_REQUIRED: "Bind DN is required", + BIND_PASSWORD_REQUIRED: "Bind password is required", + SEARCH_BASE_REQUIRED: "Search base is required" + } +} as const; + +export const TEMPLATE_SUCCESS_MESSAGES = { + CREATED: "Template created successfully", + UPDATED: "Template updated successfully", + DELETED: "Template deleted successfully" +} as const; diff --git a/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts b/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts new file mode 100644 index 000000000..ef071742d --- /dev/null +++ b/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts @@ -0,0 +1,454 @@ +import { ForbiddenError } from "@casl/ability"; + +import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { + OrgPermissionMachineIdentityAuthTemplateActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TOrgPermission } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TIdentityLdapAuthDALFactory } from "@app/services/identity-ldap-auth/identity-ldap-auth-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TIdentityAuthTemplateDALFactory } from "./identity-auth-template-dal"; +import { IdentityAuthTemplateMethod } from "./identity-auth-template-enums"; +import { + TDeleteIdentityAuthTemplateDTO, + TFindTemplateUsagesDTO, + TGetIdentityAuthTemplateDTO, + TGetTemplatesByAuthMethodDTO, + TLdapTemplateFields, + TListIdentityAuthTemplatesDTO, + TUnlinkTemplateUsageDTO +} from "./identity-auth-template-types"; + +type TIdentityAuthTemplateServiceFactoryDep = { + identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; + identityLdapAuthDAL: TIdentityLdapAuthDALFactory; + permissionService: Pick; + kmsService: Pick; + licenseService: Pick; + auditLogService: Pick; +}; + +export type TIdentityAuthTemplateServiceFactory = ReturnType; + +export const identityAuthTemplateServiceFactory = ({ + identityAuthTemplateDAL, + identityLdapAuthDAL, + permissionService, + kmsService, + licenseService, + auditLogService +}: TIdentityAuthTemplateServiceFactoryDep) => { + // Plan check + const $checkPlan = async (orgId: string) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.machineIdentityAuthTemplates) + throw new BadRequestError({ + message: + "Failed to use identity auth template due to plan restriction. Upgrade plan to access machine identity auth templates." + }); + }; + const createTemplate = async ({ + name, + authMethod, + templateFields, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: { + name: string; + authMethod: string; + templateFields: Record; + } & Omit) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + const template = await identityAuthTemplateDAL.create({ + name, + authMethod, + templateFields: encryptor({ plainText: Buffer.from(JSON.stringify(templateFields)) }).cipherTextBlob, + orgId: actorOrgId + }); + + return { ...template, templateFields }; + }; + + const updateTemplate = async ({ + templateId, + name, + templateFields, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: { + templateId: string; + name?: string; + templateFields?: Record; + } & Omit) => { + await $checkPlan(actorOrgId); + const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + template.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: template.orgId + }); + + let finalTemplateFields: Record = {}; + + const updatedTemplate = await identityAuthTemplateDAL.transaction(async (tx) => { + const authTemplate = await identityAuthTemplateDAL.updateById( + templateId, + { + name, + ...(templateFields && { + templateFields: encryptor({ plainText: Buffer.from(JSON.stringify(templateFields)) }).cipherTextBlob + }) + }, + tx + ); + + if (templateFields && template.authMethod === IdentityAuthTemplateMethod.LDAP) { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: template.orgId + }); + + const currentTemplateFields = JSON.parse( + decryptor({ cipherTextBlob: template.templateFields }).toString() + ) as TLdapTemplateFields; + + const mergedTemplateFields: TLdapTemplateFields = { ...currentTemplateFields, ...templateFields }; + finalTemplateFields = mergedTemplateFields; + const ldapUpdateData: { + url?: string; + searchBase?: string; + encryptedBindDN?: Buffer; + encryptedBindPass?: Buffer; + encryptedLdapCaCertificate?: Buffer; + } = {}; + + if ("url" in templateFields) { + ldapUpdateData.url = mergedTemplateFields.url; + } + if ("searchBase" in templateFields) { + ldapUpdateData.searchBase = mergedTemplateFields.searchBase; + } + if ("bindDN" in templateFields) { + ldapUpdateData.encryptedBindDN = encryptor({ + plainText: Buffer.from(mergedTemplateFields.bindDN) + }).cipherTextBlob; + } + if ("bindPass" in templateFields) { + ldapUpdateData.encryptedBindPass = encryptor({ + plainText: Buffer.from(mergedTemplateFields.bindPass) + }).cipherTextBlob; + } + if ("ldapCaCertificate" in templateFields) { + ldapUpdateData.encryptedLdapCaCertificate = encryptor({ + plainText: Buffer.from(mergedTemplateFields.ldapCaCertificate || "") + }).cipherTextBlob; + } + + if (Object.keys(ldapUpdateData).length > 0) { + const updatedLdapAuths = await identityLdapAuthDAL.update({ templateId }, ldapUpdateData, tx); + await Promise.all( + updatedLdapAuths.map(async (updatedLdapAuth) => { + await auditLogService.createAuditLog({ + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + orgId: actorOrgId, + event: { + type: EventType.UPDATE_IDENTITY_LDAP_AUTH, + metadata: { + identityId: updatedLdapAuth.identityId, + templateId: template.id + } + } + }); + }) + ); + } + } + return authTemplate; + }); + + return { ...updatedTemplate, templateFields: finalTemplateFields }; + }; + + const deleteTemplate = async ({ + templateId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TDeleteIdentityAuthTemplateDTO) => { + await $checkPlan(actorOrgId); + const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + template.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const deletedTemplate = await identityAuthTemplateDAL.transaction(async (tx) => { + // Remove template reference from identityLdapAuth records + const updatedLdapAuths = await identityLdapAuthDAL.update({ templateId }, { templateId: null }, tx); + await Promise.all( + updatedLdapAuths.map(async (updatedLdapAuth) => { + await auditLogService.createAuditLog({ + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + orgId: actorOrgId, + event: { + type: EventType.UPDATE_IDENTITY_LDAP_AUTH, + metadata: { + identityId: updatedLdapAuth.identityId, + templateId: template.id + } + } + }); + }) + ); + + // Delete the template + const [deletedTpl] = await identityAuthTemplateDAL.delete({ id: templateId }, tx); + return deletedTpl; + }); + + return deletedTemplate; + }; + + const getTemplate = async ({ + templateId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetIdentityAuthTemplateDTO) => { + await $checkPlan(actorOrgId); + const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + template.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: template.orgId + }); + const decryptedTemplateFields = decryptor({ cipherTextBlob: template.templateFields }).toString(); + return { + ...template, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + templateFields: JSON.parse(decryptedTemplateFields) + }; + }; + + const listTemplates = async ({ + limit, + offset, + search, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListIdentityAuthTemplatesDTO) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const { docs, totalCount } = await identityAuthTemplateDAL.findByOrgId(actorOrgId, { limit, offset, search }); + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + return { + totalCount, + templates: docs.map((doc) => ({ + ...doc, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + templateFields: JSON.parse(decryptor({ cipherTextBlob: doc.templateFields }).toString()) + })) + }; + }; + + const getTemplatesByAuthMethod = async ({ + authMethod, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetTemplatesByAuthMethodDTO) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const docs = await identityAuthTemplateDAL.findByAuthMethod(authMethod, actorOrgId); + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + return docs.map((doc) => ({ + ...doc, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + templateFields: JSON.parse(decryptor({ cipherTextBlob: doc.templateFields }).toString()) + })); + }; + + const findTemplateUsages = async ({ + templateId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TFindTemplateUsagesDTO) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + const docs = await identityAuthTemplateDAL.findTemplateUsages(templateId, template.authMethod); + return docs; + }; + + const unlinkTemplateUsage = async ({ + templateId, + identityIds, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUnlinkTemplateUsageDTO) => { + await $checkPlan(actorOrgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + + const template = await identityAuthTemplateDAL.findByIdAndOrgId(templateId, actorOrgId); + if (!template) { + throw new NotFoundError({ message: "Template not found" }); + } + + switch (template.authMethod) { + case IdentityAuthTemplateMethod.LDAP: + await identityLdapAuthDAL.update({ $in: { identityId: identityIds }, templateId }, { templateId: null }); + break; + default: + break; + } + }; + + return { + createTemplate, + updateTemplate, + deleteTemplate, + getTemplate, + listTemplates, + getTemplatesByAuthMethod, + findTemplateUsages, + unlinkTemplateUsage + }; +}; diff --git a/backend/src/ee/services/identity-auth-template/identity-auth-template-types.ts b/backend/src/ee/services/identity-auth-template/identity-auth-template-types.ts new file mode 100644 index 000000000..8039e41c2 --- /dev/null +++ b/backend/src/ee/services/identity-auth-template/identity-auth-template-types.ts @@ -0,0 +1,61 @@ +import { TProjectPermission } from "@app/lib/types"; + +import { IdentityAuthTemplateMethod } from "./identity-auth-template-enums"; + +// Method-specific template field types +export type TLdapTemplateFields = { + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + ldapCaCertificate?: string; +}; + +// Union type for all template field types +export type TTemplateFieldsByMethod = { + [IdentityAuthTemplateMethod.LDAP]: TLdapTemplateFields; +}; + +// Generic base types that use conditional types for type safety +export type TCreateIdentityAuthTemplateDTO = { + name: string; + authMethod: IdentityAuthTemplateMethod; + templateFields: TTemplateFieldsByMethod[IdentityAuthTemplateMethod]; +} & Omit; + +export type TUpdateIdentityAuthTemplateDTO = { + templateId: string; + name?: string; + templateFields?: Partial; +} & Omit; + +export type TDeleteIdentityAuthTemplateDTO = { + templateId: string; +} & Omit; + +export type TGetIdentityAuthTemplateDTO = { + templateId: string; +} & Omit; + +export type TListIdentityAuthTemplatesDTO = { + limit?: number; + offset?: number; + search?: string; +} & Omit; + +export type TGetTemplatesByAuthMethodDTO = { + authMethod: string; +} & Omit; + +export type TFindTemplateUsagesDTO = { + templateId: string; +} & Omit; + +export type TUnlinkTemplateUsageDTO = { + templateId: string; + identityIds: string[]; +} & Omit; + +// Specific LDAP types for convenience +export type TCreateLdapTemplateDTO = TCreateIdentityAuthTemplateDTO; +export type TUpdateLdapTemplateDTO = TUpdateIdentityAuthTemplateDTO; diff --git a/backend/src/ee/services/identity-auth-template/index.ts b/backend/src/ee/services/identity-auth-template/index.ts new file mode 100644 index 000000000..4358d1a27 --- /dev/null +++ b/backend/src/ee/services/identity-auth-template/index.ts @@ -0,0 +1,6 @@ +export type { TIdentityAuthTemplateDALFactory } from "./identity-auth-template-dal"; +export { identityAuthTemplateDALFactory } from "./identity-auth-template-dal"; +export * from "./identity-auth-template-enums"; +export type { TIdentityAuthTemplateServiceFactory } from "./identity-auth-template-service"; +export { identityAuthTemplateServiceFactory } from "./identity-auth-template-service"; +export type * from "./identity-auth-template-types"; diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index 5259d4616..4c42ad7a2 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -31,7 +31,8 @@ export const getDefaultOnPremFeatures = () => { caCrl: false, sshHostGroups: false, enterpriseSecretSyncs: false, - enterpriseAppConnections: false + enterpriseAppConnections: false, + machineIdentityAuthTemplates: false }; }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 5b755567b..bd3949a7e 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -59,7 +59,9 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ secretScanning: false, enterpriseSecretSyncs: false, enterpriseAppConnections: false, - fips: false + fips: false, + eventSubscriptions: false, + machineIdentityAuthTemplates: false }); export const setupLicenseRequestWithStore = ( diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 7e784d9ad..891fa208f 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -5,13 +5,14 @@ // TODO(akhilmhdh): With tony find out the api structure and fill it here import { ForbiddenError } from "@casl/ability"; +import { AxiosError } from "axios"; import { CronJob } from "cron"; import { Knex } from "knex"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { verifyOfflineLicense } from "@app/lib/crypto"; -import { NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TIdentityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; @@ -603,10 +604,22 @@ export const licenseServiceFactory = ({ }); } - const { data } = await licenseServerCloudApi.request.delete( - `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods/${pmtMethodId}` - ); - return data; + try { + const { data } = await licenseServerCloudApi.request.delete( + `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods/${pmtMethodId}` + ); + return data; + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message: `Failed to remove payment method: ${error.response?.data?.message}` + }); + } + throw new BadRequestError({ + message: "Unable to remove payment method" + }); + } }; const getOrgTaxIds = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgTaxIdDTO) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index a3412f574..098d00feb 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -75,7 +75,9 @@ export type TFeatureSet = { secretScanning: false; enterpriseSecretSyncs: false; enterpriseAppConnections: false; + machineIdentityAuthTemplates: false; fips: false; + eventSubscriptions: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index cca4efaf2..11d8e0518 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -161,7 +161,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Edit, - ProjectPermissionSecretActions.Delete + ProjectPermissionSecretActions.Delete, + ProjectPermissionSecretActions.Subscribe ], ProjectPermissionSub.Secrets ); @@ -265,7 +266,8 @@ const buildMemberPermissionRules = () => { ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Edit, ProjectPermissionSecretActions.Create, - ProjectPermissionSecretActions.Delete + ProjectPermissionSecretActions.Delete, + ProjectPermissionSecretActions.Subscribe ], ProjectPermissionSub.Secrets ); diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index f0fe73d71..2436dae2a 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -28,6 +28,15 @@ export enum OrgPermissionKmipActions { Setup = "setup" } +export enum OrgPermissionMachineIdentityAuthTemplateActions { + ListTemplates = "list-templates", + EditTemplates = "edit-templates", + CreateTemplates = "create-templates", + DeleteTemplates = "delete-templates", + UnlinkTemplates = "unlink-templates", + AttachTemplates = "attach-templates" +} + export enum OrgPermissionAdminConsoleAction { AccessAllProjects = "access-all-projects" } @@ -88,6 +97,7 @@ export enum OrgPermissionSubjects { Identity = "identity", Kms = "kms", AdminConsole = "organization-admin-console", + MachineIdentityAuthTemplate = "machine-identity-auth-template", AuditLogs = "audit-logs", ProjectTemplates = "project-templates", AppConnections = "app-connections", @@ -126,6 +136,7 @@ export type OrgPermissionSet = ) ] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] + | [OrgPermissionMachineIdentityAuthTemplateActions, OrgPermissionSubjects.MachineIdentityAuthTemplate] | [OrgPermissionKmipActions, OrgPermissionSubjects.Kmip] | [OrgPermissionSecretShareAction, OrgPermissionSubjects.SecretShare]; @@ -237,6 +248,14 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ "Describe what action an entity can take." ) }), + z.object({ + subject: z + .literal(OrgPermissionSubjects.MachineIdentityAuthTemplate) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionMachineIdentityAuthTemplateActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(OrgPermissionSubjects.Gateway).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionGatewayActions).describe( @@ -350,6 +369,25 @@ const buildAdminPermission = () => { // the proxy assignment is temporary in order to prevent "more privilege" error during role assignment to MI can(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); + can(OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate); + can(OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate); + can( + OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can( + OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can( + OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can( + OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can(OrgPermissionSecretShareAction.ManageSettings, OrgPermissionSubjects.SecretShare); return rules; @@ -385,6 +423,16 @@ const buildMemberPermission = () => { can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway); can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway); + can(OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate); + can( + OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + can( + OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + return rules; }; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 966146c27..899d364eb 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -36,7 +36,8 @@ export enum ProjectPermissionSecretActions { ReadValue = "readValue", Create = "create", Edit = "edit", - Delete = "delete" + Delete = "delete", + Subscribe = "subscribe" } export enum ProjectPermissionCmekActions { @@ -204,6 +205,7 @@ export type SecretSubjectFields = { secretPath: string; secretName?: string; secretTags?: string[]; + eventType?: string; }; export type SecretFolderSubjectFields = { @@ -483,7 +485,17 @@ const SecretConditionV2Schema = z .object({ [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] }) - .partial() + .partial(), + eventType: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]) }) .partial(); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index d1eefe7e3..1d1ca0a00 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -65,7 +65,10 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; -import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns"; +import { + hasSecretReadValueOrDescribePermission, + throwIfMissingSecretReadValueOrDescribePermission +} from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission"; import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; @@ -277,13 +280,19 @@ export const secretApprovalRequestServiceFactory = ({ ) { throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); } - - const hasSecretReadAccess = permission.can( - ProjectPermissionSecretActions.DescribeAndReadValue, - ProjectPermissionSub.Secrets - ); + const getHasSecretReadAccess = (environment: string, tags: { slug: string }[], secretPath?: string) => { + const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath: secretPath || "/", + secretTags: tags.map((i) => i.slug) + }); + return canRead; + }; let secrets; + const secretPath = await folderDAL.findSecretPathByFolderIds(secretApprovalRequest.projectId, [ + secretApprovalRequest.folderId + ]); if (shouldUseSecretV2Bridge) { const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -299,8 +308,8 @@ export const secretApprovalRequestServiceFactory = ({ version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, isRotatedSecret: el.secret?.isRotatedSecret ?? false, - secretValueHidden: !hasSecretReadAccess, - secretValue: !hasSecretReadAccess + secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path), + secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secret && el.secret.isRotatedSecret ? undefined @@ -315,8 +324,12 @@ export const secretApprovalRequestServiceFactory = ({ secretKey: el.secret.key, id: el.secret.id, version: el.secret.version, - secretValueHidden: !hasSecretReadAccess, - secretValue: !hasSecretReadAccess + secretValueHidden: !getHasSecretReadAccess( + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ), + secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secret.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString() @@ -331,8 +344,12 @@ export const secretApprovalRequestServiceFactory = ({ secretKey: el.secretVersion.key, id: el.secretVersion.id, version: el.secretVersion.version, - secretValueHidden: !hasSecretReadAccess, - secretValue: !hasSecretReadAccess + secretValueHidden: !getHasSecretReadAccess( + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ), + secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secretVersion.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString() @@ -350,7 +367,7 @@ export const secretApprovalRequestServiceFactory = ({ const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); secrets = encryptedSecrets.map((el) => ({ ...el, - secretValueHidden: !hasSecretReadAccess, + secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path), ...decryptSecretWithBot(el, botKey), secret: el.secret ? { @@ -370,9 +387,6 @@ export const secretApprovalRequestServiceFactory = ({ : undefined })); } - const secretPath = await folderDAL.findSecretPathByFolderIds(secretApprovalRequest.projectId, [ - secretApprovalRequest.folderId - ]); return { ...secretApprovalRequest, secretPath: secretPath?.[0]?.path || "/", commits: secrets }; }; diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts index e2969ac1d..84fcfdd90 100644 --- a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts @@ -21,6 +21,8 @@ const GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"; type AzureErrorResponse = { error: { message: string } }; +const EXPIRY_PADDING_IN_DAYS = 3; + const sleep = async () => new Promise((resolve) => { setTimeout(resolve, 1000); @@ -33,7 +35,8 @@ export const azureClientSecretRotationFactory: TRotationFactory< const { connection, parameters: { objectId, clientId: clientIdParam }, - secretsMapping + secretsMapping, + rotationInterval } = secretRotation; /** @@ -50,7 +53,7 @@ export const azureClientSecretRotationFactory: TRotationFactory< )}-${now.getFullYear()}`; const endDateTime = new Date(); - endDateTime.setFullYear(now.getFullYear() + 5); + endDateTime.setDate(now.getDate() + rotationInterval * 2 + EXPIRY_PADDING_IN_DAYS); // give 72 hour buffer try { const { data } = await request.post( @@ -195,6 +198,12 @@ export const azureClientSecretRotationFactory: TRotationFactory< callback ) => { const credentials = await $rotateClientSecret(); + + // 2.5 years as expiry is set to x2 interval for the inactive period of credential + if (rotationInterval > Math.floor(365 * 2.5) - EXPIRY_PADDING_IN_DAYS) { + throw new BadRequestError({ message: "Azure does not support token duration over 5 years" }); + } + return callback(credentials); }; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts index c3e7a12fb..787c07bae 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts @@ -51,6 +51,7 @@ const baseSecretRotationV2Query = ({ db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"), db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("connectionGatewayId"), db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), db @@ -104,6 +105,7 @@ const expandSecretRotation = { ); }; + const validateConfigUpdate: TSecretScanningFactoryValidateConfigUpdate< + TBitbucketDataSourceInput["config"], + TBitbucketDataSourceWithConnection + > = async () => { + // no validation required + }; + return { initialize, postInitialization, @@ -309,6 +317,7 @@ export const BitbucketSecretScanningFactory = () => { getFullScanPath, getDiffScanResourcePayload, getDiffScanFindingsPayload, - teardown + teardown, + validateConfigUpdate }; }; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts index e09b8f88b..175e5d8cc 100644 --- a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts @@ -20,7 +20,8 @@ import { TSecretScanningFactoryInitialize, TSecretScanningFactoryListRawResources, TSecretScanningFactoryPostInitialization, - TSecretScanningFactoryTeardown + TSecretScanningFactoryTeardown, + TSecretScanningFactoryValidateConfigUpdate } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; @@ -64,7 +65,14 @@ export const GitHubSecretScanningFactory = () => { }; const teardown: TSecretScanningFactoryTeardown = async () => { - // no termination required + // no teardown required + }; + + const validateConfigUpdate: TSecretScanningFactoryValidateConfigUpdate< + TGitHubDataSourceInput["config"], + TGitHubDataSourceWithConnection + > = async () => { + // no validation required }; const listRawResources: TSecretScanningFactoryListRawResources = async ( @@ -238,6 +246,7 @@ export const GitHubSecretScanningFactory = () => { getFullScanPath, getDiffScanResourcePayload, getDiffScanFindingsPayload, - teardown + teardown, + validateConfigUpdate }; }; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-constants.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-constants.ts new file mode 100644 index 000000000..b472a2f3f --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-constants.ts @@ -0,0 +1,9 @@ +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TSecretScanningDataSourceListItem } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const GITLAB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION: TSecretScanningDataSourceListItem = { + name: "GitLab", + type: SecretScanningDataSource.GitLab, + connection: AppConnection.GitLab +}; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums.ts new file mode 100644 index 000000000..89caff802 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums.ts @@ -0,0 +1,8 @@ +export enum GitLabDataSourceScope { + Project = "project", + Group = "group" +} + +export enum GitLabWebHookEvent { + Push = "Push Hook" +} diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-factory.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-factory.ts new file mode 100644 index 000000000..6ea294c1f --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-factory.ts @@ -0,0 +1,409 @@ +import { Camelize, GitbeakerRequestError, GroupHookSchema, ProjectHookSchema } from "@gitbeaker/rest"; +import { join } from "path"; + +import { scanContentAndGetFindings } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { SecretMatch } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { + SecretScanningFindingSeverity, + SecretScanningResource +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + cloneRepository, + convertPatchLineToFileLineNumber, + replaceNonChangesWithNewlines +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { + TSecretScanningFactoryGetDiffScanFindingsPayload, + TSecretScanningFactoryGetDiffScanResourcePayload, + TSecretScanningFactoryGetFullScanPath, + TSecretScanningFactoryInitialize, + TSecretScanningFactoryListRawResources, + TSecretScanningFactoryParams, + TSecretScanningFactoryPostInitialization, + TSecretScanningFactoryTeardown, + TSecretScanningFactoryValidateConfigUpdate +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { titleCaseToCamelCase } from "@app/lib/fn"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { GitLabProjectRegex } from "@app/lib/regex"; +import { + getGitLabConnectionClient, + getGitLabInstanceUrl, + TGitLabConnection +} from "@app/services/app-connection/gitlab"; + +import { GitLabDataSourceScope } from "./gitlab-secret-scanning-enums"; +import { + TGitLabDataSourceCredentials, + TGitLabDataSourceInput, + TGitLabDataSourceWithConnection, + TQueueGitLabResourceDiffScan +} from "./gitlab-secret-scanning-types"; + +const getMainDomain = (instanceUrl: string) => { + const url = new URL(instanceUrl); + const { hostname } = url; + const parts = hostname.split("."); + + if (parts.length >= 2) { + return parts.slice(-2).join("."); + } + + return hostname; +}; + +export const GitLabSecretScanningFactory = ({ appConnectionDAL, kmsService }: TSecretScanningFactoryParams) => { + const initialize: TSecretScanningFactoryInitialize< + TGitLabDataSourceInput, + TGitLabConnection, + TGitLabDataSourceCredentials + > = async ({ payload: { config, name }, connection }, callback) => { + const token = alphaNumericNanoId(64); + + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + const appCfg = getConfig(); + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectId } = config; + const project = await client.Projects.show(projectId); + + if (!project) { + throw new BadRequestError({ message: `Could not find project with ID ${projectId}.` }); + } + + let hook: Camelize; + try { + hook = await client.ProjectHooks.add(projectId, `${appCfg.SITE_URL}/secret-scanning/webhooks/gitlab`, { + token, + pushEvents: true, + enableSslVerification: true, + // @ts-expect-error gitbeaker is outdated, and the types don't support this field yet + name: `Infisical Secret Scanning - ${name}` + }); + } catch (error) { + if (error instanceof GitbeakerRequestError) { + throw new BadRequestError({ message: `${error.message}: ${error.cause?.description ?? "Unknown Error"}` }); + } + + throw error; + } + + try { + return await callback({ + credentials: { + token, + hookId: hook.id + } + }); + } catch (error) { + try { + await client.ProjectHooks.remove(projectId, hook.id); + } catch { + // do nothing, just try to clean up webhook + } + + throw error; + } + } + + // group scope + const { groupId } = config; + + const group = await client.Groups.show(groupId); + + if (!group) { + throw new BadRequestError({ message: `Could not find group with ID ${groupId}.` }); + } + + let hook: Camelize; + try { + hook = await client.GroupHooks.add(groupId, `${appCfg.SITE_URL}/secret-scanning/webhooks/gitlab`, { + token, + pushEvents: true, + enableSslVerification: true, + // @ts-expect-error gitbeaker is outdated, and the types don't support this field yet + name: `Infisical Secret Scanning - ${name}` + }); + } catch (error) { + if (error instanceof GitbeakerRequestError) { + throw new BadRequestError({ message: `${error.message}: ${error.cause?.description ?? "Unknown Error"}` }); + } + + throw error; + } + + try { + return await callback({ + credentials: { + token, + hookId: hook.id + } + }); + } catch (error) { + try { + await client.GroupHooks.remove(groupId, hook.id); + } catch { + // do nothing, just try to clean up webhook + } + + throw error; + } + }; + + const postInitialization: TSecretScanningFactoryPostInitialization< + TGitLabDataSourceInput, + TGitLabConnection, + TGitLabDataSourceCredentials + > = async ({ connection, dataSourceId, credentials, payload: { config } }) => { + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + const appCfg = getConfig(); + + const hookUrl = `${appCfg.SITE_URL}/secret-scanning/webhooks/gitlab`; + const { hookId } = credentials; + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectId } = config; + + try { + await client.ProjectHooks.edit(projectId, hookId, hookUrl, { + // @ts-expect-error gitbeaker is outdated, and the types don't support this field yet + name: `Infisical Secret Scanning - ${dataSourceId}`, + custom_headers: [{ key: "x-data-source-id", value: dataSourceId }] + }); + } catch (error) { + try { + await client.ProjectHooks.remove(projectId, hookId); + } catch { + // do nothing, just try to clean up webhook + } + + throw error; + } + + return; + } + + // group-scope + const { groupId } = config; + + try { + await client.GroupHooks.edit(groupId, hookId, hookUrl, { + // @ts-expect-error gitbeaker is outdated, and the types don't support this field yet + name: `Infisical Secret Scanning - ${dataSourceId}`, + custom_headers: [{ key: "x-data-source-id", value: dataSourceId }] + }); + } catch (error) { + try { + await client.GroupHooks.remove(groupId, hookId); + } catch { + // do nothing, just try to clean up webhook + } + + throw error; + } + }; + + const listRawResources: TSecretScanningFactoryListRawResources = async ( + dataSource + ) => { + const { connection, config } = dataSource; + + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectId } = config; + + const project = await client.Projects.show(projectId); + + if (!project) { + throw new BadRequestError({ message: `Could not find project with ID ${projectId}.` }); + } + + // scott: even though we have this data we want to get potentially updated name + return [ + { + name: project.pathWithNamespace, + externalId: project.id.toString(), + type: SecretScanningResource.Project + } + ]; + } + + // group-scope + + const { groupId, includeProjects } = config; + + const projects = await client.Groups.allProjects(groupId, { + archived: false + }); + + const filteredProjects: typeof projects = []; + if (!includeProjects || includeProjects.includes("*")) { + filteredProjects.push(...projects); + } else { + filteredProjects.push(...projects.filter((project) => includeProjects.includes(project.pathWithNamespace))); + } + + return filteredProjects.map(({ id, pathWithNamespace }) => ({ + name: pathWithNamespace, + externalId: id.toString(), + type: SecretScanningResource.Project + })); + }; + + const getFullScanPath: TSecretScanningFactoryGetFullScanPath = async ({ + dataSource, + resourceName, + tempFolder + }) => { + const { connection } = dataSource; + + const instanceUrl = await getGitLabInstanceUrl(connection.credentials.instanceUrl); + + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + + const user = await client.Users.showCurrentUser(); + + const repoPath = join(tempFolder, "repo.git"); + + if (!GitLabProjectRegex.test(resourceName)) { + throw new Error("Invalid GitLab project name"); + } + + await cloneRepository({ + cloneUrl: `https://${user.username}:${connection.credentials.accessToken}@${getMainDomain(instanceUrl)}/${resourceName}.git`, + repoPath + }); + + return repoPath; + }; + + const teardown: TSecretScanningFactoryTeardown< + TGitLabDataSourceWithConnection, + TGitLabDataSourceCredentials + > = async ({ dataSource: { connection, config }, credentials: { hookId } }) => { + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectId } = config; + try { + await client.ProjectHooks.remove(projectId, hookId); + } catch (error) { + // do nothing, just try to clean up webhook + } + return; + } + + const { groupId } = config; + try { + await client.GroupHooks.remove(groupId, hookId); + } catch (error) { + // do nothing, just try to clean up webhook + } + }; + + const getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload< + TQueueGitLabResourceDiffScan["payload"] + > = ({ project }) => { + return { + name: project.path_with_namespace, + externalId: project.id.toString(), + type: SecretScanningResource.Project + }; + }; + + const getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload< + TGitLabDataSourceWithConnection, + TQueueGitLabResourceDiffScan["payload"] + > = async ({ dataSource, payload, resourceName, configPath }) => { + const { connection } = dataSource; + + const client = await getGitLabConnectionClient(connection, appConnectionDAL, kmsService); + + const { commits, project } = payload; + + const allFindings: SecretMatch[] = []; + + for (const commit of commits) { + // eslint-disable-next-line no-await-in-loop + const commitDiffs = await client.Commits.showDiff(project.id, commit.id); + + for (const commitDiff of commitDiffs) { + // eslint-disable-next-line no-continue + if (commitDiff.deletedFile) continue; + + // eslint-disable-next-line no-await-in-loop + const findings = await scanContentAndGetFindings( + replaceNonChangesWithNewlines(`\n${commitDiff.diff}`), + configPath + ); + + const adjustedFindings = findings.map((finding) => { + const startLine = convertPatchLineToFileLineNumber(commitDiff.diff, finding.StartLine); + const endLine = + finding.StartLine === finding.EndLine + ? startLine + : convertPatchLineToFileLineNumber(commitDiff.diff, finding.EndLine); + const startColumn = finding.StartColumn - 1; // subtract 1 for + + const endColumn = finding.EndColumn - 1; // subtract 1 for + + const authorName = commit.author.name; + const authorEmail = commit.author.email; + + return { + ...finding, + StartLine: startLine, + EndLine: endLine, + StartColumn: startColumn, + EndColumn: endColumn, + File: commitDiff.newPath, + Commit: commit.id, + Author: authorName, + Email: authorEmail, + Message: commit.message, + Fingerprint: `${commit.id}:${commitDiff.newPath}:${finding.RuleID}:${startLine}:${startColumn}`, + Date: commit.timestamp, + Link: `https://gitlab.com/${resourceName}/blob/${commit.id}/${commitDiff.newPath}#L${startLine}` + }; + }); + + allFindings.push(...adjustedFindings); + } + } + + return allFindings.map( + ({ + // discard match and secret as we don't want to store + Match, + Secret, + ...finding + }) => ({ + details: titleCaseToCamelCase(finding), + fingerprint: finding.Fingerprint, + severity: SecretScanningFindingSeverity.High, + rule: finding.RuleID + }) + ); + }; + + const validateConfigUpdate: TSecretScanningFactoryValidateConfigUpdate< + TGitLabDataSourceInput["config"], + TGitLabDataSourceWithConnection + > = async ({ config, dataSource }) => { + if (dataSource.config.scope !== config.scope) { + throw new BadRequestError({ message: "Cannot change Data Source scope after creation." }); + } + }; + + return { + listRawResources, + getFullScanPath, + initialize, + postInitialization, + teardown, + getDiffScanResourcePayload, + getDiffScanFindingsPayload, + validateConfigUpdate + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-schemas.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-schemas.ts new file mode 100644 index 000000000..e70e63c11 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-schemas.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; + +import { GitLabDataSourceScope } from "@app/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums"; +import { + SecretScanningDataSource, + SecretScanningResource +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + BaseCreateSecretScanningDataSourceSchema, + BaseSecretScanningDataSourceSchema, + BaseSecretScanningFindingSchema, + BaseUpdateSecretScanningDataSourceSchema, + GitRepositoryScanFindingDetailsSchema +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-schemas"; +import { SecretScanningDataSources } from "@app/lib/api-docs"; +import { GitLabProjectRegex } from "@app/lib/regex"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const GitLabDataSourceConfigSchema = z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(GitLabDataSourceScope.Group).describe(SecretScanningDataSources.CONFIG.GITLAB.scope), + groupId: z.number().describe(SecretScanningDataSources.CONFIG.GITLAB.groupId), + groupName: z.string().trim().max(256).optional().describe(SecretScanningDataSources.CONFIG.GITLAB.groupName), + includeProjects: z + .array( + z + .string() + .min(1) + .max(256) + .refine((value) => value === "*" || GitLabProjectRegex.test(value), "Invalid project name format") + ) + .nonempty("One or more projects required") + .max(100, "Cannot configure more than 100 projects") + .default(["*"]) + .describe(SecretScanningDataSources.CONFIG.GITLAB.includeProjects) + }), + z.object({ + scope: z.literal(GitLabDataSourceScope.Project).describe(SecretScanningDataSources.CONFIG.GITLAB.scope), + projectName: z.string().trim().max(256).optional().describe(SecretScanningDataSources.CONFIG.GITLAB.projectName), + projectId: z.number().describe(SecretScanningDataSources.CONFIG.GITLAB.projectId) + }) +]); + +export const GitLabDataSourceSchema = BaseSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.GitLab, + isConnectionRequired: true +}) + .extend({ + config: GitLabDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "GitLab" + }) + ); + +export const CreateGitLabDataSourceSchema = BaseCreateSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.GitLab, + isConnectionRequired: true +}) + .extend({ + config: GitLabDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "GitLab" + }) + ); + +export const UpdateGitLabDataSourceSchema = BaseUpdateSecretScanningDataSourceSchema(SecretScanningDataSource.GitLab) + .extend({ + config: GitLabDataSourceConfigSchema.optional() + }) + .describe( + JSON.stringify({ + title: "GitLab" + }) + ); + +export const GitLabDataSourceListItemSchema = z + .object({ + name: z.literal("GitLab"), + connection: z.literal(AppConnection.GitLab), + type: z.literal(SecretScanningDataSource.GitLab) + }) + .describe( + JSON.stringify({ + title: "GitLab" + }) + ); + +export const GitLabFindingSchema = BaseSecretScanningFindingSchema.extend({ + resourceType: z.literal(SecretScanningResource.Project), + dataSourceType: z.literal(SecretScanningDataSource.GitLab), + details: GitRepositoryScanFindingDetailsSchema +}); + +export const GitLabDataSourceCredentialsSchema = z.object({ + token: z.string(), + hookId: z.number() +}); diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-service.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-service.ts new file mode 100644 index 000000000..bc669a2f5 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-service.ts @@ -0,0 +1,94 @@ +import { GitLabDataSourceScope } from "@app/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums"; +import { TSecretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TSecretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue"; +import { logger } from "@app/lib/logger"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { + TGitLabDataSource, + TGitLabDataSourceCredentials, + THandleGitLabPushEvent +} from "./gitlab-secret-scanning-types"; + +export const gitlabSecretScanningService = ( + secretScanningV2DAL: TSecretScanningV2DALFactory, + secretScanningV2Queue: Pick, + kmsService: Pick +) => { + const handlePushEvent = async ({ payload, token, dataSourceId }: THandleGitLabPushEvent) => { + if (!payload.total_commits_count || !payload.project) { + logger.warn( + `secretScanningV2PushEvent: GitLab - Insufficient data [changes=${ + payload.total_commits_count ?? 0 + }] [projectName=${payload.project?.path_with_namespace ?? "unknown"}] [projectId=${payload.project?.id ?? "unknown"}]` + ); + return; + } + + const dataSource = (await secretScanningV2DAL.dataSources.findOne({ + id: dataSourceId, + type: SecretScanningDataSource.GitLab + })) as TGitLabDataSource | undefined; + + if (!dataSource) { + logger.error( + `secretScanningV2PushEvent: GitLab - Could not find data source [dataSourceId=${dataSourceId}] [projectId=${payload.project.id}]` + ); + return; + } + + const { isAutoScanEnabled, config, encryptedCredentials, projectId } = dataSource; + + if (!encryptedCredentials) { + logger.info( + `secretScanningV2PushEvent: GitLab - Could not find encrypted credentials [dataSourceId=${dataSource.id}] [projectId=${payload.project.id}]` + ); + return; + } + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedCredentials = decryptor({ cipherTextBlob: encryptedCredentials }); + + const credentials = JSON.parse(decryptedCredentials.toString()) as TGitLabDataSourceCredentials; + + if (token !== credentials.token) { + logger.error( + `secretScanningV2PushEvent: GitLab - Invalid webhook token [dataSourceId=${dataSource.id}] [projectId=${payload.project.id}]` + ); + return; + } + + if (!isAutoScanEnabled) { + logger.info( + `secretScanningV2PushEvent: GitLab - ignoring due to auto scan disabled [dataSourceId=${dataSource.id}] [projectId=${payload.project.id}]` + ); + return; + } + + if ( + config.scope === GitLabDataSourceScope.Project + ? config.projectId.toString() === payload.project_id.toString() + : config.includeProjects.includes("*") || config.includeProjects.includes(payload.project.path_with_namespace) + ) { + await secretScanningV2Queue.queueResourceDiffScan({ + dataSourceType: SecretScanningDataSource.GitLab, + payload, + dataSourceId: dataSource.id + }); + } else { + logger.info( + `secretScanningV2PushEvent: GitLab - ignoring due to repository not being present in config [dataSourceId=${dataSource.id}] [projectId=${payload.project.id}]` + ); + } + }; + + return { + handlePushEvent + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-types.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-types.ts new file mode 100644 index 000000000..105757e38 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-types.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TGitLabConnection } from "@app/services/app-connection/gitlab"; + +import { + CreateGitLabDataSourceSchema, + GitLabDataSourceCredentialsSchema, + GitLabDataSourceListItemSchema, + GitLabDataSourceSchema, + GitLabFindingSchema +} from "./gitlab-secret-scanning-schemas"; + +export type TGitLabDataSource = z.infer; + +export type TGitLabDataSourceInput = z.infer; + +export type TGitLabDataSourceListItem = z.infer; + +export type TGitLabFinding = z.infer; + +export type TGitLabDataSourceWithConnection = TGitLabDataSource & { + connection: TGitLabConnection; +}; + +export type TGitLabDataSourceCredentials = z.infer; + +export type TGitLabDataSourcePushEventPayload = { + object_kind: "push"; + event_name: "push"; + before: string; + after: string; + ref: string; + ref_protected: boolean; + checkout_sha: string; + user_id: number; + user_name: string; + user_username: string; + user_email: string; + user_avatar: string; + project_id: number; + project: { + id: number; + name: string; + description: string; + web_url: string; + avatar_url: string | null; + git_ssh_url: string; + git_http_url: string; + namespace: string; + visibility_level: number; + path_with_namespace: string; + default_branch: string; + homepage: string; + url: string; + ssh_url: string; + http_url: string; + }; + repository: { + name: string; + url: string; + description: string; + homepage: string; + git_http_url: string; + git_ssh_url: string; + visibility_level: number; + }; + commits: { + id: string; + message: string; + title: string; + timestamp: string; + url: string; + author: { + name: string; + email: string; + }; + added: string[]; + modified: string[]; + removed: string[]; + }[]; + total_commits_count: number; +}; + +export type THandleGitLabPushEvent = { + payload: TGitLabDataSourcePushEventPayload; + dataSourceId: string; + token: string; +}; + +export type TQueueGitLabResourceDiffScan = { + dataSourceType: SecretScanningDataSource.GitLab; + payload: TGitLabDataSourcePushEventPayload; + dataSourceId: string; + resourceId: string; + scanId: string; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/gitlab/index.ts b/backend/src/ee/services/secret-scanning-v2/gitlab/index.ts new file mode 100644 index 000000000..00009ca3b --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/gitlab/index.ts @@ -0,0 +1,3 @@ +export * from "./gitlab-secret-scanning-constants"; +export * from "./gitlab-secret-scanning-schemas"; +export * from "./gitlab-secret-scanning-types"; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts index 447ffc22a..c6ca50c5e 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts @@ -49,6 +49,7 @@ const baseSecretScanningDataSourceQuery = ({ db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"), db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("connectionGatewayId"), db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), db @@ -82,6 +83,7 @@ const expandSecretScanningDataSource = < connectionUpdatedAt, connectionVersion, connectionIsPlatformManagedCredentials, + connectionGatewayId, ...el } = dataSource; @@ -100,7 +102,8 @@ const expandSecretScanningDataSource = < createdAt: connectionCreatedAt, updatedAt: connectionUpdatedAt, version: connectionVersion, - isPlatformManagedCredentials: connectionIsPlatformManagedCredentials + isPlatformManagedCredentials: connectionIsPlatformManagedCredentials, + gatewayId: connectionGatewayId } : undefined }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts index 40e5ea7dd..a4fd7bd34 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts @@ -1,6 +1,7 @@ export enum SecretScanningDataSource { GitHub = "github", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + GitLab = "gitlab" } export enum SecretScanningScanStatus { diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts index 2ca1f4882..40e664d01 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts @@ -1,5 +1,6 @@ import { BitbucketSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-factory"; import { GitHubSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/github/github-secret-scanning-factory"; +import { GitLabSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-factory"; import { SecretScanningDataSource } from "./secret-scanning-v2-enums"; import { @@ -19,5 +20,6 @@ type TSecretScanningFactoryImplementation = TSecretScanningFactory< export const SECRET_SCANNING_FACTORY_MAP: Record = { [SecretScanningDataSource.GitHub]: GitHubSecretScanningFactory as TSecretScanningFactoryImplementation, - [SecretScanningDataSource.Bitbucket]: BitbucketSecretScanningFactory as TSecretScanningFactoryImplementation + [SecretScanningDataSource.Bitbucket]: BitbucketSecretScanningFactory as TSecretScanningFactoryImplementation, + [SecretScanningDataSource.GitLab]: GitLabSecretScanningFactory as TSecretScanningFactoryImplementation }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts index bdda63f7a..4e277888a 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts @@ -13,6 +13,7 @@ import { import { SecretMatch } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; import { BITBUCKET_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/bitbucket"; import { GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/github"; +import { GITLAB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/gitlab"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; @@ -23,7 +24,8 @@ import { TCloneRepository, TGetFindingsPayload, TSecretScanningDataSourceListIte const SECRET_SCANNING_SOURCE_LIST_OPTIONS: Record = { [SecretScanningDataSource.GitHub]: GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION, - [SecretScanningDataSource.Bitbucket]: BITBUCKET_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION + [SecretScanningDataSource.Bitbucket]: BITBUCKET_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION, + [SecretScanningDataSource.GitLab]: GITLAB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION }; export const listSecretScanningDataSourceOptions = () => { diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts index c84d6056a..fd29e0b74 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts @@ -3,15 +3,18 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums export const SECRET_SCANNING_DATA_SOURCE_NAME_MAP: Record = { [SecretScanningDataSource.GitHub]: "GitHub", - [SecretScanningDataSource.Bitbucket]: "Bitbucket" + [SecretScanningDataSource.Bitbucket]: "Bitbucket", + [SecretScanningDataSource.GitLab]: "GitLab" }; export const SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP: Record = { [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar, - [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket + [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket, + [SecretScanningDataSource.GitLab]: AppConnection.GitLab }; export const AUTO_SYNC_DESCRIPTION_HELPER: Record = { [SecretScanningDataSource.GitHub]: { verb: "push", noun: "repositories" }, - [SecretScanningDataSource.Bitbucket]: { verb: "push", noun: "repositories" } + [SecretScanningDataSource.Bitbucket]: { verb: "push", noun: "repositories" }, + [SecretScanningDataSource.GitLab]: { verb: "push", noun: "projects" } }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts index 7e670af96..8621b039b 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts @@ -16,6 +16,7 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, InternalServerError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; import { TAppConnection } from "@app/services/app-connection/app-connection-types"; import { ActorType } from "@app/services/auth/auth-type"; @@ -48,6 +49,7 @@ type TSecretRotationV2QueueServiceFactoryDep = { projectMembershipDAL: Pick; projectDAL: Pick; kmsService: Pick; + appConnectionDAL: Pick; auditLogService: Pick; keyStore: Pick; }; @@ -62,7 +64,8 @@ export const secretScanningV2QueueServiceFactory = async ({ smtpService, kmsService, auditLogService, - keyStore + keyStore, + appConnectionDAL }: TSecretRotationV2QueueServiceFactoryDep) => { const queueDataSourceFullScan = async ( dataSource: TSecretScanningDataSourceWithConnection, @@ -71,7 +74,10 @@ export const secretScanningV2QueueServiceFactory = async ({ try { const { type } = dataSource; - const factory = SECRET_SCANNING_FACTORY_MAP[type](); + const factory = SECRET_SCANNING_FACTORY_MAP[type]({ + kmsService, + appConnectionDAL + }); const rawResources = await factory.listRawResources(dataSource); @@ -171,7 +177,10 @@ export const secretScanningV2QueueServiceFactory = async ({ let connection: TAppConnection | null = null; if (dataSource.connection) connection = await decryptAppConnection(dataSource.connection, kmsService); - const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource](); + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource]({ + kmsService, + appConnectionDAL + }); const findingsPath = join(tempFolder, "findings.json"); @@ -329,7 +338,10 @@ export const secretScanningV2QueueServiceFactory = async ({ dataSourceId, dataSourceType }: Pick) => { - const factory = SECRET_SCANNING_FACTORY_MAP[dataSourceType as SecretScanningDataSource](); + const factory = SECRET_SCANNING_FACTORY_MAP[dataSourceType as SecretScanningDataSource]({ + kmsService, + appConnectionDAL + }); const resourcePayload = factory.getDiffScanResourcePayload(payload); @@ -391,7 +403,10 @@ export const secretScanningV2QueueServiceFactory = async ({ if (!resource) throw new Error(`Resource with ID "${resourceId}" not found`); - const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource](); + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource]({ + kmsService, + appConnectionDAL + }); const tempFolder = await createTempFolder(); diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts index 41da217d8..6bef41e10 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts @@ -46,6 +46,7 @@ import { import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAppConnection } from "@app/services/app-connection/app-connection-types"; @@ -53,12 +54,14 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { bitbucketSecretScanningService } from "./bitbucket/bitbucket-secret-scanning-service"; +import { gitlabSecretScanningService } from "./gitlab/gitlab-secret-scanning-service"; import { TSecretScanningV2DALFactory } from "./secret-scanning-v2-dal"; import { TSecretScanningV2QueueServiceFactory } from "./secret-scanning-v2-queue"; export type TSecretScanningV2ServiceFactoryDep = { secretScanningV2DAL: TSecretScanningV2DALFactory; appConnectionService: Pick; + appConnectionDAL: Pick; permissionService: Pick; licenseService: Pick; secretScanningV2Queue: Pick< @@ -76,6 +79,7 @@ export const secretScanningV2ServiceFactory = ({ appConnectionService, licenseService, secretScanningV2Queue, + appConnectionDAL, kmsService }: TSecretScanningV2ServiceFactoryDep) => { const $checkListSecretScanningDataSourcesByProjectIdPermissions = async ( @@ -255,7 +259,10 @@ export const secretScanningV2ServiceFactory = ({ ); } - const factory = SECRET_SCANNING_FACTORY_MAP[payload.type](); + const factory = SECRET_SCANNING_FACTORY_MAP[payload.type]({ + appConnectionDAL, + kmsService + }); try { const createdDataSource = await factory.initialize( @@ -363,6 +370,31 @@ export const secretScanningV2ServiceFactory = ({ message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` }); + let connection: TAppConnection | null = null; + if (dataSource.connectionId) { + // validates permission to connect and app is valid for data source + connection = await appConnectionService.connectAppConnectionById( + SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[dataSource.type], + dataSource.connectionId, + actor + ); + } + + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type]({ + appConnectionDAL, + kmsService + }); + + if (payload.config) { + await factory.validateConfigUpdate({ + dataSource: { + ...dataSource, + connection + } as TSecretScanningDataSourceWithConnection, + config: payload.config as TSecretScanningDataSourceWithConnection["config"] + }); + } + try { const updatedDataSource = await secretScanningV2DAL.dataSources.updateById(dataSourceId, payload); @@ -416,7 +448,10 @@ export const secretScanningV2ServiceFactory = ({ message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` }); - const factory = SECRET_SCANNING_FACTORY_MAP[type](); + const factory = SECRET_SCANNING_FACTORY_MAP[type]({ + appConnectionDAL, + kmsService + }); let connection: TAppConnection | null = null; if (dataSource.connection) { @@ -903,6 +938,7 @@ export const secretScanningV2ServiceFactory = ({ findSecretScanningConfigByProjectId, upsertSecretScanningConfig, github: githubSecretScanningService(secretScanningV2DAL, secretScanningV2Queue), - bitbucket: bitbucketSecretScanningService(secretScanningV2DAL, secretScanningV2Queue, kmsService) + bitbucket: bitbucketSecretScanningService(secretScanningV2DAL, secretScanningV2Queue, kmsService), + gitlab: gitlabSecretScanningService(secretScanningV2DAL, secretScanningV2Queue, kmsService) }; }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts index 6bd8251b6..6c5d86d16 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts @@ -21,14 +21,25 @@ import { TGitHubFinding, TQueueGitHubResourceDiffScan } from "@app/ee/services/secret-scanning-v2/github"; +import { + TGitLabDataSource, + TGitLabDataSourceCredentials, + TGitLabDataSourceInput, + TGitLabDataSourceListItem, + TGitLabDataSourceWithConnection, + TGitLabFinding, + TQueueGitLabResourceDiffScan +} from "@app/ee/services/secret-scanning-v2/gitlab"; import { TSecretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; import { SecretScanningDataSource, SecretScanningFindingStatus, SecretScanningScanStatus } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; -export type TSecretScanningDataSource = TGitHubDataSource | TBitbucketDataSource; +export type TSecretScanningDataSource = TGitHubDataSource | TBitbucketDataSource | TGitLabDataSource; export type TSecretScanningDataSourceWithDetails = TSecretScanningDataSource & { lastScannedAt?: Date | null; @@ -52,15 +63,25 @@ export type TSecretScanningScanWithDetails = TSecretScanningScans & { export type TSecretScanningDataSourceWithConnection = | TGitHubDataSourceWithConnection - | TBitbucketDataSourceWithConnection; + | TBitbucketDataSourceWithConnection + | TGitLabDataSourceWithConnection; -export type TSecretScanningDataSourceInput = TGitHubDataSourceInput | TBitbucketDataSourceInput; +export type TSecretScanningDataSourceInput = + | TGitHubDataSourceInput + | TBitbucketDataSourceInput + | TGitLabDataSourceInput; -export type TSecretScanningDataSourceListItem = TGitHubDataSourceListItem | TBitbucketDataSourceListItem; +export type TSecretScanningDataSourceListItem = + | TGitHubDataSourceListItem + | TBitbucketDataSourceListItem + | TGitLabDataSourceListItem; -export type TSecretScanningDataSourceCredentials = TBitbucketDataSourceCredentials | undefined; +export type TSecretScanningDataSourceCredentials = + | TBitbucketDataSourceCredentials + | TGitLabDataSourceCredentials + | undefined; -export type TSecretScanningFinding = TGitHubFinding | TBitbucketFinding; +export type TSecretScanningFinding = TGitHubFinding | TBitbucketFinding | TGitLabFinding; export type TListSecretScanningDataSourcesByProjectId = { projectId: string; @@ -112,7 +133,10 @@ export type TQueueSecretScanningDataSourceFullScan = { scanId: string; }; -export type TQueueSecretScanningResourceDiffScan = TQueueGitHubResourceDiffScan | TQueueBitbucketResourceDiffScan; +export type TQueueSecretScanningResourceDiffScan = + | TQueueGitHubResourceDiffScan + | TQueueBitbucketResourceDiffScan + | TQueueGitLabResourceDiffScan; export type TQueueSecretScanningSendNotification = { dataSource: TSecretScanningDataSources; @@ -170,6 +194,11 @@ export type TSecretScanningFactoryInitialize< callback: (parameters: { credentials?: C; externalId?: string }) => Promise ) => Promise; +export type TSecretScanningFactoryValidateConfigUpdate< + C extends TSecretScanningDataSourceInput["config"], + T extends TSecretScanningDataSourceWithConnection +> = (params: { config: C; dataSource: T }) => Promise; + export type TSecretScanningFactoryPostInitialization< P extends TSecretScanningDataSourceInput, T extends TSecretScanningDataSourceWithConnection["connection"] | undefined = undefined, @@ -181,17 +210,23 @@ export type TSecretScanningFactoryTeardown< C extends TSecretScanningDataSourceCredentials = undefined > = (params: { dataSource: T; credentials: C }) => Promise; +export type TSecretScanningFactoryParams = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + export type TSecretScanningFactory< T extends TSecretScanningDataSourceWithConnection, P extends TQueueSecretScanningResourceDiffScan["payload"], I extends TSecretScanningDataSourceInput, C extends TSecretScanningDataSourceCredentials | undefined = undefined -> = () => { +> = (params: TSecretScanningFactoryParams) => { listRawResources: TSecretScanningFactoryListRawResources; getFullScanPath: TSecretScanningFactoryGetFullScanPath; initialize: TSecretScanningFactoryInitialize; postInitialization: TSecretScanningFactoryPostInitialization; teardown: TSecretScanningFactoryTeardown; + validateConfigUpdate: TSecretScanningFactoryValidateConfigUpdate; getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload

; getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload; }; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts index 671d4e16b..392a088a6 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { BitbucketDataSourceSchema, BitbucketFindingSchema } from "@app/ee/services/secret-scanning-v2/bitbucket"; import { GitHubDataSourceSchema, GitHubFindingSchema } from "@app/ee/services/secret-scanning-v2/github"; +import { GitLabDataSourceSchema, GitLabFindingSchema } from "@app/ee/services/secret-scanning-v2/gitlab"; export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [ GitHubDataSourceSchema, - BitbucketDataSourceSchema + BitbucketDataSourceSchema, + GitLabDataSourceSchema ]); export const SecretScanningFindingSchema = z.discriminatedUnion("dataSourceType", [ @@ -18,5 +20,10 @@ export const SecretScanningFindingSchema = z.discriminatedUnion("dataSourceType" JSON.stringify({ title: "Bitbucket" }) + ), + GitLabFindingSchema.describe( + JSON.stringify({ + title: "GitLab" + }) ) ]); diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 79679d256..26aff767e 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -46,7 +46,11 @@ export const KeyStorePrefixes = { IdentityAccessTokenStatusUpdate: (identityAccessTokenId: string) => `identity-access-token-status:${identityAccessTokenId}`, ServiceTokenStatusUpdate: (serviceTokenId: string) => `service-token-status:${serviceTokenId}`, - GatewayIdentityCredential: (identityId: string) => `gateway-credentials:${identityId}` + GatewayIdentityCredential: (identityId: string) => `gateway-credentials:${identityId}`, + ActiveSSEConnectionsSet: (projectId: string, identityId: string) => + `sse-connections:${projectId}:${identityId}` as const, + ActiveSSEConnections: (projectId: string, identityId: string, connectionId: string) => + `sse-connections:${projectId}:${identityId}:${connectionId}` as const }; export const KeyStoreTtls = { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 71b6afb6b..b4bdcb4fa 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -18,6 +18,7 @@ import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "@app/services/ export enum ApiDocsTags { Identities = "Identities", + IdentityTemplates = "Identity Templates", TokenAuth = "Token Auth", UniversalAuth = "Universal Auth", GcpAuth = "GCP Auth", @@ -214,6 +215,7 @@ export const LDAP_AUTH = { password: "The password of the LDAP user to login." }, ATTACH: { + templateId: "The ID of the identity auth template to attach the configuration onto.", identityId: "The ID of the identity to attach the configuration onto.", url: "The URL of the LDAP server.", allowedFields: @@ -240,7 +242,8 @@ export const LDAP_AUTH = { accessTokenTTL: "The new lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.", - accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." + accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", + templateId: "The ID of the identity auth template to update the configuration to." }, RETRIEVE: { identityId: "The ID of the identity to retrieve the configuration for." @@ -664,6 +667,10 @@ export const ORGANIZATIONS = { organizationId: "The ID of the organization to delete the membership from.", membershipId: "The ID of the membership to delete." }, + BULK_DELETE_USER_MEMBERSHIPS: { + organizationId: "The ID of the organization to delete the memberships from.", + membershipIds: "The IDs of the memberships to delete." + }, LIST_IDENTITY_MEMBERSHIPS: { orgId: "The ID of the organization to get identity memberships from.", offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.", @@ -2253,7 +2260,9 @@ export const AppConnections = { AZURE_DEVOPS: { code: "The OAuth code to use to connect with Azure DevOps.", tenantId: "The Tenant ID to use to connect with Azure DevOps.", - orgName: "The Organization name to use to connect with Azure DevOps." + orgName: "The Organization name to use to connect with Azure DevOps.", + clientId: "The Client ID to use to connect with Azure Client Secrets.", + clientSecret: "The Client Secret to use to connect with Azure Client Secrets." }, OCI: { userOcid: "The OCID (Oracle Cloud Identifier) of the user making the request.", @@ -2296,6 +2305,9 @@ export const AppConnections = { DIGITAL_OCEAN_APP_PLATFORM: { apiToken: "The API token used to authenticate with Digital Ocean App Platform." }, + NETLIFY: { + accessToken: "The Access token used to authenticate with Netlify." + }, OKTA: { instanceUrl: "The URL used to access your Okta organization.", apiToken: "The API token used to authenticate with Okta." @@ -2400,12 +2412,18 @@ export const SecretSyncs = { env: "The name of the GitHub environment." }, AZURE_KEY_VAULT: { - vaultBaseUrl: "The base URL of the Azure Key Vault to sync secrets to. Example: https://example.vault.azure.net/" + vaultBaseUrl: "The base URL of the Azure Key Vault to sync secrets to. Example: https://example.vault.azure.net/", + tenantId: "The Tenant ID to use to connect with Azure Client Secrets.", + clientId: "The Client ID to use to connect with Azure Client Secrets.", + clientSecret: "The Client Secret to use to connect with Azure Client Secrets." }, AZURE_APP_CONFIGURATION: { configurationUrl: "The URL of the Azure App Configuration to sync secrets to. Example: https://example.azconfig.io/", - label: "An optional label to assign to secrets created in Azure App Configuration." + label: "An optional label to assign to secrets created in Azure App Configuration.", + tenantId: "The Tenant ID to use to connect with Azure Client Secrets.", + clientId: "The Client ID to use to connect with Azure Client Secrets.", + clientSecret: "The Client Secret to use to connect with Azure Client Secrets." }, AZURE_DEVOPS: { devopsProjectId: "The ID of the Azure DevOps project to sync secrets to.", @@ -2521,6 +2539,13 @@ export const SecretSyncs = { workspaceSlug: "The Bitbucket Workspace slug to sync secrets to.", repositorySlug: "The Bitbucket Repository slug to sync secrets to.", environmentId: "The Bitbucket Deployment Environment uuid to sync secrets to." + }, + NETLIFY: { + accountId: "The ID of the Netlify account to sync secrets to.", + accountName: "The name of the Netlify account to sync secrets to.", + siteName: "The name of the Netlify site to sync secrets to.", + siteId: "The ID of the Netlify site to sync secrets to.", + context: "The Netlify context to sync secrets to." } } }; @@ -2702,6 +2727,14 @@ export const SecretScanningDataSources = { GITHUB: { includeRepos: 'The repositories to include when scanning. Defaults to all repositories (["*"]).' }, + GITLAB: { + includeProjects: 'The projects to include when scanning. Defaults to all projects (["*"]).', + scope: "The GitLab scope scanning should occur at (project or group level).", + projectId: "The ID of the project to scan.", + projectName: "The name of the project to scan.", + groupId: "The ID of the group to scan projects from.", + groupName: "The name of the group to scan projects from." + }, BITBUCKET: { workspaceSlug: "The workspace to scan.", includeRepos: 'The repositories to include when scanning. Defaults to all repositories (["*"]).' diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 3fa2c0f82..29883a7ad 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -59,6 +59,7 @@ const envSchema = z AUDIT_LOGS_DB_ROOT_CERT: zpStr( z.string().describe("Postgres database base64-encoded CA cert for Audit logs").optional() ), + DISABLE_AUDIT_LOG_STORAGE: zodStrBool.default("false").optional().describe("Disable audit log storage"), MAX_LEASE_LIMIT: z.coerce.number().default(10000), DB_ROOT_CERT: zpStr(z.string().describe("Postgres database base64-encoded CA cert").optional()), DB_HOST: zpStr(z.string().describe("Postgres database host").optional()), @@ -482,6 +483,15 @@ export const overwriteSchema: { fields: { key: keyof TEnvConfig; description?: string }[]; }; } = { + auditLogs: { + name: "Audit Logs", + fields: [ + { + key: "DISABLE_AUDIT_LOG_STORAGE", + description: "Disable audit log storage" + } + ] + }, aws: { name: "AWS", fields: [ @@ -496,7 +506,7 @@ export const overwriteSchema: { ] }, azureAppConfiguration: { - name: "Azure App Configuration", + name: "Azure App Connection: App Configuration", fields: [ { key: "INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID", @@ -509,7 +519,7 @@ export const overwriteSchema: { ] }, azureKeyVault: { - name: "Azure Key Vault", + name: "Azure App Connection: Key Vault", fields: [ { key: "INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID", @@ -522,7 +532,7 @@ export const overwriteSchema: { ] }, azureClientSecrets: { - name: "Azure Client Secrets", + name: "Azure App Connection: Client Secrets", fields: [ { key: "INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID", @@ -535,7 +545,7 @@ export const overwriteSchema: { ] }, azureDevOps: { - name: "Azure DevOps", + name: "Azure App Connection: DevOps", fields: [ { key: "INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID", diff --git a/backend/src/lib/regex/index.ts b/backend/src/lib/regex/index.ts index a57705526..959fc741e 100644 --- a/backend/src/lib/regex/index.ts +++ b/backend/src/lib/regex/index.ts @@ -11,3 +11,5 @@ export const UserPrincipalNameRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9._-] export const LdapUrlRegex = new RE2(/^ldaps?:\/\//); export const BasicRepositoryRegex = new RE2(/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/); + +export const GitLabProjectRegex = new RE2(/^[a-zA-Z0-9._-]+(?:\/[a-zA-Z0-9._-]+)+$/); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index bcef34d4f..f4c49d15a 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -22,6 +22,7 @@ import { crypto } from "@app/lib/crypto"; import { logger } from "@app/lib/logger"; import { QueueWorkerProfile } from "@app/lib/types"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { ExternalPlatforms } from "@app/services/external-migration/external-migration-types"; import { TFailedIntegrationSyncEmailsPayload, TIntegrationSyncPayload, @@ -228,6 +229,7 @@ export type TQueueJobTypes = { name: QueueJobs.ImportSecretsFromExternalSource; payload: { actorEmail: string; + importType: ExternalPlatforms; data: { iv: string; tag: string; diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 4fe639510..97c62b545 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -22,6 +22,7 @@ export type TAuthMode = orgId: string; authMethod: AuthMethod; isMfaVerified?: boolean; + token: AuthModeJwtTokenPayload; } | { authMode: AuthMode.API_KEY; @@ -30,6 +31,7 @@ export type TAuthMode = userId: string; user: TUsers; orgId: string; + token: string; } | { authMode: AuthMode.SERVICE_TOKEN; @@ -38,6 +40,7 @@ export type TAuthMode = serviceTokenId: string; orgId: string; authMethod: null; + token: string; } | { authMode: AuthMode.IDENTITY_ACCESS_TOKEN; @@ -47,6 +50,7 @@ export type TAuthMode = orgId: string; authMethod: null; isInstanceAdmin?: boolean; + token: TIdentityAccessTokenJwtPayload; } | { authMode: AuthMode.SCIM_TOKEN; @@ -56,7 +60,7 @@ export type TAuthMode = authMethod: null; }; -const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { +export const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { const apiKey = req.headers?.["x-api-key"]; if (apiKey) { return { authMode: AuthMode.API_KEY, token: apiKey, actor: ActorType.USER } as const; @@ -133,7 +137,8 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { actor, orgId: orgId as string, authMethod: token.authMethod, - isMfaVerified: token.isMfaVerified + isMfaVerified: token.isMfaVerified, + token }; break; } @@ -148,7 +153,8 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { identityId: identity.identityId, identityName: identity.name, authMethod: null, - isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId) + isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId), + token }; if (token?.identityAuth?.oidc) { requestContext.set("identityAuthInfo", { @@ -179,7 +185,8 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { serviceToken, serviceTokenId: serviceToken.id, actor, - authMethod: null + authMethod: null, + token }; break; } @@ -191,7 +198,8 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { actor, user, orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon! - authMethod: null + authMethod: null, + token: token as string }; break; } diff --git a/backend/src/server/plugins/secret-scanner-v2.ts b/backend/src/server/plugins/secret-scanner-v2.ts index 1323fa4ad..3c0813be3 100644 --- a/backend/src/server/plugins/secret-scanner-v2.ts +++ b/backend/src/server/plugins/secret-scanner-v2.ts @@ -4,6 +4,8 @@ import { Probot } from "probot"; import { z } from "zod"; import { TBitbucketPushEvent } from "@app/ee/services/secret-scanning-v2/bitbucket/bitbucket-secret-scanning-types"; +import { TGitLabDataSourcePushEventPayload } from "@app/ee/services/secret-scanning-v2/gitlab"; +import { GitLabWebHookEvent } from "@app/ee/services/secret-scanning-v2/gitlab/gitlab-secret-scanning-enums"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { writeLimit } from "@app/server/config/rateLimiter"; @@ -113,4 +115,36 @@ export const registerSecretScanningV2Webhooks = async (server: FastifyZodProvide return res.send("ok"); } }); + + // gitlab push event webhook + server.route({ + method: "POST", + url: "/gitlab", + config: { + rateLimit: writeLimit + }, + handler: async (req, res) => { + const event = req.headers["x-gitlab-event"] as GitLabWebHookEvent; + const token = req.headers["x-gitlab-token"] as string; + const dataSourceId = req.headers["x-data-source-id"] as string; + + if (event !== GitLabWebHookEvent.Push) { + return res.status(400).send({ message: `Event type not supported: ${event as string}` }); + } + + if (!token) { + return res.status(401).send({ message: "Unauthorized: Missing token" }); + } + + if (!dataSourceId) return res.status(400).send({ message: "Data Source ID header is required" }); + + await server.services.secretScanningV2.gitlab.handlePushEvent({ + dataSourceId, + payload: req.body as TGitLabDataSourcePushEventPayload, + token + }); + + return res.send("ok"); + } + }); }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index d6d627576..64cfb140e 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -31,6 +31,8 @@ import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/pro import { dynamicSecretLeaseDALFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal"; import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue"; import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; +import { eventBusFactory } from "@app/ee/services/event/event-bus-service"; +import { sseServiceFactory } from "@app/ee/services/event/event-sse-service"; import { externalKmsDALFactory } from "@app/ee/services/external-kms/external-kms-dal"; import { externalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service"; import { gatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; @@ -177,6 +179,8 @@ import { identityAccessTokenDALFactory } from "@app/services/identity-access-tok import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { identityAliCloudAuthDALFactory } from "@app/services/identity-alicloud-auth/identity-alicloud-auth-dal"; import { identityAliCloudAuthServiceFactory } from "@app/services/identity-alicloud-auth/identity-alicloud-auth-service"; +import { identityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template/identity-auth-template-dal"; +import { identityAuthTemplateServiceFactory } from "@app/ee/services/identity-auth-template/identity-auth-template-service"; import { identityAwsAuthDALFactory } from "@app/services/identity-aws-auth/identity-aws-auth-dal"; import { identityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; import { identityAzureAuthDALFactory } from "@app/services/identity-azure-auth/identity-azure-auth-dal"; @@ -392,6 +396,7 @@ export const registerRoutes = async ( const identityProjectDAL = identityProjectDALFactory(db); const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db); const identityProjectAdditionalPrivilegeDAL = identityProjectAdditionalPrivilegeDALFactory(db); + const identityAuthTemplateDAL = identityAuthTemplateDALFactory(db); const identityTokenAuthDAL = identityTokenAuthDALFactory(db); const identityUaDAL = identityUaDALFactory(db); @@ -495,6 +500,9 @@ export const registerRoutes = async ( const projectMicrosoftTeamsConfigDAL = projectMicrosoftTeamsConfigDALFactory(db); const secretScanningV2DAL = secretScanningV2DALFactory(db); + const eventBusService = eventBusFactory(server.redis); + const sseService = sseServiceFactory(eventBusService, server.redis); + const permissionService = permissionServiceFactory({ permissionDAL, orgRoleDAL, @@ -552,7 +560,8 @@ export const registerRoutes = async ( queueService, projectDAL, licenseService, - auditLogStreamDAL + auditLogStreamDAL, + eventBusService }); const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); @@ -1450,6 +1459,15 @@ export const registerRoutes = async ( identityMetadataDAL }); + const identityAuthTemplateService = identityAuthTemplateServiceFactory({ + identityAuthTemplateDAL, + identityLdapAuthDAL, + permissionService, + kmsService, + licenseService, + auditLogService + }); + const identityAccessTokenService = identityAccessTokenServiceFactory({ identityAccessTokenDAL, identityOrgMembershipDAL, @@ -1593,7 +1611,8 @@ export const registerRoutes = async ( identityAccessTokenDAL, identityOrgMembershipDAL, licenseService, - identityDAL + identityDAL, + identityAuthTemplateDAL }); const dynamicSecretProviders = buildDynamicSecretProviders({ @@ -1928,7 +1947,8 @@ export const registerRoutes = async ( projectMembershipDAL, smtpService, kmsService, - keyStore + keyStore, + appConnectionDAL }); const secretScanningV2Service = secretScanningV2ServiceFactory({ @@ -1937,7 +1957,8 @@ export const registerRoutes = async ( licenseService, secretScanningV2DAL, secretScanningV2Queue, - kmsService + kmsService, + appConnectionDAL }); // setup the communication with license key server @@ -1961,6 +1982,7 @@ export const registerRoutes = async ( await kmsService.startService(); await microsoftTeamsService.start(); await dynamicSecretQueueService.init(); + await eventBusService.init(); // inject all services server.decorate("services", { @@ -1994,6 +2016,7 @@ export const registerRoutes = async ( webhook: webhookService, serviceToken: serviceTokenService, identity: identityService, + identityAuthTemplate: identityAuthTemplateService, identityAccessToken: identityAccessTokenService, identityProject: identityProjectService, identityTokenAuth: identityTokenAuthService, @@ -2067,7 +2090,9 @@ export const registerRoutes = async ( githubOrgSync: githubOrgSyncConfigService, folderCommit: folderCommitService, secretScanningV2: secretScanningV2Service, - reminder: reminderService + reminder: reminderService, + bus: eventBusService, + sse: sseService }); const cronJobs: CronJob[] = []; @@ -2128,7 +2153,8 @@ export const registerRoutes = async ( inviteOnlySignup: z.boolean().optional(), redisConfigured: z.boolean().optional(), secretScanningConfigured: z.boolean().optional(), - samlDefaultOrgSlug: z.string().optional() + samlDefaultOrgSlug: z.string().optional(), + auditLogStorageDisabled: z.boolean().optional() }) } }, @@ -2155,7 +2181,8 @@ export const registerRoutes = async ( inviteOnlySignup: Boolean(serverCfg.allowSignUp), redisConfigured: cfg.isRedisConfigured, secretScanningConfigured: cfg.isSecretScanningConfigured, - samlDefaultOrgSlug: cfg.samlDefaultOrgSlug + samlDefaultOrgSlug: cfg.samlDefaultOrgSlug, + auditLogStorageDisabled: Boolean(cfg.DISABLE_AUDIT_LOG_STORAGE) }; } }); @@ -2183,5 +2210,7 @@ export const registerRoutes = async ( server.addHook("onClose", async () => { cronJobs.forEach((job) => job.stop()); await telemetryService.flushAll(); + await eventBusService.close(); + sseService.close(); }); }; diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 6ad368816..3f3b58b5e 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -464,6 +464,42 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "DELETE", + url: "/user-management/users", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + userIds: z.string().array() + }), + response: { + 200: z.object({ + users: UsersSchema.pick({ + username: true, + firstName: true, + lastName: true, + email: true, + id: true + }).array() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const users = await server.services.superAdmin.deleteUsers(req.body.userIds); + + return { + users + }; + } + }); + server.route({ method: "PATCH", url: "/user-management/users/:userId/admin-access", 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 8468d7305..f184f2001 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 @@ -75,6 +75,10 @@ import { import { LdapConnectionListItemSchema, SanitizedLdapConnectionSchema } from "@app/services/app-connection/ldap"; import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql"; import { MySqlConnectionListItemSchema, SanitizedMySqlConnectionSchema } from "@app/services/app-connection/mysql"; +import { + NetlifyConnectionListItemSchema, + SanitizedNetlifyConnectionSchema +} from "@app/services/app-connection/netlify"; import { OktaConnectionListItemSchema, SanitizedOktaConnectionSchema } from "@app/services/app-connection/okta"; import { PostgresConnectionListItemSchema, @@ -145,6 +149,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedChecklyConnectionSchema.options, ...SanitizedSupabaseConnectionSchema.options, ...SanitizedDigitalOceanConnectionSchema.options, + ...SanitizedNetlifyConnectionSchema.options, ...SanitizedOktaConnectionSchema.options ]); @@ -184,6 +189,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ ChecklyConnectionListItemSchema, SupabaseConnectionListItemSchema, DigitalOceanConnectionListItemSchema, + NetlifyConnectionListItemSchema, OktaConnectionListItemSchema ]); diff --git a/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts index d18d0564e..dea917947 100644 --- a/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts @@ -46,7 +46,6 @@ export const registerCloudflareConnectionRouter = async (server: FastifyZodProvi const { connectionId } = req.params; const projects = await server.services.appConnection.cloudflare.listPagesProjects(connectionId, req.permission); - return projects; } }); @@ -73,9 +72,36 @@ export const registerCloudflareConnectionRouter = async (server: FastifyZodProvi handler: async (req) => { const { connectionId } = req.params; - const projects = await server.services.appConnection.cloudflare.listWorkersScripts(connectionId, req.permission); + const scripts = await server.services.appConnection.cloudflare.listWorkersScripts(connectionId, req.permission); + return scripts; + } + }); - return projects; + server.route({ + method: "GET", + url: `/:connectionId/cloudflare-zones`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const zones = await server.services.appConnection.cloudflare.listZones(connectionId, req.permission); + return zones; } }); }; 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 6744e46b3..28e3b4e49 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -26,6 +26,7 @@ import { registerHumanitecConnectionRouter } from "./humanitec-connection-router import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; +import { registerNetlifyConnectionRouter } from "./netlify-connection-router"; import { registerOktaConnectionRouter } from "./okta-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerRailwayConnectionRouter } from "./railway-connection-router"; @@ -76,5 +77,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Netlify, + server, + sanitizedResponseSchema: SanitizedNetlifyConnectionSchema, + createSchema: CreateNetlifyConnectionSchema, + updateSchema: UpdateNetlifyConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/accounts`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + accounts: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const accounts = await server.services.appConnection.netlify.listAccounts(connectionId, req.permission); + + return { accounts }; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/accounts/:accountId/sites`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid(), + accountId: z.string() + }), + response: { + 200: z.object({ + sites: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId, accountId } = req.params; + + const sites = await server.services.appConnection.netlify.listSites(connectionId, req.permission, accountId); + + return { sites }; + } + }); +}; diff --git a/backend/src/server/routes/v1/event-router.ts b/backend/src/server/routes/v1/event-router.ts new file mode 100644 index 000000000..b8a043db5 --- /dev/null +++ b/backend/src/server/routes/v1/event-router.ts @@ -0,0 +1,118 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import { subject } from "@casl/ability"; +import { pipeline } from "stream/promises"; +import { z } from "zod"; + +import { ActionProjectType, ProjectType } from "@app/db/schemas"; +import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream"; +import { EventRegisterSchema } from "@app/ee/services/event/types"; +import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, ForbiddenRequestError, RateLimitError } from "@app/lib/errors"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerEventRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/subscribe/project-events", + config: { + rateLimit: readLimit + }, + schema: { + body: z.object({ + projectId: z.string().trim(), + register: z.array(EventRegisterSchema).max(10) + }) + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req, reply) => { + try { + const { sse, permission, identityAccessToken, authToken, license } = req.server.services; + + const plan = await license.getPlan(req.auth.orgId); + + if (!plan.eventSubscriptions) { + throw new BadRequestError({ + message: + "Failed to use event subscriptions due to plan restriction. Upgrade plan to access enterprise event subscriptions." + }); + } + + const count = await sse.getActiveConnectionsCount(req.body.projectId, req.permission.id); + + if (count >= 5) { + throw new RateLimitError({ + message: `Too many active connections for project ${req.body.projectId}. Please close some connections before opening a new one.` + }); + } + + const client = await sse.subscribe({ + type: ProjectType.SecretManager, + registered: req.body.register, + async getAuthInfo() { + const ability = await permission.getProjectPermission({ + actor: req.auth.actor, + projectId: req.body.projectId, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: req.auth.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId + }); + + return { permission: ability.permission, actorId: req.permission.id, projectId: req.body.projectId }; + }, + async onAuthRefresh(info) { + switch (req.auth.authMode) { + case AuthMode.JWT: + await authToken.fnValidateJwtIdentity(req.auth.token); + break; + case AuthMode.IDENTITY_ACCESS_TOKEN: + await identityAccessToken.fnValidateIdentityAccessToken(req.auth.token, req.realIp); + break; + default: + throw new Error("Unsupported authentication method"); + } + + req.body.register.forEach((r) => { + const allowed = info.permission.can( + ProjectPermissionSecretActions.Subscribe, + subject(ProjectPermissionSub.Secrets, { + environment: r.conditions?.environmentSlug ?? "", + secretPath: r.conditions?.secretPath ?? "/", + eventType: r.event + }) + ); + + if (!allowed) { + throw new ForbiddenRequestError({ + name: "PermissionDenied", + message: `You are not allowed to subscribe on secrets`, + details: { + event: r.event, + environmentSlug: r.conditions?.environmentSlug, + secretPath: r.conditions?.secretPath ?? "/" + } + }); + } + }); + } + }); + + // Switches to manual response and enable SSE streaming + reply.hijack(); + reply.raw.writeHead(200, getServerSentEventsHeaders()).flushHeaders(); + reply.raw.on("close", client.abort); + + await pipeline(client.stream, reply.raw, { signal: client.signal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + // If the stream is aborted, we don't need to do anything + return; + } + + throw error; + } + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index 3da8a425b..5d3612bf5 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -200,49 +200,104 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) params: z.object({ identityId: z.string().trim().describe(LDAP_AUTH.ATTACH.identityId) }), - body: z - .object({ - url: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.url), - bindDN: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.bindDN), - bindPass: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.bindPass), - searchBase: z.string().trim().min(1).describe(LDAP_AUTH.ATTACH.searchBase), - searchFilter: z - .string() - .trim() - .min(1) - .default("(uid={{username}})") - .refine(isValidLdapFilter, "Invalid LDAP search filter") - .describe(LDAP_AUTH.ATTACH.searchFilter), - allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.ATTACH.allowedFields), - ldapCaCertificate: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.ldapCaCertificate), - accessTokenTrustedIps: z - .object({ - ipAddress: z.string().trim() - }) - .array() - .min(1) - .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) - .describe(LDAP_AUTH.ATTACH.accessTokenTrustedIps), - accessTokenTTL: z - .number() - .int() - .min(0) - .max(315360000) - .default(2592000) - .describe(LDAP_AUTH.ATTACH.accessTokenTTL), - accessTokenMaxTTL: z - .number() - .int() - .min(1) - .max(315360000) - .default(2592000) - .describe(LDAP_AUTH.ATTACH.accessTokenMaxTTL), - accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) - }) - .refine( - (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, - "Access Token TTL cannot be greater than Access Token Max TTL." - ), + body: z.union([ + // Template-based configuration + z + .object({ + templateId: z.string().trim().describe(LDAP_AUTH.ATTACH.templateId), + searchFilter: z + .string() + .trim() + .min(1) + .default("(uid={{username}})") + .refine(isValidLdapFilter, "Invalid LDAP search filter") + .describe(LDAP_AUTH.ATTACH.searchFilter), + allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.ATTACH.allowedFields), + ldapCaCertificate: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.ldapCaCertificate), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(LDAP_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(0) + .max(315360000) + .default(2592000) + .describe(LDAP_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .min(1) + .max(315360000) + .default(2592000) + .describe(LDAP_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z + .number() + .int() + .min(0) + .default(0) + .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) + }) + .refine( + (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, + "Access Token TTL cannot be greater than Access Token Max TTL." + ), + + // Manual configuration + z + .object({ + url: z.string().trim().describe(LDAP_AUTH.ATTACH.url), + bindDN: z.string().trim().describe(LDAP_AUTH.ATTACH.bindDN), + bindPass: z.string().trim().describe(LDAP_AUTH.ATTACH.bindPass), + searchBase: z.string().trim().describe(LDAP_AUTH.ATTACH.searchBase), + searchFilter: z + .string() + .trim() + .min(1) + .default("(uid={{username}})") + .refine(isValidLdapFilter, "Invalid LDAP search filter") + .describe(LDAP_AUTH.ATTACH.searchFilter), + allowedFields: AllowedFieldsSchema.array().optional().describe(LDAP_AUTH.ATTACH.allowedFields), + ldapCaCertificate: z.string().trim().optional().describe(LDAP_AUTH.ATTACH.ldapCaCertificate), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(LDAP_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(0) + .max(315360000) + .default(2592000) + .describe(LDAP_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .min(1) + .max(315360000) + .default(2592000) + .describe(LDAP_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z + .number() + .int() + .min(0) + .default(0) + .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) + }) + .refine( + (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, + "Access Token TTL cannot be greater than Access Token Max TTL." + ) + ]), response: { 200: z.object({ identityLdapAuth: IdentityLdapAuthsSchema.omit({ @@ -275,7 +330,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, accessTokenTTL: identityLdapAuth.accessTokenTTL, accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, - allowedFields: req.body.allowedFields + allowedFields: req.body.allowedFields, + templateId: identityLdapAuth.templateId } } }); @@ -309,6 +365,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) bindDN: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.bindDN), bindPass: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.bindPass), searchBase: z.string().trim().min(1).optional().describe(LDAP_AUTH.UPDATE.searchBase), + templateId: z.string().trim().optional().describe(LDAP_AUTH.UPDATE.templateId), searchFilter: z .string() .trim() @@ -376,7 +433,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) accessTokenTTL: identityLdapAuth.accessTokenTTL, accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, accessTokenTrustedIps: identityLdapAuth.accessTokenTrustedIps as TIdentityTrustedIp[], - allowedFields: req.body.allowedFields + allowedFields: req.body.allowedFields, + templateId: identityLdapAuth.templateId } } }); @@ -413,7 +471,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) }).extend({ bindDN: z.string(), bindPass: z.string(), - ldapCaCertificate: z.string().optional() + ldapCaCertificate: z.string().optional(), + templateId: z.string().optional().nullable() }) }) } diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 848bd4f31..458efd194 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -13,6 +13,7 @@ import { registerCaRouter } from "./certificate-authority-router"; import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers"; import { registerCertRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; +import { registerEventRouter } from "./event-router"; import { registerExternalGroupOrgRoleMappingRouter } from "./external-group-org-role-mapping-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAliCloudAuthRouter } from "./identity-alicloud-auth-router"; @@ -183,4 +184,6 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { }, { prefix: "/reminders" } ); + + await server.register(registerEventRouter, { prefix: "/events" }); }; diff --git a/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts b/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts index 4aa68197f..046607da1 100644 --- a/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts +++ b/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts @@ -22,6 +22,7 @@ export const registerSecretReminderRouter = async (server: FastifyZodProvider) = message: z.string().trim().max(1024).optional(), repeatDays: z.number().min(1).nullable().optional(), nextReminderDate: z.string().datetime().nullable().optional(), + fromDate: z.string().datetime().nullable().optional(), recipients: z.string().array().optional() }) .refine((data) => { @@ -45,6 +46,7 @@ export const registerSecretReminderRouter = async (server: FastifyZodProvider) = message: req.body.message, repeatDays: req.body.repeatDays, nextReminderDate: req.body.nextReminderDate, + fromDate: req.body.fromDate, recipients: req.body.recipients } }); 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 03cf9d053..fed56277e 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -21,6 +21,7 @@ import { registerGitLabSyncRouter } from "./gitlab-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerNetlifySyncRouter } from "./netlify-sync-router"; import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; import { registerSupabaseSyncRouter } from "./supabase-sync-router"; @@ -61,5 +62,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.Netlify, + server, + responseSchema: NetlifySyncSchema, + createSchema: CreateNetlifySyncSchema, + updateSchema: UpdateNetlifySyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts index 0388ab53e..71e8b2cca 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -44,6 +44,7 @@ import { GitLabSyncListItemSchema, GitLabSyncSchema } from "@app/services/secret import { HCVaultSyncListItemSchema, HCVaultSyncSchema } from "@app/services/secret-sync/hc-vault"; import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret-sync/heroku"; import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec"; +import { NetlifySyncListItemSchema, NetlifySyncSchema } from "@app/services/secret-sync/netlify"; import { RailwaySyncListItemSchema, RailwaySyncSchema } from "@app/services/secret-sync/railway/railway-sync-schemas"; import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas"; import { SupabaseSyncListItemSchema, SupabaseSyncSchema } from "@app/services/secret-sync/supabase"; @@ -82,6 +83,7 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ RailwaySyncSchema, ChecklySyncSchema, DigitalOceanAppPlatformSyncSchema, + NetlifySyncSchema, BitbucketSyncSchema ]); @@ -114,6 +116,7 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ RailwaySyncListItemSchema, ChecklySyncListItemSchema, SupabaseSyncListItemSchema, + NetlifySyncListItemSchema, BitbucketSyncListItemSchema ]); diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index 2c8f3410a..9135d2356 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -264,6 +264,48 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "DELETE", + url: "/:organizationId/memberships", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.Organizations], + description: "Bulk delete organization user memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.BULK_DELETE_USER_MEMBERSHIPS.organizationId) + }), + body: z.object({ + membershipIds: z.string().trim().array().describe(ORGANIZATIONS.BULK_DELETE_USER_MEMBERSHIPS.membershipIds) + }), + response: { + 200: z.object({ + memberships: OrgMembershipsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + if (req.auth.actor !== ActorType.USER) return; + + const memberships = await server.services.org.bulkDeleteOrgMemberships({ + userId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + orgId: req.params.organizationId, + membershipIds: req.body.membershipIds, + actorOrgId: req.permission.orgId + }); + return { memberships }; + } + }); + server.route({ // TODO: re-think endpoint structure in future so users only need to pass in membershipId bc organizationId is redundant method: "GET", diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 833522cbb..263cbdd9b 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -34,6 +34,7 @@ export enum AppConnection { Checkly = "checkly", Supabase = "supabase", DigitalOcean = "digital-ocean", + Netlify = "netlify", Okta = "okta" } diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index d72fca654..9ffc358b6 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -97,6 +97,7 @@ import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnection import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; +import { getNetlifyConnectionListItem, validateNetlifyConnectionCredentials } from "./netlify"; import { getOktaConnectionListItem, OktaConnectionMethod, validateOktaConnectionCredentials } from "./okta"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway"; @@ -163,6 +164,7 @@ export const listAppConnectionOptions = () => { getChecklyConnectionListItem(), getSupabaseConnectionListItem(), getDigitalOceanConnectionListItem(), + getNetlifyConnectionListItem(), getOktaConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -251,7 +253,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Checkly]: validateChecklyConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.DigitalOcean]: validateDigitalOceanConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService); @@ -381,6 +384,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Checkly]: platformManagedCredentialsNotSupported, [AppConnection.Supabase]: platformManagedCredentialsNotSupported, [AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported, + [AppConnection.Netlify]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 603d30ccd..155b68ce1 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -36,6 +36,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Checkly]: "Checkly", [AppConnection.Supabase]: "Supabase", [AppConnection.DigitalOcean]: "DigitalOcean App Platform", + [AppConnection.Netlify]: "Netlify", [AppConnection.Okta]: "Okta" }; @@ -75,5 +76,6 @@ export const APP_CONNECTION_PLAN_MAP: Record { return { name: "Azure App Configuration" as const, app: AppConnection.AzureAppConfiguration as const, - methods: Object.values(AzureAppConfigurationConnectionMethod) as [AzureAppConfigurationConnectionMethod.OAuth], + methods: Object.values(AzureAppConfigurationConnectionMethod) as [ + AzureAppConfigurationConnectionMethod.OAuth, + AzureAppConfigurationConnectionMethod.ClientSecret + ], oauthClientId: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID }; }; @@ -35,71 +39,111 @@ export const validateAzureAppConfigurationConnectionCredentials = async ( SITE_URL } = getConfig(); - if ( - !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID || - !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET - ) { - throw new InternalServerError({ - message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` - }); - } - - let tokenResp: AxiosResponse | null = null; - let tokenError: AxiosError | null = null; - - try { - tokenResp = await request.post( - IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"), - new URLSearchParams({ - grant_type: "authorization_code", - code: inputCredentials.code, - scope: `openid offline_access https://azconfig.io/.default`, - client_id: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID, - client_secret: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET, - redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` - }) - ); - } catch (e: unknown) { - if (e instanceof AxiosError) { - tokenError = e; - } else { - throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` - }); - } - } - - if (tokenError) { - if (tokenError instanceof AxiosError) { - throw new BadRequestError({ - message: `Failed to get access token: ${ - (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" - }` - }); - } else { - throw new InternalServerError({ - message: "Failed to get access token" - }); - } - } - - if (!tokenResp) { - throw new InternalServerError({ - message: `Failed to get access token: Token was empty with no error` - }); - } - switch (method) { case AzureAppConfigurationConnectionMethod.OAuth: + if ( + !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID || + !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET + ) { + throw new InternalServerError({ + message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse | null = null; + let tokenError: AxiosError | null = null; + const oauthCredentials = inputCredentials as { code: string; tenantId?: string }; + try { + tokenResp = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "authorization_code", + code: oauthCredentials.code, + scope: `openid offline_access https://azconfig.io/.default`, + client_id: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET, + redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` + }) + ); + } catch (e: unknown) { + if (e instanceof AxiosError) { + tokenError = e; + } else { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } + } + + if (tokenError) { + if (tokenError instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + + if (!tokenResp) { + throw new InternalServerError({ + message: `Failed to get access token: Token was empty with no error` + }); + } + return { - tenantId: inputCredentials.tenantId, + tenantId: oauthCredentials.tenantId, accessToken: tokenResp.data.access_token, refreshToken: tokenResp.data.refresh_token, expiresAt: Date.now() + tokenResp.data.expires_in * 1000 }; + + case AzureAppConfigurationConnectionMethod.ClientSecret: + const { tenantId, clientId, clientSecret } = inputCredentials as { + tenantId: string; + clientId: string; + clientSecret: string; + }; + + try { + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://azconfig.io/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + return { + tenantId, + accessToken: clientData.access_token, + expiresAt: Date.now() + clientData.expires_in * 1000, + clientId, + clientSecret + }; + } catch (e: unknown) { + if (e instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (e?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + default: throw new InternalServerError({ - message: `Unhandled Azure connection method: ${method as AzureAppConfigurationConnectionMethod}` + message: `Unhandled Azure App Configuration connection method: ${method as AzureAppConfigurationConnectionMethod}` }); } }; diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts index 183376acf..68579f9ba 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts @@ -22,6 +22,29 @@ export const AzureAppConfigurationConnectionOAuthOutputCredentialsSchema = z.obj expiresAt: z.number() }); +export const AzureAppConfigurationConnectionClientSecretInputCredentialsSchema = z.object({ + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .max(50, "Client ID must be at most 50 characters long"), + clientSecret: z + .string() + .trim() + .min(1, "Client Secret required") + .max(50, "Client Secret must be at most 50 characters long"), + tenantId: z.string().uuid().trim().min(1, "Tenant ID required") +}); + +export const AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema = z.object({ + clientId: z.string(), + clientSecret: z.string(), + tenantId: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureAppConfigurationConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -30,6 +53,14 @@ export const ValidateAzureAppConfigurationConnectionCredentialsSchema = z.discri credentials: AzureAppConfigurationConnectionOAuthInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureAppConfiguration).credentials ) + }), + z.object({ + method: z + .literal(AzureAppConfigurationConnectionMethod.ClientSecret) + .describe(AppConnections.CREATE(AppConnection.AzureAppConfiguration).method), + credentials: AzureAppConfigurationConnectionClientSecretInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureAppConfiguration).credentials + ) }) ]); @@ -39,9 +70,13 @@ export const CreateAzureAppConfigurationConnectionSchema = ValidateAzureAppConfi export const UpdateAzureAppConfigurationConnectionSchema = z .object({ - credentials: AzureAppConfigurationConnectionOAuthInputCredentialsSchema.optional().describe( - AppConnections.UPDATE(AppConnection.AzureAppConfiguration).credentials - ) + credentials: z + .union([ + AzureAppConfigurationConnectionOAuthInputCredentialsSchema, + AzureAppConfigurationConnectionClientSecretInputCredentialsSchema + ]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.AzureAppConfiguration).credentials) }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureAppConfiguration)); @@ -55,6 +90,10 @@ export const AzureAppConfigurationConnectionSchema = z.intersection( z.object({ method: z.literal(AzureAppConfigurationConnectionMethod.OAuth), credentials: AzureAppConfigurationConnectionOAuthOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureAppConfigurationConnectionMethod.ClientSecret), + credentials: AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema }) ]) ); @@ -65,6 +104,13 @@ export const SanitizedAzureAppConfigurationConnectionSchema = z.discriminatedUni credentials: AzureAppConfigurationConnectionOAuthOutputCredentialsSchema.pick({ tenantId: true }) + }), + BaseAzureAppConfigurationConnectionSchema.extend({ + method: z.literal(AzureAppConfigurationConnectionMethod.ClientSecret), + credentials: AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema.pick({ + clientId: true, + tenantId: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts index 8111b4c50..e60161197 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema, AzureAppConfigurationConnectionOAuthOutputCredentialsSchema, AzureAppConfigurationConnectionSchema, CreateAzureAppConfigurationConnectionSchema, @@ -39,3 +40,7 @@ export type ExchangeCodeAzureResponse = { export type TAzureAppConfigurationConnectionCredentials = z.infer< typeof AzureAppConfigurationConnectionOAuthOutputCredentialsSchema >; + +export type TAzureAppConfigurationConnectionClientSecretCredentials = z.infer< + typeof AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema +>; diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts b/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts index f724a0b4a..c0ce74b27 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts @@ -1,4 +1,5 @@ export enum AzureDevOpsConnectionMethod { OAuth = "oauth", - AccessToken = "access-token" + AccessToken = "access-token", + ClientSecret = "client-secret" } diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts index e9bb1f6bd..39a330360 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts @@ -18,6 +18,7 @@ import { AppConnection } from "../app-connection-enums"; import { AzureDevOpsConnectionMethod } from "./azure-devops-enums"; import { ExchangeCodeAzureResponse, + TAzureDevOpsConnectionClientSecretCredentials, TAzureDevOpsConnectionConfig, TAzureDevOpsConnectionCredentials } from "./azure-devops-types"; @@ -30,7 +31,8 @@ export const getAzureDevopsConnectionListItem = () => { app: AppConnection.AzureDevOps as const, methods: Object.values(AzureDevOpsConnectionMethod) as [ AzureDevOpsConnectionMethod.OAuth, - AzureDevOpsConnectionMethod.AccessToken + AzureDevOpsConnectionMethod.AccessToken, + AzureDevOpsConnectionMethod.ClientSecret ], oauthClientId: INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID }; @@ -53,11 +55,7 @@ export const getAzureDevopsConnection = async ( }); } - const credentials = (await decryptAppConnectionCredentials({ - orgId: appConnection.orgId, - kmsService, - encryptedCredentials: appConnection.encryptedCredentials - })) as TAzureDevOpsConnectionCredentials; + const currentTime = Date.now(); // Handle different connection methods switch (appConnection.method) { @@ -69,12 +67,17 @@ export const getAzureDevopsConnection = async ( }); } - if (!("refreshToken" in credentials)) { + const oauthCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureDevOpsConnectionCredentials; + + if (!("refreshToken" in oauthCredentials)) { throw new BadRequestError({ message: "Invalid OAuth credentials" }); } - const { refreshToken, tenantId } = credentials; - const currentTime = Date.now(); + const { refreshToken, tenantId } = oauthCredentials; const { data } = await request.post( IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), @@ -87,29 +90,75 @@ export const getAzureDevopsConnection = async ( }) ); - const updatedCredentials = { - ...credentials, + const updatedOAuthCredentials = { + ...oauthCredentials, accessToken: data.access_token, expiresAt: currentTime + data.expires_in * 1000, refreshToken: data.refresh_token }; - const encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: updatedCredentials, + const encryptedOAuthCredentials = await encryptAppConnectionCredentials({ + credentials: updatedOAuthCredentials, orgId: appConnection.orgId, kmsService }); - await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedOAuthCredentials }); return data.access_token; case AzureDevOpsConnectionMethod.AccessToken: - if (!("accessToken" in credentials)) { + const accessTokenCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as { accessToken: string }; + + if (!("accessToken" in accessTokenCredentials)) { throw new BadRequestError({ message: "Invalid API token credentials" }); } // For access token, return the basic auth token directly - return credentials.accessToken; + return accessTokenCredentials.accessToken; + + case AzureDevOpsConnectionMethod.ClientSecret: + const clientSecretCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureDevOpsConnectionClientSecretCredentials; + + const { accessToken, expiresAt, clientId, clientSecret, tenantId: clientTenantId } = clientSecretCredentials; + + // Check if token is still valid (with 5 minute buffer) + if (accessToken && expiresAt && expiresAt > currentTime + 300000) { + return accessToken; + } + + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", clientTenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://app.vssps.visualstudio.com/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + const updatedClientCredentials = { + ...clientSecretCredentials, + accessToken: clientData.access_token, + expiresAt: currentTime + clientData.expires_in * 1000 + }; + + const encryptedClientCredentials = await encryptAppConnectionCredentials({ + credentials: updatedClientCredentials, + orgId: appConnection.orgId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); + + return clientData.access_token; default: throw new BadRequestError({ message: `Unsupported connection method` }); @@ -138,7 +187,7 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev let tokenError: AxiosError | null = null; try { - const oauthCredentials = inputCredentials as { code: string; tenantId: string }; + const oauthCredentials = inputCredentials as { code: string; tenantId: string; orgName: string }; tokenResp = await request.post( IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), new URLSearchParams({ @@ -262,9 +311,67 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev }); } + case AzureDevOpsConnectionMethod.ClientSecret: + const { tenantId, clientId, clientSecret, orgName } = inputCredentials as { + tenantId: string; + clientId: string; + clientSecret: string; + orgName: string; + }; + + try { + // First, get the access token using client credentials flow + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://app.vssps.visualstudio.com/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + // Validate access to the specific organization + const response = await request.get( + `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/_apis/projects?api-version=7.2-preview.2&$top=1`, + { + headers: { + Authorization: `Bearer ${clientData.access_token}` + } + } + ); + + if (response.status !== 200) { + throw new BadRequestError({ + message: `Failed to validate connection to organization '${orgName}': ${response.status}` + }); + } + + return { + tenantId, + clientId, + clientSecret, + orgName, + accessToken: clientData.access_token, + expiresAt: Date.now() + clientData.expires_in * 1000 + }; + } catch (e: unknown) { + if (e instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to authenticate with Azure DevOps using client credentials: ${ + (e?.response?.data as { error_description?: string })?.error_description || e.message + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to validate Azure DevOps client credentials" + }); + } + } + default: throw new InternalServerError({ - message: `Unhandled Azure connection method: ${method as AzureDevOpsConnectionMethod}` + message: `Unhandled Azure DevOps connection method: ${method as AzureDevOpsConnectionMethod}` }); } }; diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts b/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts index e07ed008d..2580d0114 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts @@ -38,6 +38,42 @@ export const AzureDevOpsConnectionAccessTokenOutputCredentialsSchema = z.object( orgName: z.string() }); +export const AzureDevOpsConnectionClientSecretInputCredentialsSchema = z.object({ + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .max(50, "Client ID must be at most 50 characters long") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.clientId), + clientSecret: z + .string() + .trim() + .min(1, "Client Secret required") + .max(50, "Client Secret must be at most 50 characters long") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.clientSecret), + tenantId: z + .string() + .uuid() + .trim() + .min(1, "Tenant ID required") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.tenantId), + orgName: z + .string() + .trim() + .min(1, "Organization name required") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.orgName) +}); + +export const AzureDevOpsConnectionClientSecretOutputCredentialsSchema = z.object({ + clientId: z.string(), + clientSecret: z.string(), + tenantId: z.string(), + orgName: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureDevOpsConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -54,6 +90,14 @@ export const ValidateAzureDevOpsConnectionCredentialsSchema = z.discriminatedUni credentials: AzureDevOpsConnectionAccessTokenInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureDevOps).credentials ) + }), + z.object({ + method: z + .literal(AzureDevOpsConnectionMethod.ClientSecret) + .describe(AppConnections.CREATE(AppConnection.AzureDevOps).method), + credentials: AzureDevOpsConnectionClientSecretInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureDevOps).credentials + ) }) ]); @@ -64,7 +108,11 @@ export const CreateAzureDevOpsConnectionSchema = ValidateAzureDevOpsConnectionCr export const UpdateAzureDevOpsConnectionSchema = z .object({ credentials: z - .union([AzureDevOpsConnectionOAuthInputCredentialsSchema, AzureDevOpsConnectionAccessTokenInputCredentialsSchema]) + .union([ + AzureDevOpsConnectionOAuthInputCredentialsSchema, + AzureDevOpsConnectionAccessTokenInputCredentialsSchema, + AzureDevOpsConnectionClientSecretInputCredentialsSchema + ]) .optional() .describe(AppConnections.UPDATE(AppConnection.AzureDevOps).credentials) }) @@ -84,6 +132,10 @@ export const AzureDevOpsConnectionSchema = z.intersection( z.object({ method: z.literal(AzureDevOpsConnectionMethod.AccessToken), credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureDevOpsConnectionMethod.ClientSecret), + credentials: AzureDevOpsConnectionClientSecretOutputCredentialsSchema }) ]) ); @@ -101,6 +153,14 @@ export const SanitizedAzureDevOpsConnectionSchema = z.discriminatedUnion("method credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema.pick({ orgName: true }) + }), + BaseAzureDevOpsConnectionSchema.extend({ + method: z.literal(AzureDevOpsConnectionMethod.ClientSecret), + credentials: AzureDevOpsConnectionClientSecretOutputCredentialsSchema.pick({ + clientId: true, + tenantId: true, + orgName: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-service.ts b/backend/src/services/app-connection/azure-devops/azure-devops-service.ts index a85430e4d..6006dc1b4 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-service.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-service.ts @@ -52,6 +52,11 @@ const getAuthHeaders = (appConnection: TAzureDevOpsConnection, accessToken: stri Authorization: `Basic ${basicAuthToken}`, Accept: "application/json" }; + case AzureDevOpsConnectionMethod.ClientSecret: + return { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + }; default: throw new BadRequestError({ message: "Unsupported connection method" }); } diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-types.ts b/backend/src/services/app-connection/azure-devops/azure-devops-types.ts index 62a80c1c5..f556b4600 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-types.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureDevOpsConnectionClientSecretOutputCredentialsSchema, AzureDevOpsConnectionOAuthOutputCredentialsSchema, AzureDevOpsConnectionSchema, CreateAzureDevOpsConnectionSchema, @@ -27,6 +28,10 @@ export type TAzureDevOpsConnectionConfig = DiscriminativePick< export type TAzureDevOpsConnectionCredentials = z.infer; +export type TAzureDevOpsConnectionClientSecretCredentials = z.infer< + typeof AzureDevOpsConnectionClientSecretOutputCredentialsSchema +>; + export interface ExchangeCodeAzureResponse { token_type: string; scope: string; diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-enums.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-enums.ts index 895e88298..aa70ccb68 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-enums.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-enums.ts @@ -1,3 +1,4 @@ export enum AzureKeyVaultConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts index 95102c5d1..1f88488c0 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-case-declarations */ import { AxiosError, AxiosResponse } from "axios"; import { getConfig } from "@app/lib/config/env"; @@ -16,25 +17,16 @@ import { AppConnection } from "../app-connection-enums"; import { AzureKeyVaultConnectionMethod } from "./azure-key-vault-connection-enums"; import { ExchangeCodeAzureResponse, + TAzureKeyVaultConnectionClientSecretCredentials, TAzureKeyVaultConnectionConfig, TAzureKeyVaultConnectionCredentials } from "./azure-key-vault-connection-types"; export const getAzureConnectionAccessToken = async ( connectionId: string, - appConnectionDAL: Pick, + appConnectionDAL: Pick, kmsService: Pick ) => { - const appCfg = getConfig(); - if ( - !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || - !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET - ) { - throw new BadRequestError({ - message: `Azure environment variables have not been configured` - }); - } - const appConnection = await appConnectionDAL.findById(connectionId); if (!appConnection) { @@ -49,49 +41,101 @@ export const getAzureConnectionAccessToken = async ( throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not a valid Azure connection` }); } - const credentials = (await decryptAppConnectionCredentials({ - orgId: appConnection.orgId, - kmsService, - encryptedCredentials: appConnection.encryptedCredentials - })) as TAzureKeyVaultConnectionCredentials; + const currentTime = Date.now(); - const { data } = await request.post( - IntegrationUrls.AZURE_TOKEN_URL.replace("common", credentials.tenantId || "common"), - new URLSearchParams({ - grant_type: "refresh_token", - scope: `openid offline_access`, - client_id: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, - client_secret: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, - refresh_token: credentials.refreshToken - }) - ); + switch (appConnection.method) { + case AzureKeyVaultConnectionMethod.OAuth: + const appCfg = getConfig(); + if ( + !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || + !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET + ) { + throw new BadRequestError({ + message: `Azure environment variables have not been configured` + }); + } - const accessExpiresAt = new Date(); - accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + const oauthCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureKeyVaultConnectionCredentials; - const updatedCredentials = { - ...credentials, - accessToken: data.access_token, - expiresAt: accessExpiresAt.getTime(), - refreshToken: data.refresh_token - }; + const { data } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "refresh_token", + scope: `openid offline_access https://vault.azure.net/.default`, + client_id: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, + client_secret: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, + refresh_token: oauthCredentials.refreshToken + }) + ); - const encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: updatedCredentials, - orgId: appConnection.orgId, - kmsService - }); + const updatedOAuthCredentials = { + ...oauthCredentials, + accessToken: data.access_token, + expiresAt: currentTime + data.expires_in * 1000, + refreshToken: data.refresh_token + }; - await appConnectionDAL.update( - { id: connectionId }, - { - encryptedCredentials - } - ); + const encryptedOAuthCredentials = await encryptAppConnectionCredentials({ + credentials: updatedOAuthCredentials, + orgId: appConnection.orgId, + kmsService + }); - return { - accessToken: data.access_token - }; + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedOAuthCredentials }); + + return { + accessToken: data.access_token + }; + + case AzureKeyVaultConnectionMethod.ClientSecret: + const clientSecretCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureKeyVaultConnectionClientSecretCredentials; + + const { accessToken, expiresAt, clientId, clientSecret, tenantId } = clientSecretCredentials; + + // Check if token is still valid (with 5 minute buffer) + if (accessToken && expiresAt && expiresAt > currentTime + 300000) { + return { accessToken }; + } + + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://vault.azure.net/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + const updatedClientCredentials = { + ...clientSecretCredentials, + accessToken: clientData.access_token, + expiresAt: currentTime + clientData.expires_in * 1000 + }; + + const encryptedClientCredentials = await encryptAppConnectionCredentials({ + credentials: updatedClientCredentials, + orgId: appConnection.orgId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); + + return { accessToken: clientData.access_token }; + + default: + throw new InternalServerError({ + message: `Unhandled Azure Key Vault connection method: ${appConnection.method as AzureKeyVaultConnectionMethod}` + }); + } }; export const getAzureKeyVaultConnectionListItem = () => { @@ -100,7 +144,10 @@ export const getAzureKeyVaultConnectionListItem = () => { return { name: "Azure Key Vault" as const, app: AppConnection.AzureKeyVault as const, - methods: Object.values(AzureKeyVaultConnectionMethod) as [AzureKeyVaultConnectionMethod.OAuth], + methods: Object.values(AzureKeyVaultConnectionMethod) as [ + AzureKeyVaultConnectionMethod.OAuth, + AzureKeyVaultConnectionMethod.ClientSecret + ], oauthClientId: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID }; }; @@ -111,68 +158,108 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK const { INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, SITE_URL } = getConfig(); - if (!INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || !INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET) { - throw new InternalServerError({ - message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` - }); - } - - let tokenResp: AxiosResponse | null = null; - let tokenError: AxiosError | null = null; - - try { - tokenResp = await request.post( - IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"), - new URLSearchParams({ - grant_type: "authorization_code", - code: inputCredentials.code, - scope: `openid offline_access https://vault.azure.net/.default`, - client_id: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, - client_secret: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, - redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` - }) - ); - } catch (e: unknown) { - if (e instanceof AxiosError) { - tokenError = e; - } else { - throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` - }); - } - } - - if (tokenError) { - if (tokenError instanceof AxiosError) { - throw new BadRequestError({ - message: `Failed to get access token: ${ - (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" - }` - }); - } else { - throw new InternalServerError({ - message: "Failed to get access token" - }); - } - } - - if (!tokenResp) { - throw new InternalServerError({ - message: `Failed to get access token: Token was empty with no error` - }); - } - switch (method) { case AzureKeyVaultConnectionMethod.OAuth: + if (!INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || !INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET) { + throw new InternalServerError({ + message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse | null = null; + let tokenError: AxiosError | null = null; + const oauthCredentials = inputCredentials as { code: string; tenantId?: string }; + try { + tokenResp = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "authorization_code", + code: oauthCredentials.code, + scope: `openid offline_access https://vault.azure.net/.default`, + client_id: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, + redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` + }) + ); + } catch (e: unknown) { + if (e instanceof AxiosError) { + tokenError = e; + } else { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } + } + + if (tokenError) { + if (tokenError instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + + if (!tokenResp) { + throw new InternalServerError({ + message: `Failed to get access token: Token was empty with no error` + }); + } + return { - tenantId: inputCredentials.tenantId, + tenantId: oauthCredentials.tenantId, accessToken: tokenResp.data.access_token, refreshToken: tokenResp.data.refresh_token, expiresAt: Date.now() + tokenResp.data.expires_in * 1000 }; + + case AzureKeyVaultConnectionMethod.ClientSecret: + const { tenantId, clientId, clientSecret } = inputCredentials as { + tenantId: string; + clientId: string; + clientSecret: string; + }; + + try { + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://vault.azure.net/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + return { + tenantId, + accessToken: clientData.access_token, + expiresAt: Date.now() + clientData.expires_in * 1000, + clientId, + clientSecret + }; + } catch (e: unknown) { + if (e instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (e?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + default: throw new InternalServerError({ - message: `Unhandled Azure connection method: ${method as AzureKeyVaultConnectionMethod}` + message: `Unhandled Azure Key Vault connection method: ${method as AzureKeyVaultConnectionMethod}` }); } }; diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts index f3c7c43b8..d86878bab 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts @@ -22,6 +22,29 @@ export const AzureKeyVaultConnectionOAuthOutputCredentialsSchema = z.object({ expiresAt: z.number() }); +export const AzureKeyVaultConnectionClientSecretInputCredentialsSchema = z.object({ + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .max(50, "Client ID must be at most 50 characters long"), + clientSecret: z + .string() + .trim() + .min(1, "Client Secret required") + .max(50, "Client Secret must be at most 50 characters long"), + tenantId: z.string().uuid().trim().min(1, "Tenant ID required") +}); + +export const AzureKeyVaultConnectionClientSecretOutputCredentialsSchema = z.object({ + clientId: z.string(), + clientSecret: z.string(), + tenantId: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureKeyVaultConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -30,6 +53,14 @@ export const ValidateAzureKeyVaultConnectionCredentialsSchema = z.discriminatedU credentials: AzureKeyVaultConnectionOAuthInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureKeyVault).credentials ) + }), + z.object({ + method: z + .literal(AzureKeyVaultConnectionMethod.ClientSecret) + .describe(AppConnections.CREATE(AppConnection.AzureKeyVault).method), + credentials: AzureKeyVaultConnectionClientSecretInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureKeyVault).credentials + ) }) ]); @@ -39,9 +70,13 @@ export const CreateAzureKeyVaultConnectionSchema = ValidateAzureKeyVaultConnecti export const UpdateAzureKeyVaultConnectionSchema = z .object({ - credentials: AzureKeyVaultConnectionOAuthInputCredentialsSchema.optional().describe( - AppConnections.UPDATE(AppConnection.AzureKeyVault).credentials - ) + credentials: z + .union([ + AzureKeyVaultConnectionOAuthInputCredentialsSchema, + AzureKeyVaultConnectionClientSecretInputCredentialsSchema + ]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.AzureKeyVault).credentials) }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureKeyVault)); @@ -55,6 +90,10 @@ export const AzureKeyVaultConnectionSchema = z.intersection( z.object({ method: z.literal(AzureKeyVaultConnectionMethod.OAuth), credentials: AzureKeyVaultConnectionOAuthOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureKeyVaultConnectionMethod.ClientSecret), + credentials: AzureKeyVaultConnectionClientSecretOutputCredentialsSchema }) ]) ); @@ -65,6 +104,13 @@ export const SanitizedAzureKeyVaultConnectionSchema = z.discriminatedUnion("meth credentials: AzureKeyVaultConnectionOAuthOutputCredentialsSchema.pick({ tenantId: true }) + }), + BaseAzureKeyVaultConnectionSchema.extend({ + method: z.literal(AzureKeyVaultConnectionMethod.ClientSecret), + credentials: AzureKeyVaultConnectionClientSecretOutputCredentialsSchema.pick({ + clientId: true, + tenantId: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-types.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-types.ts index 95e68952a..5c5c89b95 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-types.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureKeyVaultConnectionClientSecretOutputCredentialsSchema, AzureKeyVaultConnectionOAuthOutputCredentialsSchema, AzureKeyVaultConnectionSchema, CreateAzureKeyVaultConnectionSchema, @@ -36,3 +37,7 @@ export type ExchangeCodeAzureResponse = { }; export type TAzureKeyVaultConnectionCredentials = z.infer; + +export type TAzureKeyVaultConnectionClientSecretCredentials = z.infer< + typeof AzureKeyVaultConnectionClientSecretOutputCredentialsSchema +>; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts index d0ac070f3..02f304a7f 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts @@ -10,7 +10,8 @@ import { TCloudflareConnection, TCloudflareConnectionConfig, TCloudflarePagesProject, - TCloudflareWorkersScript + TCloudflareWorkersScript, + TCloudflareZone } from "./cloudflare-connection-types"; export const getCloudflareConnectionListItem = () => { @@ -66,6 +67,27 @@ export const listCloudflareWorkersScripts = async ( })); }; +export const listCloudflareZones = async (appConnection: TCloudflareConnection): Promise => { + const { + credentials: { apiToken } + } = appConnection; + + const { data } = await request.get<{ result: { name: string; id: string }[] }>( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + } + ); + + return data.result.map((a) => ({ + name: a.name, + id: a.id + })); +}; + export const validateCloudflareConnectionCredentials = async (config: TCloudflareConnectionConfig) => { const { apiToken, accountId } = config.credentials; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts index 5a8a161fc..c832ddb90 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts @@ -2,7 +2,11 @@ import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { listCloudflarePagesProjects, listCloudflareWorkersScripts } from "./cloudflare-connection-fns"; +import { + listCloudflarePagesProjects, + listCloudflareWorkersScripts, + listCloudflareZones +} from "./cloudflare-connection-fns"; import { TCloudflareConnection } from "./cloudflare-connection-types"; type TGetAppConnectionFunc = ( @@ -16,7 +20,6 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); try { const projects = await listCloudflarePagesProjects(appConnection); - return projects; } catch (error) { logger.error( @@ -30,9 +33,8 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF const listWorkersScripts = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); try { - const projects = await listCloudflareWorkersScripts(appConnection); - - return projects; + const scripts = await listCloudflareWorkersScripts(appConnection); + return scripts; } catch (error) { logger.error( error, @@ -42,8 +44,20 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF } }; + const listZones = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); + try { + const zones = await listCloudflareZones(appConnection); + return zones; + } catch (error) { + logger.error(error, `Failed to list Cloudflare Zones for Cloudflare connection [connectionId=${connectionId}]`); + return []; + } + }; + return { listPagesProjects, - listWorkersScripts + listWorkersScripts, + listZones }; }; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts index 0ac1b708c..163bf6495 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts @@ -32,3 +32,8 @@ export type TCloudflarePagesProject = { export type TCloudflareWorkersScript = { id: string; }; + +export type TCloudflareZone = { + id: string; + name: string; +}; diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index 57d01be29..e4bd2e10d 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -1,4 +1,5 @@ import { createAppAuth } from "@octokit/auth-app"; +import { request } from "@octokit/request"; import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; import https from "https"; import RE2 from "re2"; @@ -12,7 +13,6 @@ import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns"; -import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { AppConnection } from "../app-connection-enums"; import { GitHubConnectionMethod } from "./github-connection-enums"; @@ -30,6 +30,23 @@ export const getGitHubConnectionListItem = () => { }; }; +export const getGitHubInstanceApiUrl = async (config: { + credentials: Pick; +}) => { + const host = config.credentials.host || "github.com"; + + await blockLocalAndPrivateIpAddresses(`https://${host}`); + + let apiBase: string; + if (config.credentials.instanceType === "server") { + apiBase = `${host}/api/v3`; + } else { + apiBase = `api.${host}`; + } + + return apiBase; +}; + export const requestWithGitHubGateway = async ( appConnection: { gatewayId?: string | null }, gatewayService: Pick, @@ -73,7 +90,10 @@ export const requestWithGitHubGateway = async ( return await httpRequest.request(finalRequestConfig); } catch (error) { const axiosError = error as AxiosError; - logger.error("Error during GitHub gateway request:", axiosError.message, axiosError.response?.data); + logger.error( + { message: axiosError.message, data: axiosError.response?.data }, + "Error during GitHub gateway request:" + ); throw error; } }, @@ -112,7 +132,10 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => const appAuth = createAppAuth({ appId, privateKey: appPrivateKey, - installationId: appConnection.credentials.installationId + installationId: appConnection.credentials.installationId, + request: request.defaults({ + baseUrl: `https://${await getGitHubInstanceApiUrl(appConnection)}` + }) }); const { token } = await appAuth({ type: "installation" }); @@ -141,7 +164,7 @@ export const makePaginatedGitHubRequest = async ( const token = method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection); - let url: string | null = `https://api.${credentials.host || "github.com"}${path}`; + let url: string | null = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; let results: T[] = []; let i = 0; @@ -325,6 +348,8 @@ export const validateGitHubConnectionCredentials = async ( }); } } catch (e: unknown) { + logger.error(e, "Unable to verify GitHub connection"); + if (e instanceof BadRequestError) { throw e; } @@ -355,7 +380,7 @@ export const validateGitHubConnectionCredentials = async ( }; }[]; }>(config, gatewayService, { - url: IntegrationUrls.GITHUB_USER_INSTALLATIONS.replace("api.github.com", `api.${host}`), + url: `https://${await getGitHubInstanceApiUrl(config)}/user/installations`, headers: { Accept: "application/json", Authorization: `Bearer ${tokenResp.data.access_token}`, @@ -377,11 +402,15 @@ export const validateGitHubConnectionCredentials = async ( switch (method) { case GitHubConnectionMethod.App: return { - installationId: credentials.installationId + installationId: credentials.installationId, + instanceType: credentials.instanceType, + host: credentials.host }; case GitHubConnectionMethod.OAuth: return { - accessToken: tokenResp.data.access_token + accessToken: tokenResp.data.access_token, + instanceType: credentials.instanceType, + host: credentials.host }; default: throw new InternalServerError({ diff --git a/backend/src/services/app-connection/github/github-connection-schemas.ts b/backend/src/services/app-connection/github/github-connection-schemas.ts index bf92ec155..1b8aa9c3f 100644 --- a/backend/src/services/app-connection/github/github-connection-schemas.ts +++ b/backend/src/services/app-connection/github/github-connection-schemas.ts @@ -10,26 +10,59 @@ import { import { GitHubConnectionMethod } from "./github-connection-enums"; -export const GitHubConnectionOAuthInputCredentialsSchema = z.object({ - code: z.string().trim().min(1, "OAuth code required"), - host: z.string().trim().optional() -}); +export const GitHubConnectionOAuthInputCredentialsSchema = z.union([ + z.object({ + code: z.string().trim().min(1, "OAuth code required"), + instanceType: z.literal("server"), + host: z.string().trim().min(1, "Host is required for server instance type") + }), + z.object({ + code: z.string().trim().min(1, "OAuth code required"), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); -export const GitHubConnectionAppInputCredentialsSchema = z.object({ - code: z.string().trim().min(1, "GitHub App code required"), - installationId: z.string().min(1, "GitHub App Installation ID required"), - host: z.string().trim().optional() -}); +export const GitHubConnectionAppInputCredentialsSchema = z.union([ + z.object({ + code: z.string().trim().min(1, "GitHub App code required"), + installationId: z.string().min(1, "GitHub App Installation ID required"), + instanceType: z.literal("server"), + host: z.string().trim().min(1, "Host is required for server instance type") + }), + z.object({ + code: z.string().trim().min(1, "GitHub App code required"), + installationId: z.string().min(1, "GitHub App Installation ID required"), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); -export const GitHubConnectionOAuthOutputCredentialsSchema = z.object({ - accessToken: z.string(), - host: z.string().trim().optional() -}); +export const GitHubConnectionOAuthOutputCredentialsSchema = z.union([ + z.object({ + accessToken: z.string(), + instanceType: z.literal("server"), + host: z.string().trim().min(1) + }), + z.object({ + accessToken: z.string(), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); -export const GitHubConnectionAppOutputCredentialsSchema = z.object({ - installationId: z.string(), - host: z.string().trim().optional() -}); +export const GitHubConnectionAppOutputCredentialsSchema = z.union([ + z.object({ + installationId: z.string(), + instanceType: z.literal("server"), + host: z.string().trim().min(1) + }), + z.object({ + installationId: z.string(), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ @@ -84,11 +117,17 @@ export const GitHubConnectionSchema = z.intersection( export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [ BaseGitHubConnectionSchema.extend({ method: z.literal(GitHubConnectionMethod.App), - credentials: GitHubConnectionAppOutputCredentialsSchema.pick({}) + credentials: z.object({ + instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), + host: z.string().optional() + }) }), BaseGitHubConnectionSchema.extend({ method: z.literal(GitHubConnectionMethod.OAuth), - credentials: GitHubConnectionOAuthOutputCredentialsSchema.pick({}) + credentials: z.object({ + instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), + host: z.string().optional() + }) }) ]); diff --git a/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts b/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts index d874d1447..cb4e27e94 100644 --- a/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts +++ b/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts @@ -222,6 +222,37 @@ export const validateGitLabConnectionCredentials = async (config: TGitLabConnect return inputCredentials; }; +export const getGitLabConnectionClient = async ( + appConnection: TGitLabConnection, + appConnectionDAL: Pick, + kmsService: Pick +) => { + let { accessToken } = appConnection.credentials; + + if ( + appConnection.method === GitLabConnectionMethod.OAuth && + appConnection.credentials.refreshToken && + new Date(appConnection.credentials.expiresAt) < new Date() + ) { + accessToken = await refreshGitLabToken( + appConnection.credentials.refreshToken, + appConnection.id, + appConnection.orgId, + appConnectionDAL, + kmsService, + appConnection.credentials.instanceUrl + ); + } + + const client = await getGitLabClient( + accessToken, + appConnection.credentials.instanceUrl, + appConnection.method === GitLabConnectionMethod.OAuth + ); + + return client; +}; + export const listGitLabProjects = async ({ appConnection, appConnectionDAL, diff --git a/backend/src/services/app-connection/netlify/index.ts b/backend/src/services/app-connection/netlify/index.ts new file mode 100644 index 000000000..3e0f589f5 --- /dev/null +++ b/backend/src/services/app-connection/netlify/index.ts @@ -0,0 +1,4 @@ +export * from "./netlify-connection-constants"; +export * from "./netlify-connection-fns"; +export * from "./netlify-connection-schemas"; +export * from "./netlify-connection-types"; diff --git a/backend/src/services/app-connection/netlify/netlify-connection-constants.ts b/backend/src/services/app-connection/netlify/netlify-connection-constants.ts new file mode 100644 index 000000000..6e74bd2f7 --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-constants.ts @@ -0,0 +1,3 @@ +export enum NetlifyConnectionMethod { + AccessToken = "access-token" +} diff --git a/backend/src/services/app-connection/netlify/netlify-connection-fns.ts b/backend/src/services/app-connection/netlify/netlify-connection-fns.ts new file mode 100644 index 000000000..7f1bee4ff --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-fns.ts @@ -0,0 +1,35 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { BadRequestError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { NetlifyConnectionMethod } from "./netlify-connection-constants"; +import { NetlifyPublicAPI } from "./netlify-connection-public-client"; +import { TNetlifyConnectionConfig } from "./netlify-connection-types"; + +export const getNetlifyConnectionListItem = () => { + return { + name: "Netlify" as const, + app: AppConnection.Netlify as const, + methods: Object.values(NetlifyConnectionMethod) + }; +}; + +export const validateNetlifyConnectionCredentials = async (config: TNetlifyConnectionConfig) => { + try { + await NetlifyPublicAPI.healthcheck(config); + } 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; +}; diff --git a/backend/src/services/app-connection/netlify/netlify-connection-public-client.ts b/backend/src/services/app-connection/netlify/netlify-connection-public-client.ts new file mode 100644 index 000000000..d1328539b --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-public-client.ts @@ -0,0 +1,261 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable no-await-in-loop */ +/* eslint-disable class-methods-use-this */ +import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode, isAxiosError } from "axios"; + +import { createRequestClient } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { NetlifyConnectionMethod } from "./netlify-connection-constants"; +import { TNetlifyAccount, TNetlifyConnectionConfig, TNetlifySite, TNetlifyVariable } from "./netlify-connection-types"; + +export function getNetlifyAuthHeaders(connection: TNetlifyConnectionConfig): Record { + switch (connection.method) { + case NetlifyConnectionMethod.AccessToken: + return { + Authorization: `Bearer ${connection.credentials.accessToken}` + }; + default: + throw new Error(`Unsupported Netlify connection method`); + } +} + +export function getNetlifyRatelimiter(response: AxiosResponse): { + maxAttempts: number; + isRatelimited: boolean; + wait: () => Promise; +} { + const wait = (seconds: number = 60) => { + return new Promise((res) => { + setTimeout(res, seconds * 1000); + }); + }; + + let remaining = parseInt(response.headers["X-RateLimit-Remaining"] as string, 10); + let isRatelimited = response.status === HttpStatusCode.TooManyRequests; + + if (isRatelimited) { + if (Math.round(remaining) > 0) { + isRatelimited = true; + remaining += 1; // Jitter to ensure we wait at least 1 second + } else { + remaining = 60; + } + } + + return { + isRatelimited, + wait: () => wait(remaining), + maxAttempts: 3 + }; +} + +type NetlifyParams = { + account_id: string; + context_name?: string; + site_id?: string; +}; + +class NetlifyPublicClient { + private client: AxiosInstance; + + constructor() { + this.client = createRequestClient({ + baseURL: `${IntegrationUrls.NETLIFY_API_URL}/api/v1`, + headers: { + "Content-Type": "application/json" + } + }); + } + + async send(connection: TNetlifyConnectionConfig, config: AxiosRequestConfig, retryAttempt = 0): Promise { + const response = await this.client.request({ + ...config, + timeout: 1000 * 60, // 60 seconds timeout + validateStatus: (status) => (status >= 200 && status < 300) || status === HttpStatusCode.TooManyRequests, + headers: getNetlifyAuthHeaders(connection) + }); + const limiter = getNetlifyRatelimiter(response); + + if (limiter.isRatelimited && retryAttempt <= limiter.maxAttempts) { + await limiter.wait(); + return this.send(connection, config, retryAttempt + 1); + } + + return response.data; + } + + healthcheck(connection: TNetlifyConnectionConfig) { + switch (connection.method) { + case NetlifyConnectionMethod.AccessToken: + return this.getNetlifyAccounts(connection); + default: + throw new Error(`Unsupported Netlify connection method`); + } + } + + async getVariables( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + limit: number = 50, + page: number = 1 + ) { + const res = await this.send(connection, { + method: "GET", + url: `/accounts/${account_id}/env`, + params: { + ...params, + limit, + page + } + }); + + return res; + } + + async createVariable( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + ...variables: TNetlifyVariable[] + ) { + const res = await this.send(connection, { + method: "POST", + url: `/accounts/${account_id}/env`, + data: variables, + params + }); + + return res; + } + + async updateVariableValue( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + variable: TNetlifyVariable + ) { + const res = await this.send(connection, { + method: "PATCH", + url: `/accounts/${account_id}/env/${variable.key}`, + data: variable, + params + }); + + return res; + } + + async updateVariable( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + variable: TNetlifyVariable + ) { + const res = await this.send(connection, { + method: "PUT", + url: `/accounts/${account_id}/env/${variable.key}`, + data: variable, + params + }); + + return res; + } + + async getVariable( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + variable: Pick + ) { + try { + const res = await this.send(connection, { + method: "GET", + url: `/accounts/${account_id}/env/${variable.key}`, + params + }); + + return res; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + + throw error; + } + } + + async upsertVariable(connection: TNetlifyConnectionConfig, params: NetlifyParams, variable: TNetlifyVariable) { + const res = await this.getVariable(connection, params, variable); + + if (!res) { + return this.createVariable(connection, params, variable); + } + + if (res.is_secret) { + await this.deleteVariable(connection, params, variable); + return this.createVariable(connection, params, variable); + } + + return this.updateVariable(connection, params, variable); + } + + async deleteVariable( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + variable: Pick + ) { + try { + const res = await this.send(connection, { + method: "DELETE", + url: `/accounts/${account_id}/env/${variable.key}`, + params + }); + + return res; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + + throw error; + } + } + + async deleteVariableValue( + connection: TNetlifyConnectionConfig, + { account_id, value_id, ...params }: NetlifyParams & { value_id: string }, + variable: Pick + ) { + try { + const res = await this.send(connection, { + method: "DELETE", + url: `/accounts/${account_id}/${variable.key}/value/${value_id}`, + params + }); + + return res; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + + throw error; + } + } + + async getSites(connection: TNetlifyConnectionConfig, accountId: string) { + const res = await this.send(connection, { + method: "GET", + url: `/${accountId}/sites` + }); + + return res; + } + + async getNetlifyAccounts(connection: TNetlifyConnectionConfig) { + const res = await this.send(connection, { + method: "GET", + url: `/accounts` + }); + + return res; + } +} + +export const NetlifyPublicAPI = new NetlifyPublicClient(); diff --git a/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts b/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts new file mode 100644 index 000000000..45fb89760 --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts @@ -0,0 +1,67 @@ +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 { NetlifyConnectionMethod } from "./netlify-connection-constants"; + +export const NetlifyConnectionMethodSchema = z + .nativeEnum(NetlifyConnectionMethod) + .describe(AppConnections.CREATE(AppConnection.Netlify).method); + +export const NetlifyConnectionAccessTokenCredentialsSchema = z.object({ + accessToken: z + .string() + .trim() + .min(1, "Access Token required") + .max(255) + .describe(AppConnections.CREDENTIALS.NETLIFY.accessToken) +}); + +const BaseNetlifyConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Netlify) +}); + +export const NetlifyConnectionSchema = BaseNetlifyConnectionSchema.extend({ + method: NetlifyConnectionMethodSchema, + credentials: NetlifyConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedNetlifyConnectionSchema = z.discriminatedUnion("method", [ + BaseNetlifyConnectionSchema.extend({ + method: NetlifyConnectionMethodSchema, + credentials: NetlifyConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateNetlifyConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: NetlifyConnectionMethodSchema, + credentials: NetlifyConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Netlify).credentials + ) + }) +]); + +export const CreateNetlifyConnectionSchema = ValidateNetlifyConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Netlify) +); + +export const UpdateNetlifyConnectionSchema = z + .object({ + credentials: NetlifyConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Netlify).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Netlify)); + +export const NetlifyConnectionListItemSchema = z.object({ + name: z.literal("Netlify"), + app: z.literal(AppConnection.Netlify), + methods: z.nativeEnum(NetlifyConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/netlify/netlify-connection-service.ts b/backend/src/services/app-connection/netlify/netlify-connection-service.ts new file mode 100644 index 000000000..c2cf65e24 --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-service.ts @@ -0,0 +1,42 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { NetlifyPublicAPI } from "./netlify-connection-public-client"; +import { TNetlifyConnection } from "./netlify-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const netlifyConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listAccounts = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Netlify, connectionId, actor); + try { + const accounts = await NetlifyPublicAPI.getNetlifyAccounts(appConnection); + return accounts; + } catch (error) { + logger.error(error, "Failed to list accounts on Netlify"); + return []; + } + }; + + const listSites = async (connectionId: string, actor: OrgServiceActor, accountId: string) => { + const appConnection = await getAppConnection(AppConnection.Netlify, connectionId, actor); + try { + const sites = await NetlifyPublicAPI.getSites(appConnection, accountId); + return sites; + } catch (error) { + logger.error(error, "Failed to list sites on Netlify"); + return []; + } + }; + + return { + listAccounts, + listSites + }; +}; diff --git a/backend/src/services/app-connection/netlify/netlify-connection-types.ts b/backend/src/services/app-connection/netlify/netlify-connection-types.ts new file mode 100644 index 000000000..dc32f48d0 --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-types.ts @@ -0,0 +1,51 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateNetlifyConnectionSchema, + NetlifyConnectionSchema, + ValidateNetlifyConnectionCredentialsSchema +} from "./netlify-connection-schemas"; + +export type TNetlifyConnection = z.infer; + +export type TNetlifyConnectionInput = z.infer & { + app: AppConnection.Netlify; +}; + +export type TValidateNetlifyConnectionCredentialsSchema = typeof ValidateNetlifyConnectionCredentialsSchema; + +export type TNetlifyConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TNetlifyVariable = { + key: string; + id?: string; // ID of the variable (present in responses) + created_at?: string; + updated_at?: string; + is_secret?: boolean; + scopes?: ("builds" | "functions" | "runtime" | "post_processing")[]; + values: TNetlifyVariableValue[]; +}; + +export type TNetlifyVariableValue = { + id?: string; + context?: string; // "all", "dev", "branch-deploy", etc. + value?: string; // Omitted in response if `is_secret` is true + site_id?: string; // Optional: overrides at site-level + created_at?: string; + updated_at?: string; +}; + +export type TNetlifyAccount = { + id: string; + name: string; +}; + +export type TNetlifySite = { + id: string; + name: string; +}; diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 8f320f645..efc8b3cc0 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -115,6 +115,10 @@ export const authPaswordServiceFactory = ({ } if (!user.authMethods?.includes(AuthMethod.EMAIL)) { + logger.error( + { authMethods: user.authMethods }, + "Unable to reset password, no email authentication method is configured" + ); throw new BadRequestError({ message: "Unable to reset password, no email authentication method is configured" }); } diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts index 9f6fbe752..c4703d49f 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts @@ -1,3 +1,4 @@ export enum AcmeDnsProvider { - Route53 = "route53" + Route53 = "route53", + Cloudflare = "cloudflare" } diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index f6e77ac8e..86beadffe 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -1,19 +1,17 @@ -import { ChangeResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53"; import * as x509 from "@peculiar/x509"; import acme from "acme-client"; import { TableName } from "@app/db/schemas"; -import { CustomAWSHasher } from "@app/lib/aws/hashing"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, CryptographyError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; -import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; -import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; -import { TAwsConnection, TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; +import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; +import { TCloudflareConnection } from "@app/services/app-connection/cloudflare/cloudflare-connection-types"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; @@ -39,6 +37,8 @@ import { TCreateAcmeCertificateAuthorityDTO, TUpdateAcmeCertificateAuthorityDTO } from "./acme-certificate-authority-types"; +import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare"; +import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54"; type TAcmeCertificateAuthorityFnsDeps = { appConnectionDAL: Pick; @@ -95,74 +95,6 @@ export const castDbEntryToAcmeCertificateAuthority = ( }; }; -export const route53InsertTxtRecord = async ( - connection: TAwsConnectionConfig, - hostedZoneId: string, - domain: string, - value: string -) => { - const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global - const route53Client = new Route53Client({ - sha256: CustomAWSHasher, - useFipsEndpoint: crypto.isFipsModeEnabled(), - credentials: config.credentials!, - region: config.region - }); - - const command = new ChangeResourceRecordSetsCommand({ - HostedZoneId: hostedZoneId, - ChangeBatch: { - Comment: "Set ACME challenge TXT record", - Changes: [ - { - Action: "UPSERT", - ResourceRecordSet: { - Name: domain, - Type: "TXT", - TTL: 30, - ResourceRecords: [{ Value: value }] - } - } - ] - } - }); - - await route53Client.send(command); -}; - -export const route53DeleteTxtRecord = async ( - connection: TAwsConnectionConfig, - hostedZoneId: string, - domain: string, - value: string -) => { - const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global - const route53Client = new Route53Client({ - credentials: config.credentials!, - region: config.region - }); - - const command = new ChangeResourceRecordSetsCommand({ - HostedZoneId: hostedZoneId, - ChangeBatch: { - Comment: "Delete ACME challenge TXT record", - Changes: [ - { - Action: "DELETE", - ResourceRecordSet: { - Name: domain, - Type: "TXT", - TTL: 30, - ResourceRecords: [{ Value: value }] - } - } - ] - } - }); - - await route53Client.send(command); -}; - export const AcmeCertificateAuthorityFns = ({ appConnectionDAL, appConnectionService, @@ -209,6 +141,12 @@ export const AcmeCertificateAuthorityFns = ({ }); } + if (dnsProviderConfig.provider === AcmeDnsProvider.Cloudflare && appConnection.app !== AppConnection.Cloudflare) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not a Cloudflare connection` + }); + } + // validates permission to connect await appConnectionService.connectAppConnectionById(appConnection.app as AppConnection, dnsAppConnectionId, actor); @@ -289,6 +227,15 @@ export const AcmeCertificateAuthorityFns = ({ }); } + if ( + dnsProviderConfig.provider === AcmeDnsProvider.Cloudflare && + appConnection.app !== AppConnection.Cloudflare + ) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not a Cloudflare connection` + }); + } + // validates permission to connect await appConnectionService.connectAppConnectionById( appConnection.app as AppConnection, @@ -443,26 +390,56 @@ export const AcmeCertificateAuthorityFns = ({ const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" const recordValue = `"${keyAuthorization}"`; // must be double quoted - if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { - await route53InsertTxtRecord( - connection as TAwsConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); + switch (acmeCa.configuration.dnsProviderConfig.provider) { + case AcmeDnsProvider.Route53: { + await route53InsertTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + case AcmeDnsProvider.Cloudflare: { + await cloudflareInsertTxtRecord( + connection as TCloudflareConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + default: { + throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); + } } }, challengeRemoveFn: async (authz, challenge, keyAuthorization) => { const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" const recordValue = `"${keyAuthorization}"`; // must be double quoted - if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { - await route53DeleteTxtRecord( - connection as TAwsConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); + switch (acmeCa.configuration.dnsProviderConfig.provider) { + case AcmeDnsProvider.Route53: { + await route53DeleteTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + case AcmeDnsProvider.Cloudflare: { + await cloudflareDeleteTxtRecord( + connection as TCloudflareConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + default: { + throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); + } } } }); diff --git a/backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts b/backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts new file mode 100644 index 000000000..f4b12e657 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts @@ -0,0 +1,109 @@ +import axios from "axios"; + +import { request } from "@app/lib/config/request"; +import { TCloudflareConnectionConfig } from "@app/services/app-connection/cloudflare/cloudflare-connection-types"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +export const cloudflareInsertTxtRecord = async ( + connection: TCloudflareConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiToken } + } = connection; + + try { + await request.post( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones/${encodeURIComponent(hostedZoneId)}/dns_records`, + { + type: "TXT", + name: domain, + content: value, + ttl: 60, + proxied: false + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } catch (error) { + if (axios.isAxiosError(error)) { + const firstErrorMessage = ( + error.response?.data as { + errors?: { message: string }[]; + } + )?.errors?.[0]?.message; + if (firstErrorMessage) { + throw new Error(firstErrorMessage); + } + } + throw error; + } +}; + +export const cloudflareDeleteTxtRecord = async ( + connection: TCloudflareConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiToken } + } = connection; + + try { + const listRecordsResponse = await request.get<{ + result: { id: string; type: string; name: string; content: string }[]; + }>(`${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones/${encodeURIComponent(hostedZoneId)}/dns_records`, { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json" + }, + params: { + type: "TXT", + name: domain, + content: value + } + }); + + const dnsRecords = listRecordsResponse.data?.result; + + if (Array.isArray(dnsRecords) && dnsRecords.length > 0) { + const recordToDelete = dnsRecords.find( + (record) => record.type === "TXT" && record.name === domain && record.content === value + ); + + if (recordToDelete) { + await request.delete( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones/${encodeURIComponent(hostedZoneId)}/dns_records/${recordToDelete.id}`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } + } + } catch (error) { + if (axios.isAxiosError(error)) { + const firstErrorMessage = ( + error.response?.data as { + errors?: { message: string }[]; + } + )?.errors?.[0]?.message; + if (firstErrorMessage) { + throw new Error(firstErrorMessage); + } + } + throw error; + } +}; diff --git a/backend/src/services/certificate-authority/acme/dns-providers/route54.ts b/backend/src/services/certificate-authority/acme/dns-providers/route54.ts new file mode 100644 index 000000000..4d235a6e0 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/dns-providers/route54.ts @@ -0,0 +1,75 @@ +import { ChangeResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53"; + +import { CustomAWSHasher } from "@app/lib/aws/hashing"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; +import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; + +export const route53InsertTxtRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global + const route53Client = new Route53Client({ + sha256: CustomAWSHasher, + useFipsEndpoint: crypto.isFipsModeEnabled(), + credentials: config.credentials!, + region: config.region + }); + + const command = new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + ChangeBatch: { + Comment: "Set ACME challenge TXT record", + Changes: [ + { + Action: "UPSERT", + ResourceRecordSet: { + Name: domain, + Type: "TXT", + TTL: 30, + ResourceRecords: [{ Value: value }] + } + } + ] + } + }); + + await route53Client.send(command); +}; + +export const route53DeleteTxtRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global + const route53Client = new Route53Client({ + credentials: config.credentials!, + region: config.region + }); + + const command = new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + ChangeBatch: { + Comment: "Delete ACME challenge TXT record", + Changes: [ + { + Action: "DELETE", + ResourceRecordSet: { + Name: domain, + Type: "TXT", + TTL: 30, + ResourceRecords: [{ Value: value }] + } + } + ] + } + }); + + await route53Client.send(command); +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts index fab62ddbf..3fa7e8d5e 100644 --- a/backend/src/services/certificate-authority/certificate-authority-validators.ts +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -15,10 +15,15 @@ export const validateAltNameField = z .trim() .refine( (name) => { - return isFQDN(name, { allow_wildcard: true }) || z.string().email().safeParse(name).success || isValidIp(name); + return ( + isFQDN(name, { allow_wildcard: true, require_tld: false }) || + z.string().url().safeParse(name).success || + z.string().email().safeParse(name).success || + isValidIp(name) + ); }, { - message: "SAN must be a valid hostname, email address, or IP address" + message: "SAN must be a valid hostname, email address, IP address or URL" } ); @@ -39,10 +44,15 @@ export const validateAltNamesField = z if (data === "") return true; // Split and validate each alt name return data.split(", ").every((name) => { - return isFQDN(name, { allow_wildcard: true }) || z.string().email().safeParse(name).success || isValidIp(name); + return ( + isFQDN(name, { allow_wildcard: true, require_tld: false }) || + z.string().url().safeParse(name).success || + z.string().email().safeParse(name).success || + isValidIp(name) + ); }); }, { - message: "Each alt name must be a valid hostname or email address" + message: "Each alt name must be a valid hostname, email address, IP address or URL" } ); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 80d2842fa..eace4e47a 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -152,7 +152,7 @@ export const InternalCertificateAuthorityFns = ({ extensions.push(extendedKeyUsagesExtension); } - let altNamesArray: { type: "email" | "dns"; value: string }[] = []; + let altNamesArray: { type: "email" | "dns" | "ip" | "url"; value: string }[] = []; if (subscriber.subjectAlternativeNames?.length) { altNamesArray = subscriber.subjectAlternativeNames.map((altName) => { @@ -160,10 +160,18 @@ export const InternalCertificateAuthorityFns = ({ return { type: "email", value: altName }; } - if (isFQDN(altName, { allow_wildcard: true })) { + if (isFQDN(altName, { allow_wildcard: true, require_tld: false })) { return { type: "dns", value: altName }; } + if (z.string().url().safeParse(altName).success) { + return { type: "url", value: altName }; + } + + if (z.string().ip().safeParse(altName).success) { + return { type: "ip", value: altName }; + } + throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); }); @@ -418,7 +426,7 @@ export const InternalCertificateAuthorityFns = ({ ); } - let altNamesArray: { type: "email" | "dns"; value: string }[] = []; + let altNamesArray: { type: "email" | "dns" | "ip" | "url"; value: string }[] = []; if (altNames) { altNamesArray = altNames.split(",").map((altName) => { @@ -426,10 +434,18 @@ export const InternalCertificateAuthorityFns = ({ return { type: "email", value: altName }; } - if (isFQDN(altName, { allow_wildcard: true })) { + if (isFQDN(altName, { allow_wildcard: true, require_tld: false })) { return { type: "dns", value: altName }; } + if (z.string().url().safeParse(altName).success) { + return { type: "url", value: altName }; + } + + if (z.string().ip().safeParse(altName).success) { + return { type: "ip", value: altName }; + } + throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); }); diff --git a/backend/src/services/external-migration/external-migration-fns/vault.ts b/backend/src/services/external-migration/external-migration-fns/vault.ts index 197102f82..b512ff11a 100644 --- a/backend/src/services/external-migration/external-migration-fns/vault.ts +++ b/backend/src/services/external-migration/external-migration-fns/vault.ts @@ -17,25 +17,16 @@ type VaultData = { const vaultFactory = () => { const getMounts = async (request: AxiosInstance) => { const response = await request - .get< - Record< - string, - { - accessor: string; - options: { - version?: string; - } | null; - type: string; - } - > - >("/v1/sys/mounts") + .get<{ + data: Record; + }>("/v1/sys/mounts") .catch((err) => { if (axios.isAxiosError(err)) { logger.error(err.response?.data, "External migration: Failed to get Vault mounts"); } throw err; }); - return response.data; + return response.data.data; }; const getPaths = async ( diff --git a/backend/src/services/external-migration/external-migration-queue.ts b/backend/src/services/external-migration/external-migration-queue.ts index 8bde91aa5..b4974d2ae 100644 --- a/backend/src/services/external-migration/external-migration-queue.ts +++ b/backend/src/services/external-migration/external-migration-queue.ts @@ -19,7 +19,7 @@ import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-d import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { importDataIntoInfisicalFn } from "./external-migration-fns"; -import { ExternalPlatforms, ImportType, TImportInfisicalDataCreate } from "./external-migration-types"; +import { ExternalPlatforms, TImportInfisicalDataCreate } from "./external-migration-types"; export type TExternalMigrationQueueFactoryDep = { smtpService: TSmtpService; @@ -66,8 +66,8 @@ export const externalMigrationQueueFactory = ({ }: TExternalMigrationQueueFactoryDep) => { const startImport = async (dto: { actorEmail: string; + importType: ExternalPlatforms; data: { - importType: ImportType; iv: string; tag: string; ciphertext: string; @@ -87,14 +87,14 @@ export const externalMigrationQueueFactory = ({ }; queueService.start(QueueName.ImportSecretsFromExternalSource, async (job) => { - try { - const { data, actorEmail } = job.data; + const { data, actorEmail, importType } = job.data; + try { await smtpService.sendMail({ recipients: [actorEmail], subjectLine: "Infisical import started", substitutions: { - provider: ExternalPlatforms.EnvKey + provider: importType }, template: SmtpTemplates.ExternalImportStarted }); @@ -141,7 +141,7 @@ export const externalMigrationQueueFactory = ({ recipients: [actorEmail], subjectLine: "Infisical import successful", substitutions: { - provider: ExternalPlatforms.EnvKey + provider: importType }, template: SmtpTemplates.ExternalImportSuccessful }); @@ -150,7 +150,7 @@ export const externalMigrationQueueFactory = ({ recipients: [job.data.actorEmail], subjectLine: "Infisical import failed", substitutions: { - provider: ExternalPlatforms.EnvKey, + provider: importType, // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment error: (err as any)?.message || "Unknown error" }, diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 766b6cef4..3bbb88f5b 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -6,7 +6,7 @@ import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { TUserDALFactory } from "../user/user-dal"; import { decryptEnvKeyDataFn, importVaultDataFn, parseEnvKeyDataFn } from "./external-migration-fns"; import { TExternalMigrationQueueFactory } from "./external-migration-queue"; -import { ImportType, TImportEnvKeyDataDTO, TImportVaultDataDTO } from "./external-migration-types"; +import { ExternalPlatforms, TImportEnvKeyDataDTO, TImportVaultDataDTO } from "./external-migration-types"; type TExternalMigrationServiceFactoryDep = { permissionService: TPermissionServiceFactory; @@ -60,8 +60,8 @@ export const externalMigrationServiceFactory = ({ await externalMigrationQueue.startImport({ actorEmail: user.email!, + importType: ExternalPlatforms.EnvKey, data: { - importType: ImportType.EnvKey, ...encrypted } }); @@ -110,8 +110,8 @@ export const externalMigrationServiceFactory = ({ await externalMigrationQueue.startImport({ actorEmail: user.email!, + importType: ExternalPlatforms.Vault, data: { - importType: ImportType.Vault, ...encrypted } }); diff --git a/backend/src/services/external-migration/external-migration-types.ts b/backend/src/services/external-migration/external-migration-types.ts index 89589d674..7c0d4c9ea 100644 --- a/backend/src/services/external-migration/external-migration-types.ts +++ b/backend/src/services/external-migration/external-migration-types.ts @@ -2,11 +2,6 @@ import { TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; -export enum ImportType { - EnvKey = "envkey", - Vault = "vault" -} - export enum VaultMappingType { Namespace = "namespace", KeyVault = "key-vault" @@ -112,5 +107,6 @@ export type TEnvKeyExportJSON = { }; export enum ExternalPlatforms { - EnvKey = "EnvKey" + EnvKey = "EnvKey", + Vault = "Vault" } diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index 399ef7da9..f38f53cf0 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -2,9 +2,14 @@ import { ForbiddenError } from "@casl/ability"; import { IdentityAuthMethod } from "@app/db/schemas"; +import { TIdentityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template"; import { testLDAPConfig } from "@app/ee/services/ldap-config/ldap-fns"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + OrgPermissionIdentityActions, + OrgPermissionMachineIdentityAuthTemplateActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; import { constructPermissionErrorMessage, validatePrivilegeChangeOperation @@ -44,6 +49,7 @@ type TIdentityLdapAuthServiceFactoryDep = { permissionService: Pick; kmsService: TKmsServiceFactory; identityDAL: TIdentityDALFactory; + identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; }; export type TIdentityLdapAuthServiceFactory = ReturnType; @@ -55,7 +61,8 @@ export const identityLdapAuthServiceFactory = ({ identityOrgMembershipDAL, licenseService, permissionService, - kmsService + kmsService, + identityAuthTemplateDAL }: TIdentityLdapAuthServiceFactoryDep) => { const getLdapConfig = async (identityId: string) => { const identity = await identityDAL.findOne({ id: identityId }); @@ -173,6 +180,7 @@ export const identityLdapAuthServiceFactory = ({ const attachLdapAuth = async ({ identityId, + templateId, url, searchBase, searchFilter, @@ -213,6 +221,14 @@ export const identityLdapAuthServiceFactory = ({ actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + + if (templateId) { + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + } + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); if (!plan.ldap) { @@ -241,33 +257,55 @@ export const identityLdapAuthServiceFactory = ({ if (allowedFields) AllowedFieldsSchema.array().parse(allowedFields); const identityLdapAuth = await identityLdapAuthDAL.transaction(async (tx) => { - const { encryptor } = await kmsService.createCipherPairWithDataKey({ + const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityMembershipOrg.orgId }); + const template = templateId + ? await identityAuthTemplateDAL.findByIdAndOrgId(templateId, identityMembershipOrg.orgId) + : undefined; + + let ldapConfig: { bindDN: string; bindPass: string; searchBase: string; url: string; ldapCaCertificate?: string }; + if (template) { + ldapConfig = JSON.parse(decryptor({ cipherTextBlob: template.templateFields }).toString()); + } else { + if (!bindDN || !bindPass || !searchBase || !url) { + throw new BadRequestError({ + message: "Invalid request. Missing bind DN, bind pass, search base, or URL." + }); + } + ldapConfig = { + bindDN, + bindPass, + searchBase, + url, + ldapCaCertificate + }; + } + const { cipherTextBlob: encryptedBindPass } = encryptor({ - plainText: Buffer.from(bindPass) + plainText: Buffer.from(ldapConfig.bindPass) + }); + + const { cipherTextBlob: encryptedBindDN } = encryptor({ + plainText: Buffer.from(ldapConfig.bindDN) }); let encryptedLdapCaCertificate: Buffer | undefined; - if (ldapCaCertificate) { + if (ldapConfig.ldapCaCertificate) { const { cipherTextBlob: encryptedCertificate } = encryptor({ - plainText: Buffer.from(ldapCaCertificate) + plainText: Buffer.from(ldapConfig.ldapCaCertificate) }); encryptedLdapCaCertificate = encryptedCertificate; } - const { cipherTextBlob: encryptedBindDN } = encryptor({ - plainText: Buffer.from(bindDN) - }); - const isConnected = await testLDAPConfig({ - bindDN, - bindPass, - caCert: ldapCaCertificate || "", - url + bindDN: ldapConfig.bindDN, + bindPass: ldapConfig.bindPass, + caCert: ldapConfig.ldapCaCertificate || "", + url: ldapConfig.url }); if (!isConnected) { @@ -282,15 +320,16 @@ export const identityLdapAuthServiceFactory = ({ identityId: identityMembershipOrg.identityId, encryptedBindDN, encryptedBindPass, - searchBase, + searchBase: ldapConfig.searchBase, searchFilter, - url, + url: ldapConfig.url, encryptedLdapCaCertificate, accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), - allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined + allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined, + templateId }, tx ); @@ -301,6 +340,7 @@ export const identityLdapAuthServiceFactory = ({ const updateLdapAuth = async ({ identityId, + templateId, url, searchBase, searchFilter, @@ -344,6 +384,13 @@ export const identityLdapAuthServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (templateId) { + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ); + } + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); if (!plan.ldap) { @@ -371,33 +418,56 @@ export const identityLdapAuthServiceFactory = ({ if (allowedFields) AllowedFieldsSchema.array().parse(allowedFields); - const { encryptor } = await kmsService.createCipherPairWithDataKey({ + const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityMembershipOrg.orgId }); + const template = templateId + ? await identityAuthTemplateDAL.findByIdAndOrgId(templateId, identityMembershipOrg.orgId) + : undefined; + let config: { + bindDN?: string; + bindPass?: string; + searchBase?: string; + url?: string; + ldapCaCertificate?: string; + }; + + if (template) { + config = JSON.parse(decryptor({ cipherTextBlob: template.templateFields }).toString()); + } else { + config = { + bindDN, + bindPass, + searchBase, + url, + ldapCaCertificate + }; + } + let encryptedBindPass: Buffer | undefined; - if (bindPass) { + if (config.bindPass) { const { cipherTextBlob: bindPassCiphertext } = encryptor({ - plainText: Buffer.from(bindPass) + plainText: Buffer.from(config.bindPass) }); encryptedBindPass = bindPassCiphertext; } let encryptedLdapCaCertificate: Buffer | undefined; - if (ldapCaCertificate) { + if (config.ldapCaCertificate) { const { cipherTextBlob: ldapCaCertificateCiphertext } = encryptor({ - plainText: Buffer.from(ldapCaCertificate) + plainText: Buffer.from(config.ldapCaCertificate) }); encryptedLdapCaCertificate = ldapCaCertificateCiphertext; } let encryptedBindDN: Buffer | undefined; - if (bindDN) { + if (config.bindDN) { const { cipherTextBlob: bindDNCiphertext } = encryptor({ - plainText: Buffer.from(bindDN) + plainText: Buffer.from(config.bindDN) }); encryptedBindDN = bindDNCiphertext; @@ -406,10 +476,10 @@ export const identityLdapAuthServiceFactory = ({ const { ldapConfig } = await getLdapConfig(identityId); const isConnected = await testLDAPConfig({ - bindDN: bindDN || ldapConfig.bindDN, - bindPass: bindPass || ldapConfig.bindPass, - caCert: ldapCaCertificate || ldapConfig.caCert, - url: url || ldapConfig.url + bindDN: config.bindDN || ldapConfig.bindDN, + bindPass: config.bindPass || ldapConfig.bindPass, + caCert: config.ldapCaCertificate || ldapConfig.caCert, + url: config.url || ldapConfig.url }); if (!isConnected) { @@ -420,14 +490,15 @@ export const identityLdapAuthServiceFactory = ({ } const updatedLdapAuth = await identityLdapAuthDAL.updateById(identityLdapAuth.id, { - url, - searchBase, + url: config.url, + searchBase: config.searchBase, searchFilter, encryptedBindDN, encryptedBindPass, encryptedLdapCaCertificate, allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined, accessTokenMaxTTL, + templateId: template?.id || null, accessTokenTTL, accessTokenNumUsesLimit, accessTokenTrustedIps: reformattedAccessTokenTrustedIps diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts index 0e6feb5fb..8629763bb 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts @@ -14,11 +14,12 @@ export type TAllowedFields = z.infer; export type TAttachLdapAuthDTO = { identityId: string; - url: string; - searchBase: string; + templateId?: string; + url?: string; + searchBase?: string; searchFilter: string; - bindDN: string; - bindPass: string; + bindDN?: string; + bindPass?: string; ldapCaCertificate?: string; allowedFields?: TAllowedFields[]; accessTokenTTL: number; @@ -30,6 +31,7 @@ export type TAttachLdapAuthDTO = { export type TUpdateLdapAuthDTO = { identityId: string; + templateId?: string; url?: string; searchBase?: string; searchFilter?: string; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 5730c0d92..b96e800a7 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -513,6 +513,21 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const deleteMembershipsById = async (ids: string[], orgId: string, tx?: Knex) => { + try { + const memberships = await (tx || db)(TableName.OrgMembership) + .where({ + orgId + }) + .whereIn("id", ids) + .delete() + .returning("*"); + return memberships; + } catch (error) { + throw new DatabaseError({ error, name: "Delete org memberships" }); + } + }; + const findMembership = async ( filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {} @@ -634,6 +649,7 @@ export const orgDALFactory = (db: TDbClient) => { createMembership, updateMembershipById, deleteMembershipById, + deleteMembershipsById, updateMembership }); }; diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts index 559ccc7db..30e67cca4 100644 --- a/backend/src/services/org/org-fns.ts +++ b/backend/src/services/org/org-fns.ts @@ -1,5 +1,6 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; +import { BadRequestError } from "@app/lib/errors"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; @@ -14,6 +15,19 @@ type TDeleteOrgMembership = { userAliasDAL: Pick; licenseService: Pick; projectUserAdditionalPrivilegeDAL: Pick; + userId?: string; +}; + +type TDeleteOrgMemberships = { + orgMembershipIds: string[]; + orgId: string; + orgDAL: Pick; + projectMembershipDAL: Pick; + projectKeyDAL: Pick; + userAliasDAL: Pick; + licenseService: Pick; + projectUserAdditionalPrivilegeDAL: Pick; + userId?: string; }; export const deleteOrgMembershipFn = async ({ @@ -24,11 +38,17 @@ export const deleteOrgMembershipFn = async ({ projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, - licenseService + licenseService, + userId }: TDeleteOrgMembership) => { const deletedMembership = await orgDAL.transaction(async (tx) => { const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx); + if (userId && orgMembership.userId === userId) { + // scott: this is temporary, we will add a leave org endpoint with proper handling to ensure org isn't abandoned/broken + throw new BadRequestError({ message: "You cannot remove yourself from an organization" }); + } + if (!orgMembership.userId) { await licenseService.updateSubscriptionOrgMemberCount(orgId); return orgMembership; @@ -86,3 +106,88 @@ export const deleteOrgMembershipFn = async ({ return deletedMembership; }; + +export const deleteOrgMembershipsFn = async ({ + orgMembershipIds, + orgId, + orgDAL, + projectMembershipDAL, + projectUserAdditionalPrivilegeDAL, + projectKeyDAL, + userAliasDAL, + licenseService, + userId +}: TDeleteOrgMemberships) => { + const deletedMemberships = await orgDAL.transaction(async (tx) => { + const orgMemberships = await orgDAL.deleteMembershipsById(orgMembershipIds, orgId, tx); + + const membershipUserIds = orgMemberships + .filter((member) => Boolean(member.userId)) + .map((member) => member.userId) as string[]; + + if (userId && membershipUserIds.includes(userId)) { + // scott: this is temporary, we will add a leave org endpoint with proper handling to ensure org isn't abandoned/broken + throw new BadRequestError({ message: "You cannot remove yourself from an organization" }); + } + + if (!membershipUserIds.length) { + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMemberships; + } + + await userAliasDAL.delete( + { + $in: { + userId: membershipUserIds + }, + orgId + }, + tx + ); + + await projectUserAdditionalPrivilegeDAL.delete( + { + $in: { + userId: membershipUserIds + } + }, + tx + ); + + // Get all the project memberships of the users in the organization + const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, membershipUserIds); + + // Delete all the project memberships of the users in the organization + await projectMembershipDAL.delete( + { + $in: { + id: projectMemberships.map((membership) => membership.id) + } + }, + tx + ); + + // Get all the project keys of the user in the organization + const projectKeys = await projectKeyDAL.find({ + $in: { + projectId: projectMemberships.map((membership) => membership.projectId), + receiverId: membershipUserIds + } + }); + + // Delete all the project keys of the user in the organization + await projectKeyDAL.delete( + { + $in: { + id: projectKeys.map((key) => key.id) + } + }, + tx + ); + + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMemberships; + }); + + return deletedMemberships; +}; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index b0b97a96f..5779fa7d3 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -72,10 +72,11 @@ import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; import { TOrgBotDALFactory } from "./org-bot-dal"; import { TOrgDALFactory } from "./org-dal"; -import { deleteOrgMembershipFn } from "./org-fns"; +import { deleteOrgMembershipFn, deleteOrgMembershipsFn } from "./org-fns"; import { TOrgRoleDALFactory } from "./org-role-dal"; import { TDeleteOrgMembershipDTO, + TDeleteOrgMembershipsDTO, TFindAllWorkspacesDTO, TFindOrgMembersByEmailDTO, TGetOrgGroupsDTO, @@ -103,7 +104,13 @@ type TOrgServiceFactoryDep = { identityMetadataDAL: Pick; projectMembershipDAL: Pick< TProjectMembershipDALFactory, - "findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction" + | "findProjectMembershipsByUserId" + | "delete" + | "create" + | "find" + | "insertMany" + | "transaction" + | "findProjectMembershipsByUserIds" >; projectKeyDAL: Pick; orgMembershipDAL: Pick< @@ -1227,12 +1234,42 @@ export const orgServiceFactory = ({ projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, - licenseService + licenseService, + userId }); return deletedMembership; }; + const bulkDeleteOrgMemberships = async ({ + orgId, + userId, + membershipIds, + actorAuthMethod, + actorOrgId + }: TDeleteOrgMembershipsDTO) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); + + if (membershipIds.includes(userId)) { + throw new BadRequestError({ message: "You cannot delete your own organization membership" }); + } + + const deletedMemberships = await deleteOrgMembershipsFn({ + orgMembershipIds: membershipIds, + orgId, + orgDAL, + projectMembershipDAL, + projectUserAdditionalPrivilegeDAL, + projectKeyDAL, + userAliasDAL, + licenseService, + userId + }); + + return deletedMemberships; + }; + const listProjectMembershipsByOrgMembershipId = async ({ orgMembershipId, orgId, @@ -1386,6 +1423,7 @@ export const orgServiceFactory = ({ findOrgBySlug, resendOrgMemberInvitation, upgradePrivilegeSystem, - notifyInvitedUsers + notifyInvitedUsers, + bulkDeleteOrgMemberships }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 0736f174d..645692145 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -25,6 +25,14 @@ export type TDeleteOrgMembershipDTO = { actorAuthMethod: ActorAuthMethod; }; +export type TDeleteOrgMembershipsDTO = { + userId: string; + orgId: string; + membershipIds: string[]; + actorOrgId: string | undefined; + actorAuthMethod: ActorAuthMethod; +}; + export type TInviteUserToOrgDTO = { inviteeEmails: string[]; organizationRoleSlug: string; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 8315ed429..7dfd1f65c 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -314,11 +314,122 @@ export const projectMembershipDALFactory = (db: TDbClient) => { } }; + const findProjectMembershipsByUserIds = async (orgId: string, userIds: string[]) => { + try { + const docs = await db + .replicaNode()(TableName.ProjectMembership) + .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .whereIn(`${TableName.Users}.id`, userIds) + .where(`${TableName.Project}.orgId`, orgId) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .join( + TableName.ProjectUserMembershipRole, + `${TableName.ProjectUserMembershipRole}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectUserMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .select( + db.ref("id").withSchema(TableName.ProjectMembership), + db.ref("isGhost").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("email").withSchema(TableName.Users), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("role").withSchema(TableName.ProjectUserMembershipRole), + db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole), + db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("id").as("projectId").withSchema(TableName.Project), + db.ref("type").as("projectType").withSchema(TableName.Project) + ) + .where({ isGhost: false }); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ + email, + firstName, + username, + lastName, + publicKey, + isGhost, + id, + projectId, + projectName, + projectType, + userId + }) => ({ + id, + userId, + projectId, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + project: { + id: projectId, + name: projectName, + type: projectType + } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; + } catch (error) { + throw new DatabaseError({ error, name: "Find project memberships by user ids" }); + } + }; + return { ...projectMemberOrm, findAllProjectMembers, findProjectGhostUser, findMembershipsByUsername, - findProjectMembershipsByUserId + findProjectMembershipsByUserId, + findProjectMembershipsByUserIds }; }; diff --git a/backend/src/services/reminder/reminder-service.ts b/backend/src/services/reminder/reminder-service.ts index a03e9cddd..3c4276b56 100644 --- a/backend/src/services/reminder/reminder-service.ts +++ b/backend/src/services/reminder/reminder-service.ts @@ -79,25 +79,33 @@ export const reminderServiceFactory = ({ repeatDays, nextReminderDate: nextReminderDateInput, recipients, - projectId + projectId, + fromDate: fromDateInput }: { secretId?: string; message?: string | null; repeatDays?: number | null; nextReminderDate?: string | null; recipients?: string[] | null; + fromDate?: string | null; projectId: string; }) => { if (!secretId) { throw new BadRequestError({ message: "secretId is required" }); } let nextReminderDate; + let fromDate; if (nextReminderDateInput) { nextReminderDate = new Date(nextReminderDateInput); } - if (repeatDays && repeatDays > 0) { - nextReminderDate = $addDays(repeatDays); + if (repeatDays) { + if (fromDateInput) { + fromDate = new Date(fromDateInput); + nextReminderDate = fromDate; + } else { + nextReminderDate = $addDays(repeatDays); + } } if (!nextReminderDate) { @@ -112,7 +120,8 @@ export const reminderServiceFactory = ({ await reminderDAL.updateById(existingReminder.id, { message, repeatDays, - nextReminderDate + nextReminderDate, + fromDate }); reminderId = existingReminder.id; } else { @@ -121,7 +130,8 @@ export const reminderServiceFactory = ({ secretId, message, repeatDays, - nextReminderDate + nextReminderDate, + fromDate }); reminderId = newReminder.id; } @@ -280,14 +290,28 @@ export const reminderServiceFactory = ({ } const processedReminders = remindersData.map( - ({ secretId, message, repeatDays, nextReminderDate: nextReminderDateInput, recipients, projectId }) => { + ({ + secretId, + message, + repeatDays, + nextReminderDate: nextReminderDateInput, + recipients, + projectId, + fromDate: fromDateInput + }) => { let nextReminderDate; + let fromDate; if (nextReminderDateInput) { nextReminderDate = new Date(nextReminderDateInput); } - if (repeatDays && repeatDays > 0 && !nextReminderDate) { - nextReminderDate = $addDays(repeatDays); + if (repeatDays && !nextReminderDate) { + if (fromDateInput) { + fromDate = new Date(fromDateInput); + nextReminderDate = fromDate; + } else { + nextReminderDate = $addDays(repeatDays); + } } if (!nextReminderDate) { @@ -302,17 +326,19 @@ export const reminderServiceFactory = ({ repeatDays, nextReminderDate, recipients: recipients ? [...new Set(recipients)] : [], - projectId + projectId, + fromDate }; } ); const newReminders = await reminderDAL.insertMany( - processedReminders.map(({ secretId, message, repeatDays, nextReminderDate }) => ({ + processedReminders.map(({ secretId, message, repeatDays, nextReminderDate, fromDate }) => ({ secretId, message, repeatDays, - nextReminderDate + nextReminderDate, + fromDate })), tx ); diff --git a/backend/src/services/reminder/reminder-types.ts b/backend/src/services/reminder/reminder-types.ts index 1f6a53ac7..54726ab66 100644 --- a/backend/src/services/reminder/reminder-types.ts +++ b/backend/src/services/reminder/reminder-types.ts @@ -8,6 +8,7 @@ export type TReminder = { message?: string | null; repeatDays?: number | null; nextReminderDate: Date; + fromDate?: Date | null; createdAt: Date; updatedAt: Date; }; @@ -21,6 +22,7 @@ export type TCreateReminderDTO = { secretId?: string; message?: string | null; repeatDays?: number | null; + fromDate?: string | null; nextReminderDate?: string | null; recipients?: string[] | null; }; @@ -31,6 +33,7 @@ export type TBatchCreateReminderDTO = { message?: string | null; repeatDays?: number | null; nextReminderDate?: string | Date | null; + fromDate?: Date | null; recipients?: string[] | null; projectId?: string; }[]; @@ -95,6 +98,7 @@ export interface TReminderServiceFactory { nextReminderDate?: string | null; recipients?: string[] | null; projectId: string; + fromDate?: string | null; }) => Promise<{ id: string; created: boolean; diff --git a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts index 7aa1c16ce..51669067a 100644 --- a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts @@ -13,7 +13,7 @@ import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { TAzureAppConfigurationSyncWithCredentials } from "./azure-app-configuration-sync-types"; type TAzureAppConfigurationSyncFactoryDeps = { - appConnectionDAL: Pick; + appConnectionDAL: Pick; kmsService: Pick; }; diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts index edc8af709..a4cf59909 100644 --- a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts @@ -12,7 +12,7 @@ import { SecretSyncError } from "../secret-sync-errors"; import { GetAzureKeyVaultSecret, TAzureKeyVaultSyncWithCredentials } from "./azure-key-vault-sync-types"; type TAzureKeyVaultSyncFactoryDeps = { - appConnectionDAL: Pick; + appConnectionDAL: Pick; kmsService: Pick; }; diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index b37d5e90e..e2cf8f6e8 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -3,6 +3,7 @@ import sodium from "libsodium-wrappers"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { getGitHubAppAuthToken, + getGitHubInstanceApiUrl, GitHubConnectionMethod, makePaginatedGitHubRequest, requestWithGitHubGateway @@ -73,7 +74,7 @@ const getPublicKey = async ( } const response = await requestWithGitHubGateway(connection, gatewayService, { - url: `https://api.${connection.credentials.host || "github.com"}${path}`, + url: `https://${await getGitHubInstanceApiUrl(connection)}${path}`, method: "GET", headers: { Accept: "application/vnd.github+json", @@ -111,7 +112,7 @@ const deleteSecret = async ( } await requestWithGitHubGateway(connection, gatewayService, { - url: `https://api.${connection.credentials.host || "github.com"}${path}`, + url: `https://${await getGitHubInstanceApiUrl(connection)}${path}`, method: "DELETE", headers: { Accept: "application/vnd.github+json", @@ -157,7 +158,7 @@ const putSecret = async ( } await requestWithGitHubGateway(connection, gatewayService, { - url: `https://api.${connection.credentials.host || "github.com"}${path}`, + url: `https://${await getGitHubInstanceApiUrl(connection)}${path}`, method: "PUT", headers: { Accept: "application/vnd.github+json", diff --git a/backend/src/services/secret-sync/netlify/index.ts b/backend/src/services/secret-sync/netlify/index.ts new file mode 100644 index 000000000..2730a7757 --- /dev/null +++ b/backend/src/services/secret-sync/netlify/index.ts @@ -0,0 +1,4 @@ +export * from "./netlify-sync-constants"; +export * from "./netlify-sync-fns"; +export * from "./netlify-sync-schemas"; +export * from "./netlify-sync-types"; diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-constants.ts b/backend/src/services/secret-sync/netlify/netlify-sync-constants.ts new file mode 100644 index 000000000..c07f4c77a --- /dev/null +++ b/backend/src/services/secret-sync/netlify/netlify-sync-constants.ts @@ -0,0 +1,19 @@ +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 NETLIFY_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Netlify", + destination: SecretSync.Netlify, + connection: AppConnection.Netlify, + canImportSecrets: true +}; + +export enum NetlifySyncContext { + All = "all", + DeployPreview = "deploy-preview", + Production = "production", + BranchDeploy = "branch-deploy", + Dev = "dev", + Branch = "branch" +} diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-fns.ts b/backend/src/services/secret-sync/netlify/netlify-sync-fns.ts new file mode 100644 index 000000000..23445623d --- /dev/null +++ b/backend/src/services/secret-sync/netlify/netlify-sync-fns.ts @@ -0,0 +1,125 @@ +/* eslint-disable no-continue */ +import { NetlifyPublicAPI } from "@app/services/app-connection/netlify/netlify-connection-public-client"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { SecretSyncError } from "../secret-sync-errors"; +import type { TNetlifySyncWithCredentials } from "./netlify-sync-types"; + +export const NetlifySyncFns = { + async getSecrets(secretSync: TNetlifySyncWithCredentials): Promise { + const config = secretSync.destinationConfig; + + const params = { + account_id: config.accountId, + context_name: config.context, // Only used in the case of getVariables + site_id: config.siteId + }; + + const variables = await NetlifyPublicAPI.getVariables(secretSync.connection, params); + + const map = {} as TSecretMap; + + for (const variable of variables) { + if (variable.is_secret) continue; + + const latest = variable.values.sort((a, b) => Number(b.created_at) - Number(a.created_at))[0]; + + map[variable.key] = { + value: latest?.value ?? "" + }; + } + + return map; + }, + + async syncSecrets(secretSync: TNetlifySyncWithCredentials, secretMap: TSecretMap) { + const { + environment, + syncOptions: { disableSecretDeletion, keySchema } + } = secretSync; + + const config = secretSync.destinationConfig; + + const params = { + account_id: config.accountId, + context_name: config.context, + site_id: config.siteId + }; + + const variables = await NetlifyPublicAPI.getVariables(secretSync.connection, params); + + const existing = Object.fromEntries(variables.map((variable) => [variable.key, variable])); + + for await (const key of Object.keys(secretMap)) { + try { + const entry = secretMap[key]; + + await NetlifyPublicAPI.upsertVariable(secretSync.connection, params, { + key, + is_secret: false, + values: [ + { + value: entry.value, + context: config.context + } + ] + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (disableSecretDeletion) return; + + for await (const key of Object.keys(existing)) { + try { + // eslint-disable-next-line no-continue, @typescript-eslint/no-unsafe-argument + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + await NetlifyPublicAPI.deleteVariable(secretSync.connection, params, { + key + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + }, + + async removeSecrets(secretSync: TNetlifySyncWithCredentials, secretMap: TSecretMap) { + const config = secretSync.destinationConfig; + + const params = { + account_id: config.accountId, + context_name: config.context, + site_id: config.siteId + }; + + const variables = await NetlifyPublicAPI.getVariables(secretSync.connection, params); + + const existingSecrets = Object.fromEntries(variables.map((variable) => [variable.key, variable])); + + for await (const secret of Object.keys(existingSecrets)) { + try { + if (secret in secretMap) { + await NetlifyPublicAPI.deleteVariable(secretSync.connection, params, { + key: secret + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: secret + }); + } + } + } +}; diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts b/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts new file mode 100644 index 000000000..cd9a985c9 --- /dev/null +++ b/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts @@ -0,0 +1,68 @@ +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"; + +import { NetlifySyncContext } from "./netlify-sync-constants"; + +const NetlifySyncDestinationConfigSchema = z.object({ + accountId: z + .string() + .min(1, "Account ID is required") + .max(255, "Account ID must be less than 255 characters") + .describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.accountId), + accountName: z + .string() + .min(1, "Account Name is required") + .max(255, "Account Name must be less than 255 characters") + .optional() + .describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.accountName), + siteId: z + .string() + .min(1, "Site ID is required") + .max(255, "Site ID must be less than 255 characters") + .optional() + .describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.siteId), + siteName: z + .string() + .min(1, "Site Name is required") + .max(255, "Site Name must be less than 255 characters") + .optional() + .describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.siteName), + context: z.nativeEnum(NetlifySyncContext).optional().describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.context) +}); + +const NetlifySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const NetlifySyncSchema = BaseSecretSyncSchema(SecretSync.Netlify, NetlifySyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Netlify), + destinationConfig: NetlifySyncDestinationConfigSchema +}); + +export const CreateNetlifySyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Netlify, + NetlifySyncOptionsConfig +).extend({ + destinationConfig: NetlifySyncDestinationConfigSchema +}); + +export const UpdateNetlifySyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Netlify, + NetlifySyncOptionsConfig +).extend({ + destinationConfig: NetlifySyncDestinationConfigSchema.optional() +}); + +export const NetlifySyncListItemSchema = z.object({ + name: z.literal("Netlify"), + connection: z.literal(AppConnection.Netlify), + destination: z.literal(SecretSync.Netlify), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-types.ts b/backend/src/services/secret-sync/netlify/netlify-sync-types.ts new file mode 100644 index 000000000..2cef04686 --- /dev/null +++ b/backend/src/services/secret-sync/netlify/netlify-sync-types.ts @@ -0,0 +1,15 @@ +import z from "zod"; + +import { TNetlifyConnection } from "@app/services/app-connection/netlify"; + +import { CreateNetlifySyncSchema, NetlifySyncListItemSchema, NetlifySyncSchema } from "./netlify-sync-schemas"; + +export type TNetlifySyncListItem = z.infer; + +export type TNetlifySync = z.infer; + +export type TNetlifySyncInput = z.infer; + +export type TNetlifySyncWithCredentials = TNetlifySync & { + connection: TNetlifyConnection; +}; diff --git a/backend/src/services/secret-sync/secret-sync-dal.ts b/backend/src/services/secret-sync/secret-sync-dal.ts index 617393668..e50593f10 100644 --- a/backend/src/services/secret-sync/secret-sync-dal.ts +++ b/backend/src/services/secret-sync/secret-sync-dal.ts @@ -30,6 +30,7 @@ const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: Secre db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"), db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("connectionGatewayId"), db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), db @@ -65,6 +66,7 @@ const expandSecretSync = ( connectionUpdatedAt, connectionVersion, connectionIsPlatformManagedCredentials, + connectionGatewayId, ...el } = secretSync; @@ -83,7 +85,8 @@ const expandSecretSync = ( createdAt: connectionCreatedAt, updatedAt: connectionUpdatedAt, version: connectionVersion, - isPlatformManagedCredentials: connectionIsPlatformManagedCredentials + isPlatformManagedCredentials: connectionIsPlatformManagedCredentials, + gatewayId: connectionGatewayId }, folder: folder ? { diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 55859200a..fe0dc9f56 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -27,6 +27,7 @@ export enum SecretSync { Railway = "railway", Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", + Netlify = "netlify", Bitbucket = "bitbucket" } diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 6fa46f1e6..3ff7cefbc 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -48,6 +48,7 @@ import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify"; import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants"; import { RailwaySyncFns } from "./railway/railway-sync-fns"; import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render"; @@ -88,6 +89,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Railway]: RAILWAY_SYNC_LIST_OPTION, [SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION, [SecretSync.DigitalOceanAppPlatform]: DIGITAL_OCEAN_APP_PLATFORM_SYNC_LIST_OPTION, + [SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION, [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION }; @@ -269,6 +271,8 @@ export const SecretSyncFns = { return SupabaseSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.DigitalOceanAppPlatform: return DigitalOceanAppPlatformSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Netlify: + return NetlifySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap); default: @@ -381,6 +385,9 @@ export const SecretSyncFns = { case SecretSync.DigitalOceanAppPlatform: secretMap = await DigitalOceanAppPlatformSyncFns.getSecrets(secretSync); break; + case SecretSync.Netlify: + secretMap = await NetlifySyncFns.getSecrets(secretSync); + break; case SecretSync.Bitbucket: secretMap = await BitbucketSyncFns.getSecrets(secretSync); break; @@ -473,6 +480,8 @@ export const SecretSyncFns = { return SupabaseSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.DigitalOceanAppPlatform: return DigitalOceanAppPlatformSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Netlify: + return NetlifySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap); default: diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 2f2b3c427..04e91051d 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -30,6 +30,7 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Railway]: "Railway", [SecretSync.Checkly]: "Checkly", [SecretSync.DigitalOceanAppPlatform]: "Digital Ocean App Platform", + [SecretSync.Netlify]: "Netlify", [SecretSync.Bitbucket]: "Bitbucket" }; @@ -62,6 +63,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Railway]: AppConnection.Railway, [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, + [SecretSync.Netlify]: AppConnection.Netlify, [SecretSync.Bitbucket]: AppConnection.Bitbucket }; @@ -94,5 +96,6 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Railway]: SecretSyncPlanType.Regular, [SecretSync.Checkly]: SecretSyncPlanType.Regular, [SecretSync.DigitalOceanAppPlatform]: SecretSyncPlanType.Regular, + [SecretSync.Netlify]: SecretSyncPlanType.Regular, [SecretSync.Bitbucket]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index a844fbe48..6435e19d3 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -117,6 +117,7 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { TNetlifySync, TNetlifySyncInput, TNetlifySyncListItem, TNetlifySyncWithCredentials } from "./netlify"; import { TRailwaySync, TRailwaySyncInput, @@ -178,6 +179,7 @@ export type TSecretSync = | TRailwaySync | TChecklySync | TSupabaseSync + | TNetlifySync | TBitbucketSync; export type TSecretSyncWithCredentials = @@ -209,6 +211,7 @@ export type TSecretSyncWithCredentials = | TChecklySyncWithCredentials | TSupabaseSyncWithCredentials | TDigitalOceanAppPlatformSyncWithCredentials + | TNetlifySyncWithCredentials | TBitbucketSyncWithCredentials; export type TSecretSyncInput = @@ -240,6 +243,7 @@ export type TSecretSyncInput = | TChecklySyncInput | TSupabaseSyncInput | TDigitalOceanAppPlatformSyncInput + | TNetlifySyncInput | TBitbucketSyncInput; export type TSecretSyncListItem = @@ -271,6 +275,7 @@ export type TSecretSyncListItem = | TChecklySyncListItem | TSupabaseSyncListItem | TDigitalOceanAppPlatformSyncListItem + | TNetlifySyncListItem | TBitbucketSyncListItem; export type TSyncOptionsConfig = { diff --git a/backend/src/services/smtp/emails/AccessApprovalRequestTemplate.tsx b/backend/src/services/smtp/emails/AccessApprovalRequestTemplate.tsx index fef072546..bdefc8b86 100644 --- a/backend/src/services/smtp/emails/AccessApprovalRequestTemplate.tsx +++ b/backend/src/services/smtp/emails/AccessApprovalRequestTemplate.tsx @@ -1,7 +1,9 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface AccessApprovalRequestTemplateProps extends Omit { projectName: string; @@ -38,18 +40,15 @@ export const AccessApprovalRequestTemplate = ({ You have a new access approval request pending review for the project {projectName} -

+
- {requesterFullName} ( - - {requesterEmail} - - ) has requested {isTemporary ? "temporary" : "permanent"} access to {secretPath} in the{" "} + {requesterFullName} ({requesterEmail}) + has requested {isTemporary ? "temporary" : "permanent"} access to {secretPath} in the{" "} {environment} environment. {isTemporary && ( - + This access will expire {expiresIn} after approval. )} @@ -67,13 +66,8 @@ export const AccessApprovalRequestTemplate = ({ )}
-
- +
+ Review Request
); diff --git a/backend/src/services/smtp/emails/BaseButton.tsx b/backend/src/services/smtp/emails/BaseButton.tsx new file mode 100644 index 000000000..7d79ea581 --- /dev/null +++ b/backend/src/services/smtp/emails/BaseButton.tsx @@ -0,0 +1,18 @@ +import { Button } from "@react-email/components"; +import React from "react"; + +type Props = { + href: string; + children: string; +}; + +export const BaseButton = ({ href, children }: Props) => { + return ( + + ); +}; diff --git a/backend/src/services/smtp/emails/BaseEmailWrapper.tsx b/backend/src/services/smtp/emails/BaseEmailWrapper.tsx index 01bf779c5..3e908db18 100644 --- a/backend/src/services/smtp/emails/BaseEmailWrapper.tsx +++ b/backend/src/services/smtp/emails/BaseEmailWrapper.tsx @@ -16,23 +16,21 @@ export const BaseEmailWrapper = ({ title, preview, children, siteUrl }: BaseEmai {preview} -
-
-
- Infisical Logo -
+
+ Infisical Logo
+
{children}

Email sent via{" "} - + Infisical diff --git a/backend/src/services/smtp/emails/BaseLink.tsx b/backend/src/services/smtp/emails/BaseLink.tsx new file mode 100644 index 000000000..f2a8763e6 --- /dev/null +++ b/backend/src/services/smtp/emails/BaseLink.tsx @@ -0,0 +1,15 @@ +import { Link } from "@react-email/components"; +import React from "react"; + +type Props = { + href: string; + children: string; +}; + +export const BaseLink = ({ href, children }: Props) => { + return ( + + {children} + + ); +}; diff --git a/backend/src/services/smtp/emails/EmailMfaTemplate.tsx b/backend/src/services/smtp/emails/EmailMfaTemplate.tsx index b01e2f8af..a5d1fdf03 100644 --- a/backend/src/services/smtp/emails/EmailMfaTemplate.tsx +++ b/backend/src/services/smtp/emails/EmailMfaTemplate.tsx @@ -1,7 +1,8 @@ -import { Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface EmailMfaTemplateProps extends Omit { code: string; @@ -25,11 +26,7 @@ export const EmailMfaTemplate = ({ code, siteUrl, isCloud }: EmailMfaTemplatePro Not you?{" "} {isCloud ? ( <> - Contact us at{" "} - - support@infisical.com - {" "} - immediately + Contact us at support@infisical.com immediately ) : ( "Contact your administrator immediately" diff --git a/backend/src/services/smtp/emails/EmailVerificationTemplate.tsx b/backend/src/services/smtp/emails/EmailVerificationTemplate.tsx index fc32b01b0..313601e6a 100644 --- a/backend/src/services/smtp/emails/EmailVerificationTemplate.tsx +++ b/backend/src/services/smtp/emails/EmailVerificationTemplate.tsx @@ -1,7 +1,8 @@ -import { Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface EmailVerificationTemplateProps extends Omit { code: string; @@ -29,10 +30,7 @@ export const EmailVerificationTemplate = ({ code, siteUrl, isCloud }: EmailVerif Questions about Infisical?{" "} {isCloud ? ( <> - Email us at{" "} - - support@infisical.com - + Email us at support@infisical.com ) : ( "Contact your administrator" diff --git a/backend/src/services/smtp/emails/ExternalImportFailedTemplate.tsx b/backend/src/services/smtp/emails/ExternalImportFailedTemplate.tsx index 3cca41721..28bccc9d5 100644 --- a/backend/src/services/smtp/emails/ExternalImportFailedTemplate.tsx +++ b/backend/src/services/smtp/emails/ExternalImportFailedTemplate.tsx @@ -1,7 +1,8 @@ -import { Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface ExternalImportFailedTemplateProps extends Omit { error: string; @@ -21,12 +22,9 @@ export const ExternalImportFailedTemplate = ({ error, siteUrl, provider }: Exter If your issue persists, you can contact the Infisical team at{" "} - - support@infisical.com - - . + support@infisical.com. - + Error: "{error}"
diff --git a/backend/src/services/smtp/emails/IntegrationSyncFailedTemplate.tsx b/backend/src/services/smtp/emails/IntegrationSyncFailedTemplate.tsx index c2fb78ae0..3263d4928 100644 --- a/backend/src/services/smtp/emails/IntegrationSyncFailedTemplate.tsx +++ b/backend/src/services/smtp/emails/IntegrationSyncFailedTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface IntegrationSyncFailedTemplateProps extends Omit { @@ -30,7 +31,7 @@ export const IntegrationSyncFailedTemplate = ({ {count} integration(s) failed to sync -
+
Project {projectName} Environment @@ -38,15 +39,10 @@ export const IntegrationSyncFailedTemplate = ({ Secret Path {secretPath} Failure Reason: - "{syncMessage}" + "{syncMessage}"
-
- +
+ View Integrations
); diff --git a/backend/src/services/smtp/emails/NewDeviceLoginTemplate.tsx b/backend/src/services/smtp/emails/NewDeviceLoginTemplate.tsx index 9692bc77d..da1049096 100644 --- a/backend/src/services/smtp/emails/NewDeviceLoginTemplate.tsx +++ b/backend/src/services/smtp/emails/NewDeviceLoginTemplate.tsx @@ -1,7 +1,8 @@ -import { Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface NewDeviceLoginTemplateProps extends Omit { email: string; @@ -42,9 +43,7 @@ export const NewDeviceLoginTemplate = ({ If you believe that this login is suspicious, please contact{" "} {isCloud ? ( - - support@infisical.com - + support@infisical.com ) : ( "your administrator" )}{" "} diff --git a/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx b/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx index 1d2ecc1a3..ee09574b6 100644 --- a/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx +++ b/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx @@ -1,7 +1,8 @@ -import { Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface OrgAdminBreakglassAccessTemplateProps extends Omit { email: string; @@ -35,10 +36,7 @@ export const OrgAdminBreakglassAccessTemplate = ({ {userAgent} If you'd like to disable Admin SSO Bypass, please visit{" "} - - Organization Security Settings - - . + Organization Security Settings.
diff --git a/backend/src/services/smtp/emails/OrgAdminProjectGrantAccessTemplate.tsx b/backend/src/services/smtp/emails/OrgAdminProjectGrantAccessTemplate.tsx index bec22575c..1cf875d1c 100644 --- a/backend/src/services/smtp/emails/OrgAdminProjectGrantAccessTemplate.tsx +++ b/backend/src/services/smtp/emails/OrgAdminProjectGrantAccessTemplate.tsx @@ -2,6 +2,7 @@ import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface OrgAdminProjectGrantAccessTemplateProps extends Omit { email: string; @@ -24,8 +25,8 @@ export const OrgAdminProjectGrantAccessTemplate = ({
- The organization admin {email} has self-issued direct access to the project{" "} - {projectName}. + The organization admin {email} has self-issued direct access to + the project {projectName}.
diff --git a/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx b/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx index 27092a843..9c57548ce 100644 --- a/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx +++ b/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx @@ -1,7 +1,9 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface OrganizationInvitationTemplateProps extends Omit { metadata?: string; @@ -36,15 +38,13 @@ export const OrganizationInvitationTemplate = ({
{organizationName} on Infisical -
+
{inviterFirstName && inviterUsername ? ( <> {inviterFirstName} ( - - {inviterUsername} - - ) has invited you to collaborate on {organizationName}. + {inviterUsername}) has invited you to collaborate + on {organizationName}. ) : ( <> @@ -53,13 +53,12 @@ export const OrganizationInvitationTemplate = ({ )}
-
- +
diff --git a/backend/src/services/smtp/emails/PasswordResetTemplate.tsx b/backend/src/services/smtp/emails/PasswordResetTemplate.tsx index 7486b29d9..2cfa00508 100644 --- a/backend/src/services/smtp/emails/PasswordResetTemplate.tsx +++ b/backend/src/services/smtp/emails/PasswordResetTemplate.tsx @@ -1,7 +1,9 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface PasswordResetTemplateProps extends Omit { email: string; @@ -20,16 +22,13 @@ export const PasswordResetTemplate = ({ email, isCloud, siteUrl, callback_url, t Account Recovery -
+
A password reset was requested for your Infisical account. If you did not initiate this request, please contact{" "} {isCloud ? ( <> - us immediately at{" "} - - support@infisical.com - + us immediately at support@infisical.com ) : ( "your administrator immediately" @@ -37,13 +36,8 @@ export const PasswordResetTemplate = ({ email, isCloud, siteUrl, callback_url, t .
-
- +
+ Reset Password
); diff --git a/backend/src/services/smtp/emails/PasswordSetupTemplate.tsx b/backend/src/services/smtp/emails/PasswordSetupTemplate.tsx index c8a986c8c..78164bed2 100644 --- a/backend/src/services/smtp/emails/PasswordSetupTemplate.tsx +++ b/backend/src/services/smtp/emails/PasswordSetupTemplate.tsx @@ -1,6 +1,8 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import { Button, Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseLink } from "@app/services/smtp/emails/BaseLink"; + import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface PasswordSetupTemplateProps extends Omit { @@ -16,19 +18,16 @@ export const PasswordSetupTemplate = ({ email, isCloud, siteUrl, callback_url, t Password Setup -
+
Someone requested to set up a password for your Infisical account. - + Make sure you are already logged in to Infisical in the current browser before clicking the link below. If you did not initiate this request, please contact{" "} {isCloud ? ( <> - us immediately at{" "} - - support@infisical.com - + us immediately at support@infisical.com ) : ( "your administrator immediately" @@ -36,7 +35,7 @@ export const PasswordSetupTemplate = ({ email, isCloud, siteUrl, callback_url, t .
-
+
+
+ Grant Access
); diff --git a/backend/src/services/smtp/emails/ProjectInvitationTemplate.tsx b/backend/src/services/smtp/emails/ProjectInvitationTemplate.tsx index 1745dc8e5..2105e967a 100644 --- a/backend/src/services/smtp/emails/ProjectInvitationTemplate.tsx +++ b/backend/src/services/smtp/emails/ProjectInvitationTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface ProjectInvitationTemplateProps extends Omit { @@ -18,18 +19,13 @@ export const ProjectInvitationTemplate = ({ callback_url, workspaceName, siteUrl You've been invited to join a project on Infisical -
+
You've been invited to join the project {workspaceName}.
-
- +
+ Join Project
diff --git a/backend/src/services/smtp/emails/ScimUserProvisionedTemplate.tsx b/backend/src/services/smtp/emails/ScimUserProvisionedTemplate.tsx index bbd41818e..df60afba3 100644 --- a/backend/src/services/smtp/emails/ScimUserProvisionedTemplate.tsx +++ b/backend/src/services/smtp/emails/ScimUserProvisionedTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface ScimUserProvisionedTemplateProps extends Omit { @@ -24,18 +25,13 @@ export const ScimUserProvisionedTemplate = ({
{organizationName} on Infisical -
+
You've been invited to collaborate on {organizationName}.
-
- +
+ Accept Invite
diff --git a/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx b/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx index a07110aa3..ebbbeda15 100644 --- a/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx @@ -1,7 +1,9 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface SecretApprovalRequestBypassedTemplateProps extends Omit { @@ -35,13 +37,10 @@ export const SecretApprovalRequestBypassedTemplate = ({ A secret approval request has been bypassed in the project {projectName} -
+
- {requesterFullName} ( - - {requesterEmail} - - ) has {requestType === "change" ? "merged" : "accessed"} a secret {requestType === "change" ? "to" : "in"}{" "} + {requesterFullName} ({requesterEmail}) + has {requestType === "change" ? "merged" : "accessed"} a secret {requestType === "change" ? "to" : "in"}{" "} {secretPath} in the {environment} environment without obtaining the required approval. @@ -50,13 +49,8 @@ export const SecretApprovalRequestBypassedTemplate = ({ {bypassReason}"
-
- +
+ Review Bypass
); diff --git a/backend/src/services/smtp/emails/SecretApprovalRequestNeedsReviewTemplate.tsx b/backend/src/services/smtp/emails/SecretApprovalRequestNeedsReviewTemplate.tsx index b4a7c306a..000614e00 100644 --- a/backend/src/services/smtp/emails/SecretApprovalRequestNeedsReviewTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretApprovalRequestNeedsReviewTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface SecretApprovalRequestNeedsReviewTemplateProps @@ -27,20 +28,15 @@ export const SecretApprovalRequestNeedsReviewTemplate = ({ A secret approval request for the project {projectName} requires review -
+
Hello {firstName}, You have a new secret change request pending your review for the project {projectName} in the organization {organizationName}.
-
- +
+ Review Changes
); diff --git a/backend/src/services/smtp/emails/SecretLeakIncidentTemplate.tsx b/backend/src/services/smtp/emails/SecretLeakIncidentTemplate.tsx index 631013756..fa60deb11 100644 --- a/backend/src/services/smtp/emails/SecretLeakIncidentTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretLeakIncidentTemplate.tsx @@ -1,7 +1,9 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface SecretLeakIncidentTemplateProps extends Omit { numberOfSecrets: number; @@ -24,7 +26,7 @@ export const SecretLeakIncidentTemplate = ({ Infisical has uncovered {numberOfSecrets} secret(s) from a recent commit -
+
You are receiving this notification because one or more leaked secrets have been detected in a recent commit {(pusher_email || pusher_name) && ( @@ -33,11 +35,7 @@ export const SecretLeakIncidentTemplate = ({ pushed by {pusher_name ?? "Unknown Pusher"}{" "} {pusher_email && ( <> - ( - - {pusher_email} - - ) + ({pusher_email}) )} @@ -49,24 +47,16 @@ export const SecretLeakIncidentTemplate = ({ a comment in the given programming language. This will prevent future notifications from being sent out for these secrets. - + If these are production secrets, please rotate them immediately. Once you have taken action, be sure to update the status of the risk in the{" "} - - Infisical Dashboard - - . + Infisical Dashboard.
-
- +
+ View Leaked Secrets
); diff --git a/backend/src/services/smtp/emails/SecretRequestCompletedTemplate.tsx b/backend/src/services/smtp/emails/SecretRequestCompletedTemplate.tsx index 4adeec636..37e758661 100644 --- a/backend/src/services/smtp/emails/SecretRequestCompletedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretRequestCompletedTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface SecretRequestCompletedTemplateProps extends Omit { @@ -20,7 +21,7 @@ export const SecretRequestCompletedTemplate = ({ A secret has been shared with you -
+
{respondentUsername ? {respondentUsername} : "Someone"} shared a secret{" "} {name && ( @@ -31,13 +32,8 @@ export const SecretRequestCompletedTemplate = ({ with you.
-
- +
+ View Secret
); diff --git a/backend/src/services/smtp/emails/SecretRotationFailedTemplate.tsx b/backend/src/services/smtp/emails/SecretRotationFailedTemplate.tsx index 52e58986d..04cc95c56 100644 --- a/backend/src/services/smtp/emails/SecretRotationFailedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretRotationFailedTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface SecretRotationFailedTemplateProps extends Omit { @@ -28,7 +29,7 @@ export const SecretRotationFailedTemplate = ({ Your {rotationType} rotation {rotationName} failed to rotate -
+
Name {rotationName} Type @@ -40,15 +41,12 @@ export const SecretRotationFailedTemplate = ({ Secret Path {secretPath} Reason: - {content} + {content}
-
- +
); diff --git a/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx b/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx index 2e212cb82..5b56b574a 100644 --- a/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface SecretScanningScanFailedTemplateProps extends Omit { @@ -30,7 +31,7 @@ export const SecretScanningScanFailedTemplate = ({ Infisical encountered an error while attempting to scan the resource {resourceName} -
+
Resource {resourceName} Data Source @@ -40,15 +41,10 @@ export const SecretScanningScanFailedTemplate = ({ Timestamp {timestamp} Error - {errorMessage} + {errorMessage}
-
- +
+ View in Infisical
); diff --git a/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx b/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx index b7c0d8a14..6bd4e8aa7 100644 --- a/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx @@ -1,7 +1,9 @@ -import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface SecretScanningSecretsDetectedTemplateProps extends Omit { @@ -32,7 +34,7 @@ export const SecretScanningSecretsDetectedTemplate = ({ Infisical has uncovered {numberOfSecrets} secret(s) {isDiffScan ? " from a recent commit to" : " in"} {resourceName} -
+
You are receiving this notification because one or more leaked secrets have been detected {isDiffScan && " in a recent commit"} @@ -43,11 +45,7 @@ export const SecretScanningSecretsDetectedTemplate = ({ pushed by {authorName ?? "Unknown Pusher"}{" "} {authorEmail && ( <> - ( - - {authorEmail} - - ) + ({authorEmail}) )} @@ -65,24 +63,16 @@ export const SecretScanningSecretsDetectedTemplate = ({ a comment in the given programming language. This will prevent future notifications from being sent out for these secrets. - + If these are production secrets, please rotate them immediately. Once you have taken action, be sure to update the finding status in the{" "} - - Infisical Dashboard - - . + Infisical Dashboard.
-
- +
+ View Leaked Secrets
); diff --git a/backend/src/services/smtp/emails/SecretSyncFailedTemplate.tsx b/backend/src/services/smtp/emails/SecretSyncFailedTemplate.tsx index 01d909219..4abbf8f99 100644 --- a/backend/src/services/smtp/emails/SecretSyncFailedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretSyncFailedTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface SecretSyncFailedTemplateProps extends Omit { @@ -28,7 +29,7 @@ export const SecretSyncFailedTemplate = ({ Your {syncDestination} sync {syncName} failed to complete -
+
Name {syncName} Destination @@ -50,17 +51,12 @@ export const SecretSyncFailedTemplate = ({ {failureMessage && ( <> Reason: - {failureMessage} + {failureMessage} )}
-
- +
+ View in Infisical
); diff --git a/backend/src/services/smtp/emails/ServiceTokenExpiryNoticeTemplate.tsx b/backend/src/services/smtp/emails/ServiceTokenExpiryNoticeTemplate.tsx index 8f8deb63a..a4c171f86 100644 --- a/backend/src/services/smtp/emails/ServiceTokenExpiryNoticeTemplate.tsx +++ b/backend/src/services/smtp/emails/ServiceTokenExpiryNoticeTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface ServiceTokenExpiryNoticeTemplateProps extends Omit { @@ -24,20 +25,15 @@ export const ServiceTokenExpiryNoticeTemplate = ({ Service token expiry notice -
+
Your service token {tokenName} for the project {projectName} will expire within 24 hours. If this token is still needed for your workflow, please create a new one before it expires.
-
- +
+ Create New Token
); diff --git a/backend/src/services/smtp/emails/SignupEmailVerificationTemplate.tsx b/backend/src/services/smtp/emails/SignupEmailVerificationTemplate.tsx index 0a3da75f9..4c0ab133f 100644 --- a/backend/src/services/smtp/emails/SignupEmailVerificationTemplate.tsx +++ b/backend/src/services/smtp/emails/SignupEmailVerificationTemplate.tsx @@ -1,7 +1,8 @@ -import { Heading, Link, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; interface SignupEmailVerificationTemplateProps extends Omit { code: string; @@ -29,10 +30,7 @@ export const SignupEmailVerificationTemplate = ({ code, siteUrl, isCloud }: Sign Questions about setting up Infisical?{" "} {isCloud ? ( <> - Email us at{" "} - - support@infisical.com - + Email us at support@infisical.com ) : ( "Contact your administrator" diff --git a/backend/src/services/smtp/emails/UnlockAccountTemplate.tsx b/backend/src/services/smtp/emails/UnlockAccountTemplate.tsx index b0b4f2086..7ec1be3f4 100644 --- a/backend/src/services/smtp/emails/UnlockAccountTemplate.tsx +++ b/backend/src/services/smtp/emails/UnlockAccountTemplate.tsx @@ -1,6 +1,7 @@ -import { Button, Heading, Section, Text } from "@react-email/components"; +import { Heading, Section, Text } from "@react-email/components"; import React from "react"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface UnlockAccountTemplateProps extends Omit { @@ -18,19 +19,14 @@ export const UnlockAccountTemplate = ({ token, siteUrl, callback_url }: UnlockAc Unlock your Infisical account -
+
Your account has been temporarily locked due to multiple failed login attempts. If these attempts were not made by you, reset your password immediately.
-
- +
+ Unlock Account
); diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index ff47718f1..93ac5cadb 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -704,10 +704,39 @@ export const superAdminServiceFactory = ({ }; const deleteUser = async (userId: string) => { + const superAdmins = await userDAL.find({ + superAdmin: true + }); + + if (superAdmins.length === 1 && superAdmins[0].id === userId) { + throw new BadRequestError({ + message: "Cannot delete the only server admin on this instance. Add another server admin to delete this user." + }); + } + const user = await userDAL.deleteById(userId); return user; }; + const deleteUsers = async (userIds: string[]) => { + const superAdmins = await userDAL.find({ + superAdmin: true + }); + + if (superAdmins.every((superAdmin) => userIds.includes(superAdmin.id))) { + throw new BadRequestError({ + message: "Instance must have at least one server admin. Add another server admin to delete these users." + }); + } + + const users = await userDAL.delete({ + $in: { + id: userIds + } + }); + return users; + }; + const deleteIdentitySuperAdminAccess = async (identityId: string, actorId: string) => { const identity = await identityDAL.findById(identityId); if (!identity) { @@ -730,6 +759,17 @@ export const superAdminServiceFactory = ({ throw new NotFoundError({ name: "User", message: "User not found" }); } + const superAdmins = await userDAL.find({ + superAdmin: true + }); + + if (superAdmins.length === 1 && superAdmins[0].id === userId) { + throw new BadRequestError({ + message: + "Cannot remove the only server admin on this instance. Add another server admin to remove status for this user." + }); + } + const updatedUser = userDAL.updateById(userId, { superAdmin: false }); return updatedUser; @@ -913,6 +953,7 @@ export const superAdminServiceFactory = ({ initializeAdminIntegrationConfigSync, initializeEnvConfigSync, getEnvOverrides, - getEnvOverridesOrganized + getEnvOverridesOrganized, + deleteUsers }; }; diff --git a/company/favicon.ico b/company/favicon.ico new file mode 100644 index 000000000..a76dd0339 Binary files /dev/null and b/company/favicon.ico differ diff --git a/company/favicon.png b/company/favicon.png deleted file mode 100644 index 45c9b868e..000000000 Binary files a/company/favicon.png and /dev/null differ diff --git a/company/mint.json b/company/mint.json index 247fb5a2b..8d2b9e346 100644 --- a/company/mint.json +++ b/company/mint.json @@ -5,7 +5,7 @@ "light": "/logo/light.svg", "href": "https://infisical.com" }, - "favicon": "/favicon.png", + "favicon": "/favicon.ico", "colors": { "primary": "#26272b", "light": "#97b31d", diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 00dc19a46..2de271180 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -197,4 +197,4 @@ volumes: driver: local ldap_data: ldap_config: - grafana_storage: + grafana_storage: \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/netlify/available.mdx b/docs/api-reference/endpoints/app-connections/netlify/available.mdx new file mode 100644 index 000000000..19ca363cc --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/netlify/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/create.mdx b/docs/api-reference/endpoints/app-connections/netlify/create.mdx new file mode 100644 index 000000000..8c99c14e7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/netlify" +--- + + + Check out the configuration docs for [Netlify Connections](/integrations/app-connections/netlify) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/netlify/delete.mdx b/docs/api-reference/endpoints/app-connections/netlify/delete.mdx new file mode 100644 index 000000000..8ab1741a8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/netlify/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/netlify/get-by-id.mdx new file mode 100644 index 000000000..cfc49a128 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/netlify/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/netlify/get-by-name.mdx new file mode 100644 index 000000000..5869b5d23 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/netlify/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/list.mdx b/docs/api-reference/endpoints/app-connections/netlify/list.mdx new file mode 100644 index 000000000..50de77482 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/netlify" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/update.mdx b/docs/api-reference/endpoints/app-connections/netlify/update.mdx new file mode 100644 index 000000000..f3a981095 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/netlify/{connectionId}" +--- + + + Check out the configuration docs for [Netlify Connections](/integrations/app-connections/netlify) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/organizations/bulk-delete-memberships.mdx b/docs/api-reference/endpoints/organizations/bulk-delete-memberships.mdx new file mode 100644 index 000000000..70793572c --- /dev/null +++ b/docs/api-reference/endpoints/organizations/bulk-delete-memberships.mdx @@ -0,0 +1,4 @@ +--- +title: "Bulk Delete User Memberships" +openapi: "DELETE /api/v2/organizations/{organizationId}/memberships" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/create.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/create.mdx new file mode 100644 index 000000000..1ed9f8cea --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-scanning/data-sources/gitlab" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/delete.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/delete.mdx new file mode 100644 index 000000000..5c26f478d --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id.mdx new file mode 100644 index 000000000..48e9b9fb4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name.mdx new file mode 100644 index 000000000..42bca6419 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab/data-source-name/{dataSourceName}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources.mdx new file mode 100644 index 000000000..299e2f0a6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources.mdx @@ -0,0 +1,4 @@ +--- +title: "List Resources" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}/resources" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans.mdx new file mode 100644 index 000000000..952fa1e89 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans.mdx @@ -0,0 +1,4 @@ +--- +title: "List Scans" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}/scans" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list.mdx new file mode 100644 index 000000000..2d6ffbed3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/data-sources/gitlab" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource.mdx new file mode 100644 index 000000000..da4623bae --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan Resource" +openapi: "POST /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}/resources/{resourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan.mdx new file mode 100644 index 000000000..5ccad9c16 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/scan.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan" +openapi: "POST /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/update.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/update.mdx new file mode 100644 index 000000000..e48ee40d1 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/gitlab/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/data-sources/gitlab/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/create.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/create.mdx new file mode 100644 index 000000000..6fd4a4391 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/netlify" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/delete.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/delete.mdx new file mode 100644 index 000000000..051d6825c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/netlify/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/get-by-id.mdx new file mode 100644 index 000000000..d207e29a6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/netlify/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/get-by-name.mdx new file mode 100644 index 000000000..caee424b2 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/netlify/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/import-secrets.mdx new file mode 100644 index 000000000..41fdadf4f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/netlify/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/list.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/list.mdx new file mode 100644 index 000000000..e366d82e9 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/netlify" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/remove-secrets.mdx new file mode 100644 index 000000000..5d402e061 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/netlify/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/sync-secrets.mdx new file mode 100644 index 000000000..437bc8524 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/netlify/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/update.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/update.mdx new file mode 100644 index 000000000..e51ba3270 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/netlify/{syncId}" +--- diff --git a/docs/docs.json b/docs/docs.json index 82988328c..6b406a517 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -10,97 +10,53 @@ "styling": { "codeblocks": "dark" }, - "favicon": "/favicon.png", + "favicon": "/favicon.ico", "navigation": { "tabs": [ { - "tab": "Documentation", + "tab": "Platform", "groups": [ { "group": "Getting Started", "pages": [ + "documentation/getting-started/overview", "documentation/getting-started/introduction", { - "group": "Quickstart", - "pages": ["documentation/guides/local-development"] - }, - { - "group": "Guides", + "group": "Concepts", "pages": [ - "documentation/guides/introduction", - "documentation/guides/node", - "documentation/guides/python", - "documentation/guides/nextjs-vercel", - "documentation/guides/microsoft-power-apps", - "documentation/guides/organization-structure" + "documentation/getting-started/concepts/deployment-models", + "documentation/getting-started/concepts/platform-hierarchy", + "documentation/getting-started/concepts/platform-iam", + "documentation/getting-started/concepts/client-integrations", + "documentation/getting-started/concepts/audit-logs" ] }, { - "group": "Setup", - "pages": ["documentation/setup/networking"] + "group": "Guides", + "pages": ["documentation/guides/organization-structure"] } ] }, { - "group": "Platform", + "group": "Platform Reference", "pages": [ "documentation/platform/organization", - "documentation/platform/project", - "documentation/platform/folder", { - "group": "Secrets", + "group": "Projects", "pages": [ - "documentation/platform/secret-versioning", - "documentation/platform/pit-recovery", - "documentation/platform/secret-reference", - "documentation/platform/webhooks" - ] - }, - { - "group": "Internal PKI", - "pages": [ - "documentation/platform/pki/overview", - "documentation/platform/pki/private-ca", - "documentation/platform/pki/external-ca", - "documentation/platform/pki/subscribers", - "documentation/platform/pki/certificates", - "documentation/platform/pki/acme-ca", - "documentation/platform/pki/est", - "documentation/platform/pki/alerting", + "documentation/platform/project", + "documentation/platform/project-templates", { - "group": "Integrations", + "group": "KMS Configuration", "pages": [ - "documentation/platform/pki/pki-issuer", - "documentation/platform/pki/integration-guides/gloo-mesh" + "documentation/platform/kms-configuration/overview", + "documentation/platform/kms-configuration/aws-kms", + "documentation/platform/kms-configuration/aws-hsm", + "documentation/platform/kms-configuration/gcp-kms" ] } ] }, - { - "group": "Infisical SSH", - "pages": [ - "documentation/platform/ssh/overview", - "documentation/platform/ssh/host-groups" - ] - }, - { - "group": "Key Management (KMS)", - "pages": [ - "documentation/platform/kms/overview", - "documentation/platform/kms/hsm-integration", - "documentation/platform/kms/kubernetes-encryption", - "documentation/platform/kms/kmip" - ] - }, - { - "group": "KMS Configuration", - "pages": [ - "documentation/platform/kms-configuration/overview", - "documentation/platform/kms-configuration/aws-kms", - "documentation/platform/kms-configuration/aws-hsm", - "documentation/platform/kms-configuration/gcp-kms" - ] - }, { "group": "Identities", "pages": [ @@ -140,57 +96,53 @@ ] }, { - "group": "Secret Rotation", + "group": "App Connections", "pages": [ - "documentation/platform/secret-rotation/overview", - "documentation/platform/secret-rotation/auth0-client-secret", - "documentation/platform/secret-rotation/aws-iam-user-secret", - "documentation/platform/secret-rotation/azure-client-secret", - "documentation/platform/secret-rotation/ldap-password", - "documentation/platform/secret-rotation/mssql-credentials", - "documentation/platform/secret-rotation/mysql-credentials", - "documentation/platform/secret-rotation/okta-client-secret", - "documentation/platform/secret-rotation/oracledb-credentials", - "documentation/platform/secret-rotation/postgres-credentials" + "integrations/app-connections/overview", + { + "group": "Connections", + "pages": [ + "integrations/app-connections/1password", + "integrations/app-connections/auth0", + "integrations/app-connections/aws", + "integrations/app-connections/azure-app-configuration", + "integrations/app-connections/azure-client-secrets", + "integrations/app-connections/azure-devops", + "integrations/app-connections/azure-key-vault", + "integrations/app-connections/bitbucket", + "integrations/app-connections/camunda", + "integrations/app-connections/checkly", + "integrations/app-connections/cloudflare", + "integrations/app-connections/databricks", + "integrations/app-connections/digital-ocean", + "integrations/app-connections/flyio", + "integrations/app-connections/gcp", + "integrations/app-connections/github", + "integrations/app-connections/github-radar", + "integrations/app-connections/gitlab", + "integrations/app-connections/hashicorp-vault", + "integrations/app-connections/heroku", + "integrations/app-connections/humanitec", + "integrations/app-connections/ldap", + "integrations/app-connections/mssql", + "integrations/app-connections/mysql", + "integrations/app-connections/netlify", + "integrations/app-connections/oci", + "integrations/app-connections/okta", + "integrations/app-connections/oracledb", + "integrations/app-connections/postgres", + "integrations/app-connections/railway", + "integrations/app-connections/render", + "integrations/app-connections/supabase", + "integrations/app-connections/teamcity", + "integrations/app-connections/terraform-cloud", + "integrations/app-connections/vercel", + "integrations/app-connections/windmill", + "integrations/app-connections/zabbix" + ] + } ] }, - { - "group": "Dynamic Secrets", - "pages": [ - "documentation/platform/dynamic-secrets/overview", - "documentation/platform/dynamic-secrets/aws-elasticache", - "documentation/platform/dynamic-secrets/aws-iam", - "documentation/platform/dynamic-secrets/azure-entra-id", - "documentation/platform/dynamic-secrets/cassandra", - "documentation/platform/dynamic-secrets/elastic-search", - "documentation/platform/dynamic-secrets/gcp-iam", - "documentation/platform/dynamic-secrets/github", - "documentation/platform/dynamic-secrets/ldap", - "documentation/platform/dynamic-secrets/mongo-atlas", - "documentation/platform/dynamic-secrets/mongo-db", - "documentation/platform/dynamic-secrets/mssql", - "documentation/platform/dynamic-secrets/mysql", - "documentation/platform/dynamic-secrets/oracle", - "documentation/platform/dynamic-secrets/postgresql", - "documentation/platform/dynamic-secrets/rabbit-mq", - "documentation/platform/dynamic-secrets/redis", - "documentation/platform/dynamic-secrets/sap-ase", - "documentation/platform/dynamic-secrets/sap-hana", - "documentation/platform/dynamic-secrets/snowflake", - "documentation/platform/dynamic-secrets/totp", - "documentation/platform/dynamic-secrets/kubernetes", - "documentation/platform/dynamic-secrets/vertica" - ] - }, - { - "group": "Gateway", - "pages": [ - "documentation/platform/gateways/overview", - "documentation/platform/gateways/gateway-security", - "documentation/platform/gateways/networking" - ] - }, - "documentation/platform/project-templates", { "group": "Workflow Integrations", "pages": [ @@ -206,21 +158,20 @@ "documentation/platform/external-migrations/vault" ] }, + "documentation/platform/admin-panel/server-admin", + "documentation/platform/secret-sharing" + ] + }, + { + "group": "Connectivity", + "pages": [ + "documentation/setup/networking", { - "group": "Admin Consoles", + "group": "Gateway", "pages": [ - "documentation/platform/admin-panel/overview", - "documentation/platform/admin-panel/server-admin", - "documentation/platform/admin-panel/org-admin-console" - ] - }, - "documentation/platform/secret-sharing", - { - "group": "Secret Scanning", - "pages": [ - "documentation/platform/secret-scanning/overview", - "documentation/platform/secret-scanning/bitbucket", - "documentation/platform/secret-scanning/github" + "documentation/platform/gateways/overview", + "documentation/platform/gateways/gateway-security", + "documentation/platform/gateways/networking" ] } ] @@ -321,6 +272,7 @@ } ] }, + "documentation/platform/identities/auth-templates", "documentation/platform/token", "documentation/platform/mfa", "documentation/platform/github-org-sync" @@ -425,18 +377,80 @@ ] }, { - "tab": "Integrations", - "groups": [ + "tab": "Products", + "menu": [ { - "group": "Infrastructure Integrations", - "pages": [ - "integrations/platforms/ansible", - "integrations/platforms/apache-airflow", + "item": "Secrets Management", + "groups": [ { - "group": "Container orchestrators", + "group": "Secrets Management", "pages": [ + "documentation/platform/secrets-mgmt/overview", + "documentation/platform/secrets-mgmt/project", + "documentation/platform/folder", { - "group": "Kubernetes", + "group": "Secret Rotation", + "pages": [ + "documentation/platform/secret-rotation/overview", + "documentation/platform/secret-rotation/auth0-client-secret", + "documentation/platform/secret-rotation/aws-iam-user-secret", + "documentation/platform/secret-rotation/azure-client-secret", + "documentation/platform/secret-rotation/ldap-password", + "documentation/platform/secret-rotation/mssql-credentials", + "documentation/platform/secret-rotation/mysql-credentials", + "documentation/platform/secret-rotation/okta-client-secret", + "documentation/platform/secret-rotation/oracledb-credentials", + "documentation/platform/secret-rotation/postgres-credentials" + ] + }, + { + "group": "Dynamic Secrets", + "pages": [ + "documentation/platform/dynamic-secrets/overview", + "documentation/platform/dynamic-secrets/aws-elasticache", + "documentation/platform/dynamic-secrets/aws-iam", + "documentation/platform/dynamic-secrets/azure-entra-id", + "documentation/platform/dynamic-secrets/cassandra", + "documentation/platform/dynamic-secrets/elastic-search", + "documentation/platform/dynamic-secrets/gcp-iam", + "documentation/platform/dynamic-secrets/github", + "documentation/platform/dynamic-secrets/ldap", + "documentation/platform/dynamic-secrets/mongo-atlas", + "documentation/platform/dynamic-secrets/mongo-db", + "documentation/platform/dynamic-secrets/mssql", + "documentation/platform/dynamic-secrets/mysql", + "documentation/platform/dynamic-secrets/oracle", + "documentation/platform/dynamic-secrets/postgresql", + "documentation/platform/dynamic-secrets/rabbit-mq", + "documentation/platform/dynamic-secrets/redis", + "documentation/platform/dynamic-secrets/sap-ase", + "documentation/platform/dynamic-secrets/sap-hana", + "documentation/platform/dynamic-secrets/snowflake", + "documentation/platform/dynamic-secrets/totp", + "documentation/platform/dynamic-secrets/kubernetes", + "documentation/platform/dynamic-secrets/vertica" + ] + }, + { + "group": "Guides", + "pages": [ + "documentation/guides/introduction", + "documentation/guides/local-development", + "documentation/guides/node", + "documentation/guides/python", + "documentation/guides/nextjs-vercel", + "documentation/guides/microsoft-power-apps" + ] + } + ] + }, + { + "group": "Infrastructure Integrations", + "pages": [ + "integrations/platforms/ansible", + "integrations/platforms/apache-airflow", + { + "group": "Kubernetes Operator", "pages": [ "integrations/platforms/kubernetes/overview", "integrations/platforms/kubernetes/infisical-secret-crd", @@ -446,220 +460,249 @@ }, "integrations/platforms/kubernetes-injector", "integrations/platforms/kubernetes-csi", - "integrations/platforms/docker-swarm-with-agent", - "integrations/platforms/ecs-with-agent" + { + "group": "Agent", + "pages": [ + "integrations/platforms/infisical-agent", + "integrations/platforms/docker-swarm-with-agent", + "integrations/platforms/ecs-with-agent" + ] + }, + { + "group": "Docker", + "pages": [ + "integrations/platforms/docker-intro", + "integrations/platforms/docker", + "integrations/platforms/docker-pass-envs", + "integrations/platforms/docker-compose" + ] + }, + "integrations/frameworks/packer", + "integrations/frameworks/pulumi", + "integrations/frameworks/terraform" ] }, { - "group": "Docker", + "group": "Secret Syncs", "pages": [ - "integrations/platforms/docker-intro", - "integrations/platforms/docker", - "integrations/platforms/docker-pass-envs", - "integrations/platforms/docker-compose" + "integrations/secret-syncs/overview", + { + "group": "Syncs", + "pages": [ + "integrations/secret-syncs/1password", + "integrations/secret-syncs/aws-parameter-store", + "integrations/secret-syncs/aws-secrets-manager", + "integrations/secret-syncs/azure-app-configuration", + "integrations/secret-syncs/azure-devops", + "integrations/secret-syncs/azure-key-vault", + "integrations/secret-syncs/bitbucket", + "integrations/secret-syncs/camunda", + "integrations/secret-syncs/checkly", + "integrations/secret-syncs/cloudflare-pages", + "integrations/secret-syncs/cloudflare-workers", + "integrations/secret-syncs/databricks", + "integrations/secret-syncs/digital-ocean-app-platform", + "integrations/secret-syncs/flyio", + "integrations/secret-syncs/gcp-secret-manager", + "integrations/secret-syncs/github", + "integrations/secret-syncs/gitlab", + "integrations/secret-syncs/hashicorp-vault", + "integrations/secret-syncs/heroku", + "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/netlify", + "integrations/secret-syncs/oci-vault", + "integrations/secret-syncs/railway", + "integrations/secret-syncs/render", + "integrations/secret-syncs/supabase", + "integrations/secret-syncs/teamcity", + "integrations/secret-syncs/terraform-cloud", + "integrations/secret-syncs/vercel", + "integrations/secret-syncs/windmill", + "integrations/secret-syncs/zabbix" + ] + } ] }, - "integrations/platforms/infisical-agent", - "integrations/frameworks/packer", - "integrations/frameworks/pulumi", - "integrations/frameworks/terraform" - ] - }, - { - "group": "App Connections", - "pages": [ - "integrations/app-connections/overview", { - "group": "Connections", + "group": "Native Integrations", "pages": [ - "integrations/app-connections/1password", - "integrations/app-connections/auth0", - "integrations/app-connections/aws", - "integrations/app-connections/azure-app-configuration", - "integrations/app-connections/azure-client-secrets", - "integrations/app-connections/azure-devops", - "integrations/app-connections/azure-key-vault", - "integrations/app-connections/bitbucket", - "integrations/app-connections/camunda", - "integrations/app-connections/checkly", - "integrations/app-connections/cloudflare", - "integrations/app-connections/databricks", - "integrations/app-connections/digital-ocean", - "integrations/app-connections/flyio", - "integrations/app-connections/gcp", - "integrations/app-connections/github", - "integrations/app-connections/github-radar", - "integrations/app-connections/gitlab", - "integrations/app-connections/hashicorp-vault", - "integrations/app-connections/heroku", - "integrations/app-connections/humanitec", - "integrations/app-connections/ldap", - "integrations/app-connections/mssql", - "integrations/app-connections/mysql", - "integrations/app-connections/oci", - "integrations/app-connections/okta", - "integrations/app-connections/oracledb", - "integrations/app-connections/postgres", - "integrations/app-connections/railway", - "integrations/app-connections/render", - "integrations/app-connections/supabase", - "integrations/app-connections/teamcity", - "integrations/app-connections/terraform-cloud", - "integrations/app-connections/vercel", - "integrations/app-connections/windmill", - "integrations/app-connections/zabbix" - ] - } - ] - }, - { - "group": "Secret Syncs", - "pages": [ - "integrations/secret-syncs/overview", - { - "group": "Syncs", - "pages": [ - "integrations/secret-syncs/1password", - "integrations/secret-syncs/aws-parameter-store", - "integrations/secret-syncs/aws-secrets-manager", - "integrations/secret-syncs/azure-app-configuration", - "integrations/secret-syncs/azure-devops", - "integrations/secret-syncs/azure-key-vault", - "integrations/secret-syncs/bitbucket", - "integrations/secret-syncs/camunda", - "integrations/secret-syncs/checkly", - "integrations/secret-syncs/cloudflare-pages", - "integrations/secret-syncs/cloudflare-workers", - "integrations/secret-syncs/databricks", - "integrations/secret-syncs/digital-ocean-app-platform", - "integrations/secret-syncs/flyio", - "integrations/secret-syncs/gcp-secret-manager", - "integrations/secret-syncs/github", - "integrations/secret-syncs/gitlab", - "integrations/secret-syncs/hashicorp-vault", - "integrations/secret-syncs/heroku", - "integrations/secret-syncs/humanitec", - "integrations/secret-syncs/oci-vault", - "integrations/secret-syncs/railway", - "integrations/secret-syncs/render", - "integrations/secret-syncs/supabase", - "integrations/secret-syncs/teamcity", - "integrations/secret-syncs/terraform-cloud", - "integrations/secret-syncs/vercel", - "integrations/secret-syncs/windmill", - "integrations/secret-syncs/zabbix" - ] - } - ] - }, - { - "group": "Native Integrations", - "pages": [ - { - "group": "AWS", - "pages": [ - "integrations/cloud/aws-parameter-store", - "integrations/cloud/aws-secret-manager", - "integrations/cloud/aws-amplify" + { + "group": "AWS", + "pages": [ + "integrations/cloud/aws-parameter-store", + "integrations/cloud/aws-secret-manager", + "integrations/cloud/aws-amplify" + ] + }, + "integrations/cloud/vercel", + "integrations/cloud/azure-key-vault", + "integrations/cloud/azure-app-configuration", + "integrations/cloud/azure-devops", + "integrations/cloud/gcp-secret-manager", + { + "group": "Cloudflare", + "pages": [ + "integrations/cloud/cloudflare-pages", + "integrations/cloud/cloudflare-workers" + ] + }, + "integrations/cloud/terraform-cloud", + "integrations/cloud/databricks", + { + "group": "View more", + "pages": [ + "integrations/cloud/digital-ocean-app-platform", + "integrations/cloud/heroku", + "integrations/cloud/netlify", + "integrations/cloud/flyio", + "integrations/cloud/railway", + "integrations/cloud/render", + "integrations/cloud/laravel-forge", + "integrations/cloud/supabase", + "integrations/cloud/northflank", + "integrations/cloud/hasura-cloud", + "integrations/cloud/qovery", + "integrations/cloud/hashicorp-vault", + "integrations/cloud/cloud-66", + "integrations/cloud/windmill" + ] + } ] }, - "integrations/cloud/vercel", - "integrations/cloud/azure-key-vault", - "integrations/cloud/azure-app-configuration", - "integrations/cloud/azure-devops", - "integrations/cloud/gcp-secret-manager", { - "group": "Cloudflare", + "group": "CI/CD Integrations", "pages": [ - "integrations/cloud/cloudflare-pages", - "integrations/cloud/cloudflare-workers" + "integrations/cicd/jenkins", + "integrations/cicd/githubactions", + "integrations/cicd/gitlab", + "integrations/cicd/bitbucket", + "integrations/cloud/teamcity", + { + "group": "View more", + "pages": [ + "integrations/cicd/circleci", + "integrations/cicd/travisci", + "integrations/cicd/rundeck", + "integrations/cicd/codefresh", + "integrations/cloud/checkly", + "integrations/cicd/octopus-deploy" + ] + } ] }, - "integrations/cloud/terraform-cloud", - "integrations/cloud/databricks", { - "group": "View more", + "group": "Framework Integrations", "pages": [ - "integrations/cloud/digital-ocean-app-platform", - "integrations/cloud/heroku", - "integrations/cloud/netlify", - "integrations/cloud/flyio", - "integrations/cloud/railway", - "integrations/cloud/render", - "integrations/cloud/laravel-forge", - "integrations/cloud/supabase", - "integrations/cloud/northflank", - "integrations/cloud/hasura-cloud", - "integrations/cloud/qovery", - "integrations/cloud/hashicorp-vault", - "integrations/cloud/cloud-66", - "integrations/cloud/windmill" + "integrations/frameworks/spring-boot-maven", + "integrations/frameworks/react", + "integrations/frameworks/vue", + "integrations/frameworks/express", + { + "group": "View more", + "pages": [ + "integrations/frameworks/nextjs", + "integrations/frameworks/nestjs", + "integrations/frameworks/sveltekit", + "integrations/frameworks/nuxt", + "integrations/frameworks/gatsby", + "integrations/frameworks/remix", + "integrations/frameworks/vite", + "integrations/frameworks/fiber", + "integrations/frameworks/django", + "integrations/frameworks/flask", + "integrations/frameworks/laravel", + "integrations/frameworks/rails", + "integrations/frameworks/dotnet", + "integrations/platforms/pm2", + "integrations/frameworks/ab-initio" + ] + } + ] + }, + { + "group": "Build Tool Integrations", + "pages": ["integrations/build-tools/gradle"] + }, + { + "group": "Others", + "pages": ["integrations/external/backstage"] + } + ] + }, + { + "item": "Secrets Scanning", + "groups": [ + { + "group": "Secret Scanning", + "pages": [ + "documentation/platform/secret-scanning/overview" + ] + }, + { + "group": "Datasources", + "pages": [ + "documentation/platform/secret-scanning/bitbucket", + "documentation/platform/secret-scanning/github", + "documentation/platform/secret-scanning/gitlab" ] } ] }, { - "group": "CI/CD Integrations", - "pages": [ - "integrations/cicd/jenkins", - "integrations/cicd/githubactions", - "integrations/cicd/gitlab", - "integrations/cicd/bitbucket", - "integrations/cloud/teamcity", + "item": "Infisical PKI", + "groups": [ { - "group": "View more", + "group": "Infisical PKI", "pages": [ - "integrations/cicd/circleci", - "integrations/cicd/travisci", - "integrations/cicd/rundeck", - "integrations/cicd/codefresh", - "integrations/cloud/checkly", - "integrations/cicd/octopus-deploy" + "documentation/platform/pki/overview", + "documentation/platform/pki/private-ca", + "documentation/platform/pki/external-ca", + "documentation/platform/pki/subscribers", + "documentation/platform/pki/certificates", + "documentation/platform/pki/acme-ca", + "documentation/platform/pki/est", + "documentation/platform/pki/alerting", + { + "group": "Integrations", + "pages": [ + "documentation/platform/pki/pki-issuer", + "documentation/platform/pki/integration-guides/gloo-mesh" + ] + } ] } ] }, { - "group": "Framework Integrations", - "pages": [ - "integrations/frameworks/spring-boot-maven", - "integrations/frameworks/react", - "integrations/frameworks/vue", - "integrations/frameworks/express", + "item": "Infisical SSH", + "groups": [ { - "group": "View more", + "group": "Infisical SSH", "pages": [ - "integrations/frameworks/nextjs", - "integrations/frameworks/nestjs", - "integrations/frameworks/sveltekit", - "integrations/frameworks/nuxt", - "integrations/frameworks/gatsby", - "integrations/frameworks/remix", - "integrations/frameworks/vite", - "integrations/frameworks/fiber", - "integrations/frameworks/django", - "integrations/frameworks/flask", - "integrations/frameworks/laravel", - "integrations/frameworks/rails", - "integrations/frameworks/dotnet", - "integrations/platforms/pm2", - "integrations/frameworks/ab-initio" + "documentation/platform/ssh/overview", + "documentation/platform/ssh/host-groups" ] } ] }, { - "group": "Build Tool Integrations", - "pages": ["integrations/build-tools/gradle"] - }, - { - "group": "Others", - "pages": ["integrations/external/backstage"] + "item": "Infisical KMS", + "groups": [ + { + "group": "Infisical KMS", + "pages": [ + "documentation/platform/kms/overview", + "documentation/platform/kms/hsm-integration", + "documentation/platform/kms/kubernetes-encryption", + "documentation/platform/kms/kmip" + ] + } + ] } ] }, { - "tab": "CLI", + "tab": "CLI Reference", "groups": [ { "group": "Command line", @@ -904,6 +947,7 @@ "api-reference/endpoints/organizations/memberships", "api-reference/endpoints/organizations/update-membership", "api-reference/endpoints/organizations/delete-membership", + "api-reference/endpoints/organizations/bulk-delete-memberships", "api-reference/endpoints/organizations/list-identity-memberships", "api-reference/endpoints/organizations/workspaces" ] @@ -1203,6 +1247,21 @@ "api-reference/endpoints/secret-scanning/data-sources/github/scan", "api-reference/endpoints/secret-scanning/data-sources/github/scan-resource" ] + }, + { + "group": "GitLab", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/gitlab/list", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/create", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/update", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/delete", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/scan", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource" + ] } ] }, @@ -1541,6 +1600,18 @@ "api-reference/endpoints/app-connections/mysql/delete" ] }, + { + "group": "Netlify", + "pages": [ + "api-reference/endpoints/app-connections/netlify/list", + "api-reference/endpoints/app-connections/netlify/available", + "api-reference/endpoints/app-connections/netlify/get-by-id", + "api-reference/endpoints/app-connections/netlify/get-by-name", + "api-reference/endpoints/app-connections/netlify/create", + "api-reference/endpoints/app-connections/netlify/update", + "api-reference/endpoints/app-connections/netlify/delete" + ] + }, { "group": "OCI", "pages": [ @@ -1961,6 +2032,19 @@ "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] }, + { + "group": "Netlify", + "pages": [ + "api-reference/endpoints/secret-syncs/netlify/list", + "api-reference/endpoints/secret-syncs/netlify/get-by-id", + "api-reference/endpoints/secret-syncs/netlify/get-by-name", + "api-reference/endpoints/secret-syncs/netlify/create", + "api-reference/endpoints/secret-syncs/netlify/update", + "api-reference/endpoints/secret-syncs/netlify/delete", + "api-reference/endpoints/secret-syncs/netlify/sync-secrets", + "api-reference/endpoints/secret-syncs/netlify/remove-secrets" + ] + }, { "group": "OCI", "pages": [ diff --git a/docs/documentation/getting-started/concepts/audit-logs.mdx b/docs/documentation/getting-started/concepts/audit-logs.mdx new file mode 100644 index 000000000..e67f06f5a --- /dev/null +++ b/docs/documentation/getting-started/concepts/audit-logs.mdx @@ -0,0 +1,40 @@ +--- +title: "Audit Logs" +sidebarTitle: "Audit Logs" +description: "Understand how Infisical logs activity and supports external audit streaming." +--- + +Infisical records a detailed audit trail of actions across the platform — providing deep visibility into access, changes, and usage for security and compliance purposes. + +Every interaction with Infisical resources generates an audit event. These events are immutable and include metadata such as the actor, event type, affected resources, timestamp, IP address, and client source. + +Audit logs enable teams to: + +- Monitor access and changes to secrets, certificates, and infrastructure. +- Investigate incidents with full context around who did what, when, and how. +- Meet compliance and governance requirements with structured activity records. + +To learn more, refer to the [audit logs documentation](/documentation/platform/audit-logs). + +## Log Coverage + +Infisical tracks dozens of event types across the platform — including secret access, permission changes, certificate issuance, SSH session activity, and identity management. + +Each audit entry includes structured fields that make it easy to search, filter, and correlate across systems. For example: + +- Event Type: Action that occurred (e.g., `create-secret`, `issue-ssh-cert`). +- Actor: Who performed the action (user or machine identity). +- Resource: What was affected (e.g., project, secret, certificate). +- Context: IP address, user agent, permissions, and more. + +## External Log Streaming + +For centralized monitoring and long-term retention, Infisical supports [audit log streaming](/documentation/platform/audit-log-streams/audit-log-streams) to external systems. + +You can forward logs to SIEM platforms, storage buckets, or observability stacks using JSON-based collectors. Infisical integrates well with tools like [Fluent Bit](/documentation/platform/audit-log-streams/audit-log-streams-with-fluentbit#deploy-fluent-bit), enabling teams to route logs to destinations such as: + +- AWS S3 +- Elasticsearch +- Splunk +- Datadog +- Cloud-native log pipelines diff --git a/docs/documentation/getting-started/concepts/client-integrations.mdx b/docs/documentation/getting-started/concepts/client-integrations.mdx new file mode 100644 index 000000000..bcd935830 --- /dev/null +++ b/docs/documentation/getting-started/concepts/client-integrations.mdx @@ -0,0 +1,31 @@ +--- +title: "Client Ecosystem" +sidebarTitle: "Client Ecosystem" +description: "Get an overview of the CLI, SDKs, agents, APIs, and integrations that interact with Infisical." +--- + +Infisical provides a flexible interface for integrating into development workflows and infrastructure. Around it is a rich ecosystem of clients and integrations that allow users and systems to interact with Infisical across any environment. + +These clients enable access to secrets, certificates, and other resources from wherever they’re needed—whether that’s a developer’s terminal, a CI/CD pipeline, or a running Kubernetes workload. + +## Available Clients and Interfaces + +Infisical offers a non-exhaustive set of clients and interfaces to support a wide range of use cases: + +- [CLI](/cli/overview): A powerful command-line interface for developers and operators to interact with Infisical from local or automated environments. Commonly used for secret access, SSH credential issuance, and more. + +- [SDKs](/sdks/overview): Official client libraries for languages like Go, Node.js, and Python make it easy to integrate Infisical directly into applications and internal tooling. + +- [HTTP API](/api-reference/overview/introduction): A fully documented RESTful API powers all core functionality and enables advanced or custom integrations. + +- [Agents](/integrations/platforms/infisical-agent): Lightweight background processes that can fetch and sync secrets or credentials into local environments, containers, or file systems. + +- [Kubernetes Operator](/integrations/platforms/kubernetes/overview): A native controller that syncs Infisical secrets into Kubernetes as native Secrets, and supports secure workload integration. + +- [External Secrets Operator (ESO)](https://external-secrets.io/latest/provider/infisical): Allows Infisical to act as a backend provider for syncing secrets into Kubernetes `Secret` objects using the widely adopted External Secrets Operator. + +- [Kubernetes PKI Issuer](/documentation/platform/pki/pki-issuer): A controller that issues X.509 certificates from Infisical PKI using the cert-manager Issuer and Certificate CRDs. + +- [Secret Syncs](/integrations/secret-syncs/overview): Native integrations to forward secrets to services like GitHub, GitLab, AWS Secrets Manager, Vercel, and more. + +This modular ecosystem lets teams use Infisical alongside their existing stack—without requiring opinionated workflows or lock-in. diff --git a/docs/documentation/getting-started/concepts/deployment-models.mdx b/docs/documentation/getting-started/concepts/deployment-models.mdx new file mode 100644 index 000000000..39078a142 --- /dev/null +++ b/docs/documentation/getting-started/concepts/deployment-models.mdx @@ -0,0 +1,52 @@ +--- +title: "Using Infisical: Cloud or Self-Hosted" +sidebarTitle: "Cloud vs. Self-Host" +description: "Choose between Infisical Cloud or a self-managed deployment" +--- + +Infisical can be used in two ways: via [Infisical Cloud](https://app.infisical.com), a managed offering, or through a self-hosted deployment within your own infrastructure. + +Both options provide the same core platform capabilities. The decision depends on your operational model, trust boundaries, and compliance requirements. While Infisical Cloud comes with built-in security and operational guarantees, a self-hosted deployment gives you full control—but also full responsibility for securing and maintaining the system. + +## Infisical Cloud + +Infisical Cloud is our managed service found at [app.infisical.com](https://app.infisical.com). It includes automated updates, availability guarantees, and secure infrastructure operations. + +For most teams, Infisical Cloud is the recommended way to get started. It simplifies adoption by removing the need to manage deployment, scaling, or maintenance internally. + +Use this if: + +- You prefer not to operate infrastructure or handle upgrades +- You require a secure, production-grade hosted service +- You want to adopt Infisical with minimal operational overhead + + +

+ By default, Infisical Cloud is a secure, multi-tenant service. For + enterprises with stricter isolation or regulatory needs, dedicated cloud + instances are available. +

+

Contact sales@infisical.com to learn more.

+
+ +## Self-Hosted Infisical + +Infisical can also be deployed and managed within your own infrastructure. This approach provides full control over platform configuration, data storage, and operational security. In this model, your team is responsible for maintaining uptime, monitoring, patching, and integrations. + +Use this if: + +- You require complete control over data, deployment, and security posture +- Your compliance model mandates self-managed or on-premise systems +- You need to tightly integrate with internal tooling and infrastructure + +Infisical supports multiple deployment methods, including [Docker](/self-hosting/deployment-options/standalone-infisical), [Docker Compose](/self-hosting/deployment-options/docker-compose), [Kubernetes](/self-hosting/deployment-options/kubernetes-helm), and [Linux package](/self-hosting/deployment-options/native/linux-package/installation). + +To learn more, refer to the [self-hosting documentation](/self-hosting/overview). + + +

+ The open-source core is available under the MIT license. Additional + enterprise features and support are available with a commercial license. +

+

Contact sales@infisical.com to learn more.

+
diff --git a/docs/documentation/getting-started/concepts/platform-hierarchy.mdx b/docs/documentation/getting-started/concepts/platform-hierarchy.mdx new file mode 100644 index 000000000..7697f935b --- /dev/null +++ b/docs/documentation/getting-started/concepts/platform-hierarchy.mdx @@ -0,0 +1,41 @@ +--- +title: "Platform Hierarchy" +sidebarTitle: "Platform Hierarchy" +description: "Understand how organizations and projects are structured in Infisical." +--- + +Infisical is structured around organizations and projects, allowing teams to manage multiple products, access scopes, and use cases within a single account while keeping boundaries and responsibilities clearly defined. + +## Organizations + +An [organization](/documentation/platform/organization) typically represents a company or high-level entity (e.g. Acme Corp). It acts as the umbrella for all projects, members, and billing settings. + +[Users](/documentation/platform/identities/user-identities) are invited to an organization and assigned [organization-level roles](/documentation/platform/access-controls/role-based-access-controls#organization-level-access-controls) that determine what they can manage—such as members, machine identities, and billing details. + +![organization](/images/platform/organization/organization.png) + +## Projects + +A [project](/documentation/platform/project) belongs to an organization and defines a specific scope of work. Each project has a product type such as Secrets Management, SSH, or PKI that determines what features are available in that project. + +For example: + +- A Secrets Management project manages application secrets across environments. + +- An SSH project enables certificate-based access to infrastructure. + +- A PKI project manages certificate authorities and X.509 certificate workflows. + +Users are added to a project and assigned [project-level roles](/documentation/platform/access-controls/role-based-access-controls#project-level-access-controls) that determine what they can manage—such as secrets, access policies, or certificate authorities. A user can have different roles across projects, allowing for flexible and fine-grained access control that reflects how teams operate in practice. + +![organization projects](/images/platform/organization/organization-projects.png) + +## Key Characteristics + +- Projects are isolated in terms of configuration, permissions, and product workflows. + +- Access is managed independently at both the organization and project level. + +- All projects within an organization share the same billing and user directory. + +Teams can adopt Infisical incrementally—starting with one product and expanding as needed. diff --git a/docs/documentation/getting-started/concepts/platform-iam.mdx b/docs/documentation/getting-started/concepts/platform-iam.mdx new file mode 100644 index 000000000..87c343269 --- /dev/null +++ b/docs/documentation/getting-started/concepts/platform-iam.mdx @@ -0,0 +1,29 @@ +--- +title: "Platform Identity and Access Management" +sidebarTitle: "Platform IAM" +description: "Understand how users, machine identities, roles, and permissions are managed." +--- + +Infisical uses identity-based access control to govern how users and systems interact with secrets, certificates, infrastructure, and other resources on the platform. + +There are two types of identities: + +- [User identities](/documentation/platform/identities/user-identities): Represent individuals such as developers or administrators that typically access the platform via browser. + +- [Machine identities](/documentation/platform/identities/machine-identities): Represent systems such as CI pipelines or applications that programmatically interact with the platform. + +Each identity is granted access based on its assigned roles and permissions and must authenticate with the platform in order to access any resources. + +To learn more, refer to the [identities documentation](/documentation/platform/identities/overview). + +## Roles and Access + +Infisical provides a robust and flexible access control system. The primary authorization mechanism is [role-based access control (RBAC)](/documentation/platform/access-controls/role-based-access-controls), where identities are assigned roles at two access control levels: + +- [Organization-level access control](/documentation/platform/access-controls/role-based-access-controls#organization-level-access-controls): Control billing, member management, and platform-wide settings + +- [Project-level access control](/documentation/platform/access-controls/role-based-access-controls#project-level-access-controls): Control access to specific product resources like secrets, SSH hosts, or certificates + +Beyond RBAC, Infisical also supports additional project-level permissioning features, [including attribute-based access control (ABAC)](/documentation/platform/access-controls/abac/overview), [temporary access grants](/documentation/platform/access-controls/temporary-access), and [additional privileges](/documentation/platform/access-controls/additional-privileges) for select project types. + +To learn more, refer to the [access control documentation](/documentation/platform/access-controls/overview). diff --git a/docs/documentation/getting-started/introduction-new.mdx b/docs/documentation/getting-started/introduction-new.mdx deleted file mode 100644 index c8eee8739..000000000 --- a/docs/documentation/getting-started/introduction-new.mdx +++ /dev/null @@ -1,107 +0,0 @@ ---- -mode: 'custom' ---- - -export function openSearch() { - document.getElementById('search-bar-entry').click(); -} - -
-
- -
-
-
- Infisical Documentation -
-

- What can we help you build? -

- -
-
- -
- -
- Choose a topic below or simply{' '} - get started -
- - - - Practical guides and best practices to get you up and running quickly. - - - Comprehensive details about the Infisical API. - - - Learn more about Infisical's architecture and underlying security. - - - Read self-hosting instruction for Infisical. - - - Infisical's growing number of third-party integrations. - - - News about features and changes in Pinecone and related tools. - - - -
\ No newline at end of file diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index d73c28ab5..f773019ec 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -1,106 +1,40 @@ --- title: "What is Infisical?" sidebarTitle: "What is Infisical?" -description: "An Introduction to the Infisical secret management platform." +description: "The open source platform for managing secrets, certificates, and secure infrastructure access." --- -**[Infisical](https://infisical.com)** is the open source secret management platform that developers use to centralize their application configuration and secrets like API keys and database credentials as well as manage their internal PKI. Additionally, developers use Infisical to prevent secrets leaks to git and securely share secrets amongst engineers. +## What is Infisical? + +[Infisical](https://infisical.com) is the [open source](https://github.com/Infisical/infisical), all-in-one platform for secrets, certificates, and privileged access management. + +It provides modern security workflows — including secrets rotation, dynamic credentials, access approvals, and SSH certificate-based access — all within one platform designed for developers, infrastructure, and security teams. Start managing secrets securely with [Infisical Cloud](https://app.infisical.com) or learn how to [host Infisical](/self-hosting/overview) yourself. - - - Get started with Infisical Cloud in just a few minutes. - - - Self-host Infisical on your own infrastructure. - - +## Why use Infisical? -## Why Infisical? +Managing secrets, credentials, and infrastructure access is a critical concern for engineering teams. As infrastructure scales and environments become more complex, [secrets start to sprawl](https://infisical.com/blog/what-is-secret-sprawl) — across codebases, CI/CD pipelines, configuration files, and cloud services. This makes them difficult to track, rotate, and secure. -Infisical helps developers achieve secure centralized secret management and provides all the tools to easily manage secrets in various environments and infrastructure components. In particular, here are some of the most common points that developers mention after adopting Infisical: +Without proper management, secret sprawl turns into risk: hardcoded credentials, unrotated keys, fragmented access controls that attackers can exploit amongst other things. -- Streamlined **local development** processes (switching .env files to [Infisical CLI](/cli/commands/run) and removing secrets from developer machines). -- **Best-in-class developer experience** with an easy-to-use [Web Dashboard](/documentation/platform/project). -- Simple secret management inside **[CI/CD pipelines](/integrations/cicd/githubactions)** and staging environments. -- Secure and compliant secret management practices in **[production environments](/sdks/overview)**. -- **Facilitated workflows** around [secret change management](/documentation/platform/pr-workflows), [access requests](/documentation/platform/access-controls/access-requests), [temporary access provisioning](/documentation/platform/access-controls/temporary-access), and more. -- **Improved security posture** thanks to [secret scanning](/cli/scanning-overview), [granular access control policies](/documentation/platform/access-controls/overview), [automated secret rotation](https://infisical.com/docs/documentation/platform/secret-rotation/overview), and [dynamic secrets](/documentation/platform/dynamic-secrets/overview) capabilities. +Infisical addresses this challenge by providing an all-in-one platform and workflows to: -## How does Infisical work? +- Securely store and manage application secrets from development to production. +- Scan code and pipelines for exposed credentials. +- Automate X.509 certificate issuance and renewal. +- Manage SSH access using short-lived, policy-driven certificates. +- Encrypt and decrypt sensitive data with centralized key control. +- Audit every access, credential use, and change. -To make secret management effortless and secure, Infisical follows a certain structure for enabling secret management workflows as defined below. +Infisical is designed to integrate cleanly into your stack—improving security without adding complexity. -**Identities** in Infisical are users or machine which have a certain set of roles and permissions assigned to them. Such identities are able to manage secrets in various **Clients** throughout the entire infrastructure. To do that, identities have to verify themselves through one of the available **Authentication Methods**. +## What does Infisical include? -As a result, the 3 main concepts that are important to understand are: +Infisical consists of several tightly integrated products, each designed to solve a specific part of the infrastructure security surface: -- **[Identities](/documentation/platform/identities/overview)**: users or machines with a set permissions assigned to them. -- **[Clients](/integrations/platforms/kubernetes)**: Infisical-developed tools for managing secrets in various infrastructure components (e.g., [Kubernetes Operator](/integrations/platforms/kubernetes), [Infisical Agent](/integrations/platforms/infisical-agent), [CLI](/cli/usage), [SDKs](/sdks/overview), [API](/api-reference/overview/introduction), [Web Dashboard](/documentation/platform/organization)). -- **[Authentication Methods](/documentation/platform/identities/universal-auth)**: ways for Identities to authenticate inside different clients (e.g., SAML SSO for Web Dashboard, Universal Auth for Infisical Agent, AWS Auth etc.). - -## How to get started with Infisical? - -Depending on your use case, it might be helpful to look into some of the resources and guides provided below. - - - - Inject secrets into any application process/environment. - - - Fetch secrets with any programming language on demand. - - - Inject secrets into Docker containers. - - - Fetch and save secrets as native Kubernetes secrets. - - - Fetch secrets via HTTP request. - - - Explore integrations for GitHub, Vercel, AWS, and more. - - +- [Secrets Management](/documentation/platform/secrets-mgmt/overview): Securely store, access, and distribute secrets across environments with fine-grained controls, automatic rotation, and audit logging. +- [Secrets Scanning](/documentation/platform/secret-scanning/overview): Detect hardcoded secrets in code, CI pipelines, and infrastructure—integrated with GitHub, GitLab, Bitbucket, and more. +- [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. +- [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. +- [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. diff --git a/docs/documentation/getting-started/overview.mdx b/docs/documentation/getting-started/overview.mdx new file mode 100644 index 000000000..769990987 --- /dev/null +++ b/docs/documentation/getting-started/overview.mdx @@ -0,0 +1,77 @@ +--- +title: "Overview" +sidebarTitle: "Overview" +description: "The open source platform for managing secrets, certificates, and secure infrastructure access." +--- + + + Learn what Infisical is and how it can help you manage secrets, certificates, + and secure access across your infrastructure. + + +## Products + + + + Securely store, manage, and control access to sensitive application secrets across your environments. + + + + Automatically detect and alert on hardcoded secrets in source code, CI pipelines, and infrastructure. + + + Automate the issuance and management of X.509 certificates across your infrastructure using modern protocols like EST. + + + Replace static SSH keys with short-lived SSH certificates to simplify access and improve security. + + + + + + Encrypt and decrypt sensitive data using a centralized key management system. + + + +## Resources + + + + Explore Infisical’s command-line interface for managing secrets, + certificates, and system operations via terminal. + + + Browse Infisical’s API documentation to programmatically interact with + secrets, access controls, and certificate workflows. + + + + + Learn how to deploy and operate Infisical on your own infrastructure with full + control and data ownership. + + diff --git a/docs/documentation/platform/identities/auth-templates.mdx b/docs/documentation/platform/identities/auth-templates.mdx new file mode 100644 index 000000000..5c389376f --- /dev/null +++ b/docs/documentation/platform/identities/auth-templates.mdx @@ -0,0 +1,96 @@ +--- +title: "Machine Identity Auth Templates" +description: "Learn how to use auth templates to standardize authentication configurations for machine identities." +--- + +## Concept + +Machine Identity Auth Templates allow you to create reusable authentication configurations that can be applied across multiple machine identities. This feature helps standardize authentication setups, reduces configuration drift, and simplifies identity management at scale. + +Instead of manually configuring authentication settings for each identity, you can create templates with predefined authentication parameters and apply them to multiple identities. This ensures consistency and reduces the likelihood of configuration errors. + +Key Benefits: + +- **Standardization**: Ensure consistent authentication configurations across identities +- **Efficiency**: Reduce time spent configuring individual identities +- **Governance**: Centrally manage and update authentication parameters +- **Scalability**: Easily apply proven configurations to new identities + +## Managing Auth Templates + +Auth templates are managed in **Organization Settings > Access Control > Identities** under the **Identity Auth Templates** section. + +![Identity Auth Templates Section](/images/platform/identities/auth-templates/templates-section.png) + +### Creating a Template + + + + In your organization settings, go to **Access Control > Identities** and scroll down to the **Identity Auth Templates** section. + + + + Click **Create Template** to open the template creation modal. + + ![Create Template Button](/images/platform/identities/auth-templates/create-template-button.png) + + Select the authentication method you want to create a template for (currently supports LDAP Auth). + + + + Fill in the template configuration based on your chosen authentication method. + + + + **For LDAP Auth templates**, configure the following fields: + + ![LDAP Auth Template](/images/platform/identities/auth-templates/ldap-template.png) + + - **Template Name**: A descriptive name for your template + - **URL**: The LDAP server to connect to such as `ldap://ldap.your-org.com`, `ldaps://ldap.myorg.com:636` _(for connection over SSL/TLS)_, etc. + - **Bind DN**: The DN to bind to the LDAP server with. + - **Bind Pass**: The password to bind to the LDAP server with. + - **Search Base / DN**: Base DN under which to perform user search such as `ou=Users,dc=acme,dc=com`. + - **CA Certificate**: The CA certificate to use when verifying the LDAP server certificate. This field is optional but recommended. + + + You can read more about LDAP Auth configuration in the [LDAP Auth documentation](/documentation/platform/identities/ldap-auth/general). + + + + + + +### Using Templates + +Once created, templates can be applied when configuring authentication methods for machine identities. When adding an auth method to an identity, you'll have the option to select from available templates or configure manually. + +![Attach Template](/images/platform/identities/auth-templates/machine-identity-page.png) +![Attach Template Form](/images/platform/identities/auth-templates/attach-template-form.png) + +### Managing Template Usage + +You can view which identities are using a specific template by clicking **View Usages** in the template's dropdown menu. + +![Template Usages](/images/platform/identities/auth-templates/template-usages.png) +![Template Usages Modal](/images/platform/identities/auth-templates/template-usages-modal.png) + +## FAQ + + + + Yes, you can edit existing templates. After editing a template, changes to templates will automatically update identities that are already using them. + + + + If you delete a template that's currently being used by identities, those identities will continue to function with their existing configuration. However, the link to the template will be broken, and you won't be able to use the template for new identities. + + + + Yes, click **View Usages** in the template's dropdown menu to see all identities currently using that template. + + + + Currently, auth templates support LDAP Auth. Support for additional authentication methods will be added in future releases. + + \ No newline at end of file diff --git a/docs/documentation/platform/identities/ldap-auth/general.mdx b/docs/documentation/platform/identities/ldap-auth/general.mdx index 7fb2798c7..01395b68c 100644 --- a/docs/documentation/platform/identities/ldap-auth/general.mdx +++ b/docs/documentation/platform/identities/ldap-auth/general.mdx @@ -5,6 +5,12 @@ description: "Learn how to authenticate with Infisical using LDAP." **LDAP Auth** is an LDAP based authentication method that allows you to authenticate with Infisical using a machine identity configured with an [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) directory. +## Templates + +You can create reusable LDAP authentication templates to standardize configurations across multiple machine identities. Templates help ensure consistency, reduce configuration errors, and simplify identity management at scale. + +To create and manage LDAP auth templates, see our [Machine Identity Auth Templates documentation](/documentation/platform/identities/auth-templates). Once you've created a template, you can apply it when configuring LDAP auth for your identities in the guide below. + ## Guide diff --git a/docs/documentation/platform/identities/user-identities.mdx b/docs/documentation/platform/identities/user-identities.mdx index bcb470a3e..31e4bf242 100644 --- a/docs/documentation/platform/identities/user-identities.mdx +++ b/docs/documentation/platform/identities/user-identities.mdx @@ -5,18 +5,19 @@ description: "Read more about the concept of user identities in Infisical." ## Concept -A **user identity** (also known as **user**) represents a developer, admin, or any other human entity interacting with resources in Infisical. +A **user identity** (also known as **user**) represents a developer, admin, or any other human entity interacting with resources in Infisical. -Users can be added manually (through Web UI) or programmatically (e.g., API) to [organizations](../organization) and [projects](../projects). +Users can be added manually (through Web UI) or programmatically (e.g., API) to [organizations](../organization) and [projects](../projects). -Upon being added to an organization and projects, users assume a certain set of roles and permissions that represents their identity. +Upon being added to an organization and projects, users assume a certain set of roles and permissions that represents their identity. -![organization members](../../../images/platform/organization/organization-members.png) +![organization users](/images/platform/organization/organization-users.png) ## Authentication methods -To interact with various resources in Infisical, users are able to utilize a number of authentication methods: -- **Email & Password**: the most common authentication method that is used for authentication into Web Dashboard and Infisical CLI. It is recommended to utilize [Multi-factor Authentication](/documentation/platform/mfa) in addition to it. -- **SSO**: Infisical natively integrates with a number of SSO identity providers like [Google](/documentation/platform/sso/google), [GitHub](/documentation/platform/sso/github), and [GitLab](/documentation/platform/sso/gitlab). -- **SAML SSO**: It is also possible to set up SAML SSO integration with identity providers like [Okta](/documentation/platform/sso/okta), [Microsoft Entra ID](/documentation/platform/sso/azure) (formerly known as Azure AD), [JumpCloud](/documentation/platform/sso/jumpcloud), [Google](/documentation/platform/sso/google-saml), and more. +To interact with various resources in Infisical, users are able to utilize a number of authentication methods: + +- **Email & Password**: the most common authentication method that is used for authentication into Web Dashboard and Infisical CLI. It is recommended to utilize [Multi-factor Authentication](/documentation/platform/mfa) in addition to it. +- **SSO**: Infisical natively integrates with a number of SSO identity providers like [Google](/documentation/platform/sso/google), [GitHub](/documentation/platform/sso/github), and [GitLab](/documentation/platform/sso/gitlab). +- **SAML SSO**: It is also possible to set up SAML SSO integration with identity providers like [Okta](/documentation/platform/sso/okta), [Microsoft Entra ID](/documentation/platform/sso/azure) (formerly known as Azure AD), [JumpCloud](/documentation/platform/sso/jumpcloud), [Google](/documentation/platform/sso/google-saml), and more. - **LDAP**: For organizations with more advanced needs, Infisical also provides user authentication with [LDAP](/documentation/platform/ldap/overview) that includes a number of LDAP providers. diff --git a/docs/documentation/platform/organization.mdx b/docs/documentation/platform/organization.mdx index 6c3b218ab..aea842d3f 100644 --- a/docs/documentation/platform/organization.mdx +++ b/docs/documentation/platform/organization.mdx @@ -3,74 +3,94 @@ title: "Organizations" description: "Learn more and understand the concept of Infisical organizations." --- -An Infisical organization is a set of [projects](./project) that use the same billing. Organizations allow one or more users to control billing and project permissions for all of the projects belonging to the organization. Each project belongs to an organization. +Infisical is structured around organizations and [projects](/documentation/platform/project). + +## Organizations + +An organization represents a company or high-level entity (e.g. Acme Corp) and acts as the root scope for managing members and machine identities, projects, usage and billing, global integrations and configuration (such as single sign-on, provisioning, etc), and more. + +Within an organization, you can create any number of projects—each tied to a specific product type such as Secrets Management or PKI that determines the functionality available. + +![organization](/images/platform/organization/organization.png) ## Projects -The **Projects** page is where you can view the projects that you have access to within your organization -as well as create a new project. +The _Projects_ tab shows a list of projects that you have access to. -![organization](../../images/platform/organization/organization-projects.png) +If you're an organization admin, you also have the option to view _All Projects_—a complete view of every project within the organization, including those you are not currently a member of— and gain access to any project. -## Settings +Admins can gain access to any project in the organization by opening the options menu (⋮) next to a project and selecting Access. This will add you to the project as an admin and allow full visibility and control. -The **Settings** page lets you manage information about your organization including: +![organization projects](/images/platform/organization/organization-projects.png) -- **Name**: The name of your organization. -- **Slug**: The slug of your organization. -- **Default Organization Member Role**: The role assigned to users when joining your organization unless otherwise specified. -- **Incident Contacts**: Emails that should be alerted if anything abnormal is detected within the organization. -- **Enabled Products**: Products which are enabled for your organization. This setting strictly affects the sidebar UI; disabling a product does not disable its API or routes. +## Roles and Access Control -![organization settings general](../../images/platform/organization/organization-settings-general.png) +The _Access Control_ tab lets you view and manage roles and permissions for users, machine identities, and groups across your organization. -- Security and Authentication: A set of setting to enforce or manage [SAML](/documentation/platform/sso/overview), [OIDC](/documentation/platform/sso/overview), [SCIM](/documentation/platform/scim/overview), [LDAP](/documentation/platform/ldap/overview), and other authentication configurations. +Users are invited to an organization and assigned organization-level roles such as `Admin` or `Member`. You can also define [custom roles](/documentation/platform/access-controls/role-based-access-controls#creating-custom-roles) at the organization level to fit your permission model. -![organization settings auth](../../images/platform/organization/organization-settings-auth.png) +![organization users](/images/platform/organization/organization-users.png) - - You can adjust the maximum time a user token will remain valid for your organization. After this period, users will be required to re-authenticate. This helps improve security by enforcing regular sign-ins. - +Infisical supports [user identities](/documentation/platform/identities/user-identities) (representing people) and [machine identities](/documentation/platform/identities/machine-identities) (representing services, CI/CD pipelines, or agents). The same roles and permissions can be applied to either type of identity. -## Access Control +To manage access at scale, Infisical also supports [user groups](/documentation/platform/groups) — roles assigned to a group apply to all of its members automatically. -The **Access Control** page is where you can manage identities (both people and machines) that are part of your organization. -You can add or remove additional members as well as modify their permissions. +Note that Infisical distinguishes between organization-level and project-level access control: -![organization members](../../images/platform/organization/organization-members.png) -![organization identities](../../images/platform/organization/organization-machine-identities.png) +- [Organization-level access control](/documentation/platform/access-controls/role-based-access-controls#organization-level-access-controls): Roles and permissions governing access to organization-level resources and controls such as billing, member management, and identity provider configuration. +- [Project-level access control](/documentation/platform/access-controls/role-based-access-controls#project-level-access-controls): Roles and permissions governing access to resources and workflows within a specific project (e.g., secrets, certificates, SSH hosts). -In the **Organization Roles** tab, you can edit current or create new custom roles for members within the organization. +![organization roles](/images/platform/organization/organization-roles.png) + +To learn more about how permissions work in detail, refer to the [access control documentation](/documentation/platform/access-controls/overview). - Note that Role-Based Access Management (RBAC) is partly a paid feature. - - Infisical provides immutable roles like `admin`, `member`, etc. - at the organization and project level for free. +Infisical provides immutable roles such as `admin` and `member` for free. If you're using Infisical Cloud, the ability to create custom roles is available under the **Pro Tier**. + If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. -![organization roles](../../images/platform/organization/organization-members-roles.png) - -As you can see next, Infisical supports granular permissions that you can tailor to each role. -If you need certain members to only be able to access billing details, for example, then you can -assign them that permission only. - -![organization role permissions](../../images/platform/organization/organization-members-roles-add-perm.png) - ## Usage & Billing -The **Usage & Billing** page applies only to [Infisical Cloud](https://app.infisical.com) and is where you can -manage your plan and billing information. +The _Usage & Billing_ tab provides an overview of your organization's billing information and platform usage. -This includes the following items: +Infisical calculates usage at the organization level—aggregating activity across all projects and product types (e.g., Secrets Management, SSH, PKI). From this tab, you can track usage, view billing details, and manage your Infisical Cloud subscription. -- Current plan: The current plan information such as what tier your organization is on and what features/limits apply to this tier. -- Licenses: The license keys for self-hosted instances of Infisical (if applicable). -- Receipts: The receipts of monthly/annual invoices. -- Billing: The billing details of your organization including payment methods on file, tax IDs (if applicable), etc. +![organization billing](/images/platform/organization/organization-billing.png) -![organization usage and billing](../../images/platform/organization/organization-usage-billing.png) +## Audit Logs + +Infisical provides a unified view of [audit logs](/documentation/platform/audit-logs) at the organization level. All platform activity—including secret access, certificate issuance, platform logins across the organization —is recorded and searchable in a central log view. + +Audit logs are also viewable at the project level, where they are scoped to show only events relevant to that specific project. This allows project administrators to monitor activity and investigate changes without requiring organization-wide access. + +## App Connections + +Infisical supports [app connections](/integrations/app-connections/overview) — integrations configured at the organization level with third-party platforms such as AWS, GCP, GitHub, and many others. + +Once configured, these connections can be reused across multiple projects as part of any feature that requires third-party integrations—such as [secret syncing](/integrations/secret-syncs/overview) or [dynamic credential generation](/documentation/platform/dynamic-secrets/overview). + +![organization app connections](/images/platform/organization/organization-app-connections.png) + +To learn more, refer to the [app connections documentation](/integrations/app-connections/overview). + +## Organization Settings + +The _Organization Settings_ tab lets you configure global behavior and security controls for the organization. + +Key configuration areas include: + +- General: Manage the organization’s name, slug, and default role for newly invited members. +- Single Sign-On (SSO): Enable [SAML](/documentation/platform/sso/overview), [LDAP](/documentation/platform/ldap/overview), or [OIDC-based](/documentation/platform/sso/general-oidc/overview) authentication for user login. +- Provisioning: Enable [SCIM](/documentation/platform/scim/overview) to automatically provision and deprovision users and groups from an identity provider. +- Security Policies: Enforce MFA and configure session duration limits. +- Encryption: Integrate with external KMS systems or bring your own encryption keys (BYOK). +- [Audit Log Streaming](/documentation/platform/audit-log-streams/audit-log-streams): Forward audit events to third-party logging tools like SIEMs or cloud storage. +- Workflow Integrations: Trigger [Slack](/documentation/platform/workflow-integrations/slack-integration) or [Microsoft Teams](/documentation/platform/workflow-integrations/microsoft-teams-integration) notifications for events like access requests. +- [Project Templates](/documentation/platform/project-templates): Define default environments, roles, and settings to standardize project creation. +- KMIP (Enterprise): Connect to KMIP-compatible HSMs for hardware-backed key storage and operations. + +![organization settings](/images/platform/organization/organization-settings.png) diff --git a/docs/documentation/platform/pki/acme-ca.mdx b/docs/documentation/platform/pki/acme-ca.mdx index 495d20917..8b1fa10db 100644 --- a/docs/documentation/platform/pki/acme-ca.mdx +++ b/docs/documentation/platform/pki/acme-ca.mdx @@ -14,7 +14,7 @@ ACME is a protocol that automates the process of certificate issuance and renewa ```mermaid graph TD A[ACME CA Provider
e.g., Let's Encrypt] <-->|ACME v2 Protocol| B[Infisical] - B -->|Creates TXT Records
via Route53| C[DNS Validation] + B -->|Creates TXT Records
via Route53/Cloudflare| C[DNS Validation] B -->|Manages Certificates| D[Subscribers] ``` @@ -28,8 +28,8 @@ We recommend reading about [ACME protocol](https://tools.ietf.org/html/rfc8555) A typical workflow for using Infisical with ACME Certificate Authorities consists of the following steps: -1. Setting up AWS Route53 credentials with appropriate DNS permissions. -2. Creating an AWS connection in Infisical to store the Route53 credentials. +1. Setting up AWS Route53 or Cloudflare credentials with appropriate DNS permissions. +2. Creating an AWS/Cloudflare connection in Infisical to store the credentials. 3. Registering an ACME Certificate Authority (like Let's Encrypt) with Infisical. 4. Creating subscribers that use the ACME CA as their issuing authority. 5. Managing certificate lifecycle events such as issuance, renewal, and revocation through Infisical. @@ -55,59 +55,75 @@ This automated process eliminates the need for manual intervention in domain val In the following steps, we explore how to set up ACME Certificate Authority integration with Infisical using Let's Encrypt as an example. - - Before proceeding with the ACME CA registration, you need to set up an AWS connection with the appropriate permissions for DNS validation: + + Before proceeding with the ACME CA registration, you need to set up an App Connection with the appropriate permissions for DNS validation: - 1. Navigate to your Organization Settings > App Connections and create a new AWS connection. + + + 1. Navigate to your Organization Settings > App Connections and create a new AWS connection. - 2. Ensure your AWS connection has the following minimum permissions for Route53 DNS validation: + 2. Ensure your AWS connection has the following minimum permissions for Route53 DNS validation: - ```json - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": "route53:GetChange", - "Resource": "arn:aws:route53:::change/*" - }, - { - "Effect": "Allow", - "Action": "route53:ListHostedZonesByName", - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "route53:ListResourceRecordSets" - ], - "Resource": [ - "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" - ] - }, - { - "Effect": "Allow", - "Action": [ - "route53:ChangeResourceRecordSets" - ], - "Resource": [ - "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" - ], - "Condition": { - "ForAllValues:StringEquals": { - "route53:ChangeResourceRecordSetsRecordTypes": [ - "TXT" + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "route53:GetChange", + "Resource": "arn:aws:route53:::change/*" + }, + { + "Effect": "Allow", + "Action": "route53:ListHostedZonesByName", + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "route53:ListResourceRecordSets" + ], + "Resource": [ + "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" + ] + }, + { + "Effect": "Allow", + "Action": [ + "route53:ChangeResourceRecordSets" + ], + "Resource": [ + "arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID" + ], + "Condition": { + "ForAllValues:StringEquals": { + "route53:ChangeResourceRecordSetsRecordTypes": [ + "TXT" + ] + } + } + } ] } - } - } - ] - } - ``` + ``` - Replace `YOUR_HOSTED_ZONE_ID` with your actual Route53 hosted zone ID. + Replace `YOUR_HOSTED_ZONE_ID` with your actual Route53 hosted zone ID. - For detailed instructions on setting up an AWS connection, see the [AWS Connection](/integrations/app-connections/aws) documentation. + For detailed instructions on setting up an AWS connection, see the [AWS Connection](/integrations/app-connections/aws) documentation. + + + 1. Navigate to your Organization Settings > App Connections and create a new Cloudflare connection. + + 2. Ensure your Cloudflare token has the following minimum permissions for DNS validation: + + ``` + Account:Account Settings:Read + Zone:DNS:Edit + ``` + + For detailed instructions on setting up a Cloudflare connection, see the [Cloudflare Connection](/integrations/app-connections/cloudflare) documentation. + + @@ -127,7 +143,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte - **Type**: Select "ACME" as the External CA type. - **Name**: Enter a name for the ACME CA (e.g., "lets-encrypt-production"). - **DNS App Connection**: Select from available DNS app connections or configure a new one. This connection provides Infisical with the credentials needed to create and remove DNS records for ACME validation. - - **Hosted Zone ID**: Enter your Route53 hosted zone ID (e.g., Z04044I124N1GOOMCOYX1) for the domain(s) you'll be requesting certificates for. + - **Zone ID**: Enter the Zone ID for the domain(s) you'll be requesting certificates for. - **Directory URL**: Enter the ACME v2 directory URL for your chosen CA provider (e.g., `https://acme-v02.api.letsencrypt.org/directory` for Let's Encrypt). - **Account Email**: Email address to associate with your ACME account. This email will receive important notifications about your certificates. - **Enable Direct Issuance**: Toggle on to allow direct certificate issuance without requiring subscribers. @@ -140,7 +156,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte ![pki external ca list](/images/platform/pki/ca/external-ca/external-ca-list.png) From here, you can: - + - View the status of the ACME CA registration - Edit the configuration settings - Disable or re-enable the ACME CA @@ -152,9 +168,9 @@ In the following steps, we explore how to set up ACME Certificate Authority inte To register an ACME CA with Infisical using the API, make a request to the Create External CA endpoint: - + ### Sample request - + ```bash Request curl 'https://app.infisical.com/api/v1/pki/ca/acme' \ -H 'Authorization: Bearer ' \ @@ -180,9 +196,9 @@ In the following steps, we explore how to set up ACME Certificate Authority inte } }' ``` - + ### Sample response - + ```bash Response { "id": "c48b701e-a20c-4a9a-8119-68f54e5fbb05", @@ -226,7 +242,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte 1. Infisical generates a key pair for the certificate 2. Sends a Certificate Signing Request (CSR) to the ACME CA 3. Receives a DNS-01 challenge from the ACME provider - 4. Creates a TXT record in Route53 to satisfy the challenge + 4. Creates a TXT record in Route53/Cloudflare to satisfy the challenge 5. Notifies the ACME provider that the challenge is ready for validation 6. Once validated, the ACME provider issues the certificate 7. Infisical stores and manages the certificate for your subscriber @@ -235,7 +251,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte The issued certificate and private key are now available through Infisical and can be: - + - Downloaded directly from the Infisical UI - Retrieved via the Infisical API for programmatic access using the [latest certificate bundle endpoint](/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle) @@ -265,35 +281,35 @@ Let's Encrypt is a free, automated, and open Certificate Authority that provides - Currently, Infisical supports DNS-01 validation through AWS Route53. The DNS-01 challenge method is preferred for ACME integrations because it: - + Currently, Infisical supports DNS-01 validation through AWS Route53 or Cloudflare. The DNS-01 challenge method is preferred for ACME integrations because it: + - Works with wildcard certificates - Doesn't require your servers to be publicly accessible - Can be fully automated without manual intervention - + Support for additional DNS providers is planned for future releases. Yes! ACME CAs like Let's Encrypt support wildcard certificates (e.g., `*.example.com`) when using DNS-01 validation. Simply specify the wildcard domain in your subscriber configuration. - + Note that wildcard certificates still require DNS-01 validation - HTTP-01 validation cannot be used for wildcard certificates. Most ACME providers issue certificates with 90-day validity periods. This shorter validity period is designed to: - + - Encourage automation of certificate management - Reduce the impact of compromised certificates - Ensure systems stay up-to-date with certificate management practices - + When configured, Infisical automatically handles certificate renewal for subscribers. Yes! You can register multiple ACME CAs in the same project: - + - Different providers for different domains or use cases - Staging and production environments for the same provider - Backup providers for redundancy - + Each subscriber can be configured to use a specific ACME CA based on your requirements. - \ No newline at end of file + diff --git a/docs/documentation/platform/pki/external-ca.mdx b/docs/documentation/platform/pki/external-ca.mdx index 02285cac6..efe029149 100644 --- a/docs/documentation/platform/pki/external-ca.mdx +++ b/docs/documentation/platform/pki/external-ca.mdx @@ -13,11 +13,11 @@ In addition to creating a Private CA hierarchy, Infisical allows you to integrat ```mermaid graph TD B[Infisical] -->|Manages Certificates| D[Subscribers] - + A1[Public CAs
Let's Encrypt, ZeroSSL] -->|ACME Protocol| B A2[Enterprise CAs
Vault PKI, Step CA] -->|ACME Protocol| B A3[Cloud CAs
ACME-compatible services] -->|ACME Protocol| B - + A4[Future: Enterprise CAs] -.->|EST/SCEP Protocols| B A5[Future: Cloud CAs] -.->|REST APIs| B ``` @@ -54,7 +54,7 @@ ACME (Automatic Certificate Management Environment) is a widely adopted protocol **Public Certificate Authorities:** - Let's Encrypt - Free, automated SSL/TLS certificates -- ZeroSSL - Free and premium SSL certificates +- ZeroSSL - Free and premium SSL certificates - Buypass - Norwegian CA with free ACME certificates **Enterprise Certificate Authorities:** @@ -73,7 +73,7 @@ External CA integration is ideal for various scenarios: ### Public-Facing Services Use publicly trusted CAs for websites and services that need browser compatibility: - Web applications and APIs -- Load balancers and CDNs +- Load balancers and CDNs - Public-facing microservices ### Compliance Requirements @@ -137,18 +137,18 @@ Get started with External CA integration: Currently, Infisical supports any Certificate Authority that implements the ACME protocol, including: - + - **Public CAs**: Let's Encrypt, ZeroSSL, Buypass - - **Enterprise CAs**: HashiCorp Vault PKI, Step CA + - **Enterprise CAs**: HashiCorp Vault PKI, Step CA - **Cloud CAs**: ACME-compatible managed services - - Integration uses DNS-01 validation through Route53. Learn more about [supported DNS validation methods](/documentation/platform/pki/acme-ca#what-dns-validation-methods-are-supported). - + + Integration uses DNS-01 validation through Route53 or Cloudflare. Learn more about [supported DNS validation methods](/documentation/platform/pki/acme-ca#what-dns-validation-methods-are-supported). + Support for additional integration protocols (EST, SCEP, direct APIs) is planned for future releases. Yes. You can have both Private CAs (root and intermediate) and External CAs in the same project, allowing you flexibility in how you issue certificates for different use cases. This hybrid approach enables you to: - + - Use Private CAs for internal services and applications - Use External CAs for public-facing services - Apply consistent management practices across all certificate types @@ -156,37 +156,37 @@ Get started with External CA integration: The types of certificates you can issue depend on the External CA provider and type: - + - **Public CAs**: Typically support Domain Validation (DV) certificates, with some offering Organization Validation (OV) - **Enterprise CAs**: Support internal certificates, device certificates, and custom certificate types - **Cloud CAs**: Support various certificate types depending on the service - + Certificate capabilities vary by provider and integration method. Certificate reissuance is handled automatically by Infisical based on the CA type: - + - **Public CAs**: Automatic reissuance using ACME protocol with the same certificate extensions before expiration - **Other CA types**: Certificate management methods depend on the specific integration (when available) - + All certificate lifecycle events are tracked and managed through Infisical's unified interface, ensuring continuous certificate validity. Authentication methods vary by CA type: - + - **Public CAs**: ACME account registration with email and account keys - **Enterprise CAs**: Client certificates, username/password, or domain authentication (when available) - **Cloud CAs**: API keys, OAuth tokens, or service account authentication (when available) - + Infisical securely stores and manages all authentication credentials. Yes, Infisical provides policy enforcement capabilities: - + - Certificate template constraints - Monitoring and alerting policies - Access controls for certificate operations - + These policies ensure consistent governance across both internal and external certificate sources. - \ No newline at end of file + diff --git a/docs/documentation/platform/pki/overview.mdx b/docs/documentation/platform/pki/overview.mdx index 8ee9b113d..b2351813c 100644 --- a/docs/documentation/platform/pki/overview.mdx +++ b/docs/documentation/platform/pki/overview.mdx @@ -1,13 +1,13 @@ --- -title: "Internal PKI" +title: "Infisical PKI" sidebarTitle: "Overview" description: "Learn how to create a Private CA hierarchy and issue X.509 certificates." --- -Infisical can be used to create a Private Certificate Authority (CA) hierarchy and issue X.509 certificates for internal use. This allows you to manage your own PKI infrastructure and issue digital certificates for subscribers such as services, applications, and devices. +Infisical can be used to create and manage Certificate Authorities (CAs) and issue X.509 certificates. This allows you to manage PKI infrastructure and issue digital certificates for subscribers such as services, applications, and devices. Infisical's PKI offering is split into three components: -- [Certificate Authorities](/documentation/platform/pki/private-ca): Create and manage private CAs, including root and intermediate CAs. +- [Certificate Authorities](/documentation/platform/pki/private-ca): Create and manage CAs, including root and intermediate CAs. - [Subscribers](/documentation/platform/pki/subscribers): Define and manage entities that will request X.509 certificates from CAs. This module provides a centralized view of all subscribers, enabling you to issue certificates and monitor their status. - [Certificates](/documentation/platform/pki/certificates): Track and monitor issued X.509 certificates, maintaining a comprehensive inventory of all active and expired certificates. diff --git a/docs/documentation/platform/project.mdx b/docs/documentation/platform/project.mdx index dcf447169..7d0df2e22 100644 --- a/docs/documentation/platform/project.mdx +++ b/docs/documentation/platform/project.mdx @@ -1,116 +1,51 @@ --- -title: "Projects" +title: "Overview" description: "Learn more and understand the concept of Infisical projects." --- -A project in Infisical belongs to an [organization](./organization) and contains a number of environments, folders, and secrets. -Only users and machine identities who belong to a project can access resources inside of it according to predefined permissions. +## Projects -Infisical also allows users to request project access. Refer to the [project access request section](./access-controls/project-access-requests) +A project defines a specific scope of work for a given product line in Infisical. -## Project environments +Projects are created within an [organization](/documentation/platform/organization), and an organization can contain multiple projects across different product types. -For both visual and organizational structure, Infisical allows splitting up secrets into environments (e.g., development, staging, production). In project settings, such environments can be -customized depending on the intended use case. +## Project Types -![project secrets overview](../../images/platform/project/project-environments.png) +Infisical supports project types, each representing a different security product with its own dashboard, workflows, and capabilities. -## Secrets Overview +![project types](/images/platform/project/project-types.png) -The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](./folder) across environments. -This is useful for comparing secrets, identifying if anything is missing, and making quick changes. +The supported project types are: -![project secrets overview](../../images/platform/project/project-secrets-overview-open.png) +- [Secrets Management](/documentation/platform/secrets-mgmt/overview): Securely store, access, and distribute secrets across environments with fine-grained controls, automatic rotation, and audit logging. +- [Secrets Scanning](/documentation/platform/secret-scanning/overview): Detect hardcoded secrets in code, CI pipelines, and infrastructure—integrated with GitHub, GitLab, Bitbucket, and more. +- [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. +- [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. +- [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. -## Secrets Dashboard +## Roles and Access Control -The **Secrets Dashboard** page appears when you press to manage the secrets of a specific environment. +[Users](/documentation/platform/identities/user-identities) and [machine identities](/documentation/platform/identities/machine-identities) must be added to a project to access its resources. Each identity is assigned a [project-level role](/documentation/platform/access-controls/role-based-access-controls#project-level-access-controls) that defines what they can manage—such as secrets, certificates, or SSH access. These roles apply to both individuals and [user groups](/documentation/platform/groups), enabling scalable access across teams and environments. -![project dashboard](../../images/dashboard.png) +Project access is strictly scoped: only members of a project can view or manage its resources. If someone needs access but isn’t part of the project, they can submit an access request. -### Secrets +Each project in Infisical has its own [access control model](/documentation/platform/access-controls/role-based-access-controls#project-level-access-controls), distinct from [organization-level access control](/documentation/platform/access-controls/role-based-access-controls#organization-level-access-controls). While organization roles govern broader administrative access, project-level roles control what users, groups, and machine identities can do within the boundaries of a specific project—such as managing secrets, issuing certificates, or configuring SSH access. -To add a secret, press **Add Secret** button at the top of the dashboard. +Depending on the project type (e.g. Secrets Management, PKI, SSH), project-level access control supports advanced features like [temporary access](/documentation/platform/access-controls/temporary-access), [access requests](/documentation/platform/access-controls/access-requests), and [additional privileges](/documentation/platform/access-controls/additional-privileges). -![project add secret](../../images/platform/project/project-secrets-add.png) +![project roles](/images/platform/project/project-roles.png) -For a new project, it can be convenient to populate the dashboard by dropping a `.env` file into the provided pane as shown below: +To learn more about how permissions work in detail, refer to the [access control documentation](/documentation/platform/access-controls/overview). -![project drop env file](../../images/platform/project/project-secrets-drop-env.png) +## Audit Logs -To delete a secret, hover over it and press the **X** button that appears on the right side. +Infisical provides [audit logging](/documentation/platform/audit-logs) at the project level to help teams monitor activity and maintain accountability within a specific project. These logs capture all relevant events—such as secret access, certificate issuance, and SSH activity—that occur within the boundaries of that project. -![project delete secret](../../images/platform/project/project-secrets-delete.png) +Unlike the organization-level audit view, which aggregates logs across all projects in one centralized interface, the project-level audit view is scoped to a single project. This enables relevant project admins and contributors to review activity relevant to their work without having broader access to audit logs in other projects that they are not part of. -To delete multiple secrets at once, hover over and select the secrets you'd like to delete -and press the **Delete** button that appears at the top. +## Project Settings -![project delete secret batch](../../images/platform/project/project-secrets-delete-batch.png) +Each project has its own settings panel, with options that vary depending on the selected product type. These may include +setup and configuration for environments, tags, behaviors, encryption strategies, and other options. -### Search - -To search for specific secrets by their key name, you can use the search bar. - -![project search](../../images/platform/project/project-secrets-search.png) - -To assist you with finding secrets, you can also group them by similar prefixes and filter them by tags (if applicable). - -![project filter](../../images/platform/project/project-secrets-filter.png) - -### Hide/Un-hide - -To view/hide all secrets at once, toggle the hide or un-hide button. - -![project filter](../../images/platform/project/project-secrets-unhide.png) - -### Download as .env - -To download/export secrets back into a `.env` file, press the download button. - -![project download back env](../../images/platform/project/project-secrets-download-env.png) - -### Tags - -To better organize similar secrets, hover over them and label them with a tag. - -![project tag secret](../../images/platform/project/project-secrets-tag.png) - -### Comments - -To provide more context about a given secret, especially for your team, hover over it and press the comment button. - -![project comment secret](../../images/platform/project/project-secrets-comment.png) - -### Personal overrides - -Infisical employs the concept of **shared** and **personal** secrets to address the need -for common and custom secret values, or branching, amongst members of a team during software development. -To provide a helpful analogy: A shared value is to a `main` branch as a personal value is to a custom branch. - -Consider: - -- A team with users A, B, user C. -- A project with an environment containing a shared secret called D with the value E. - -Suppose user A overrides the value of secret D with the value F. - -Then: - -- If user A fetches the secret D back, they get the value F. -- If users B and C fetch the secret D back, they both get the value E. - - - Please keep in mind that secret reminders won't work with personal overrides. - - -![project override secret](../../images/platform/project/project-secrets-override.png) - -### Drawer - -To view the full details of each secret, you can hover over it and press on the ellipses button. - -![project secrets ellipses](../../images/platform/project/project-secrets-ellipses.png) - -This opens up a side-drawer: - -![project secrets drawer](../../images/platform/project/project-secrets-drawer.png) +Project settings are fully independent and reflect the capabilities of the associated product. diff --git a/docs/documentation/platform/secret-scanning/gitlab.mdx b/docs/documentation/platform/secret-scanning/gitlab.mdx new file mode 100644 index 000000000..52f52f505 --- /dev/null +++ b/docs/documentation/platform/secret-scanning/gitlab.mdx @@ -0,0 +1,103 @@ +--- +title: "GitLab Secret Scanning" +sidebarTitle: "GitLab" +description: "Learn how to configure secret scanning for GitLab." +--- + +## Prerequisites + +- Create a [GitLab Connection](/integrations/app-connections/gitlab) with Secret Scanning permissions + +## Create a GitLab Data Source in Infisical + + + + 1. Navigate to your Secret Scanning Project's Dashboard and click the **Add Data Source** button. + ![Secret Scanning Dashboard](/images/platform/secret-scanning/github/github-data-source-step-1.png) + + 2. Select the **GitLab** option. + ![Select GitLab](/images/platform/secret-scanning/gitlab/step-2.png) + + 3. Configure which workspace and repositories you would like to scan. Then click **Next**. + ![Data Source Configuration](/images/platform/secret-scanning/gitlab/step-3.png) + + - **GitLab Connection** - the connection that has access to the repositories you want to scan. + - **Scope** - the GitLab scope to scan secrets in. + - **Project** - scan an individual GitLab project. + - **Group** - scan one or more projects belonging to a GitLab group. + - **Scan Repositories** - when using **Group Scope**, select which repositories you would like to scan. + - **All Repositories** - Infisical will scan all repositories associated with your connection. + - **Select Repositories** - Infisical will scan the selected repositories. + - **Auto-Scan Enabled** - whether Infisical should automatically perform a scan when a push is made to configured repositories. + + 4. Give your data source a name and description (optional). Then click **Next**. + ![Data Source Details](/images/platform/secret-scanning/gitlab/step-4.png) + + - **Name** - the name of the data source. Must be slug-friendly. + - **Description** (optional) - a description of this data source. + + 5. Review your data source, then click **Create Data Source**. + ![Data Source Review](/images/platform/secret-scanning/gitlab/step-5.png) + + 6. Your **GitLab Data Source** is now available and will begin a full scan if **Auto-Scan** is enabled. + ![Data Source Created](/images/platform/secret-scanning/gitlab/step-6.png) + + 7. You can view repositories and scan results by clicking on your data source. + ![Data Source Page](/images/platform/secret-scanning/gitlab/step-7.png) + + 8. In addition, you can review any findings from the **Findings Page**. + ![Findings Page](/images/platform/secret-scanning/gitlab/step-8.png) + + + To create a GitLab Data Source, make an API request to the [Create GitLab Data Source](/api-reference/endpoints/secret-scanning/data-sources/gitlab/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-scanning/data-sources/gitlab \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-gitlab-source", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my gitlab data source", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "isAutoScanEnabled": true, + "config": { + "scope": "project", + "projectId": 123456789, + "projectName": "my-group/my-project" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "dataSource": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "externalId": "1234567890", + "name": "my-gitlab-source", + "description": "my gitlab data source", + "isAutoScanEnabled": true, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "type": "gitlab", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "gitlab", + "name": "my-gitlab-app", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "config": { + "scope": "project", + "projectId": 123456789, + "projectName": "my-group/my-project" + } + } + } + ``` + + diff --git a/docs/documentation/platform/secrets-mgmt/overview.mdx b/docs/documentation/platform/secrets-mgmt/overview.mdx new file mode 100644 index 000000000..e00eba358 --- /dev/null +++ b/docs/documentation/platform/secrets-mgmt/overview.mdx @@ -0,0 +1,17 @@ +--- +title: "Secrets Management" +sidebarTitle: "Overview" +description: "Learn how to securely store, access, and manage sensitive application secrets." +--- + +Infisical provides a flexible platform for managing application secrets — such as API keys, database credentials, application configuration, and more — across every stage of the development lifecycle from local development to production. + +It helps teams eliminate hardcoded secrets, enforce access controls, and adopt secure workflows like secret rotation, dynamic secrets, and secrets syncs to external platforms. + +Core capabilities include: + +- Secret Stores: Secure, versioned storage scoped by [project](/documentation/platform/secrets-mgmt/project), [environment](/documentation/platform/secrets-mgmt/project#project-environments), and [path](/documentation/platform/folder). +- [Access Control](/documentation/platform/access-controls/overview): Fine-grained, identity-aware permissions for users and machines +- Secret Delivery: Access secrets via [CLI](/cli/overview), [SDKs](/sdks/overview) (Go, Node.js, Python, etc.), [HTTP API](/api-reference/overview/introduction), [agents](/integrations/platforms/infisical-agent), [Kubernetes Operator](/integrations/platforms/kubernetes/overview), [External Secrets Operator (ESO)](https://external-secrets.io/latest/provider/infisical), and more. +- Lifecycle Automation: Automate [secret rotation](/documentation/platform/secret-rotation/overview), generate [dynamic secrets](/documentation/platform/dynamic-secrets/overview), and enforce [approval-based workflows](/documentation/platform/pr-workflows). +- [Secrets Syncs](/integrations/secret-syncs/overview): Push secrets to external services like [GitHub](/integrations/secret-syncs/github), [GitLab](/integrations/secret-syncs/gitlab), [AWS Secrets Manager](/integrations/secret-syncs/aws-secrets-manager), [Vercel](/integrations/secret-syncs/vercel), and more. diff --git a/docs/documentation/platform/secrets-mgmt/project.mdx b/docs/documentation/platform/secrets-mgmt/project.mdx new file mode 100644 index 000000000..3e7c7cbc7 --- /dev/null +++ b/docs/documentation/platform/secrets-mgmt/project.mdx @@ -0,0 +1,115 @@ +--- +title: "Projects" +description: "Learn more and understand the concept of Infisical projects." +--- + +A secrets management project in Infisical is a dedicated workspace for managing application secrets such as API keys, database credentials, configuration, etc. used by your applications. + +Secrets are organized into a clear hierarchy of environments, folders, and individual secrets, making it easy to manage values across different stages of your development lifecycle (e.g., development, staging, production). + +## Project environments + +For both visual and organizational structure, Infisical allows splitting up secrets into environments (e.g., development, staging, production). In project settings, such environments can be +customized depending on the intended use case. + +![project secrets overview](/images/platform/project/project-environments.png) + +## Secrets Overview + +The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](./folder) across environments. +This is useful for comparing secrets, identifying if anything is missing, and making quick changes. + +![project secrets overview](/images/platform/project/project-secrets-overview-open.png) + +## Secrets Dashboard + +The **Secrets Dashboard** page appears when you press to manage the secrets of a specific environment. + +![project dashboard](/images/dashboard.png) + +### Secrets + +To add a secret, press **Add Secret** button at the top of the dashboard. + +![project add secret](/images/platform/project/project-secrets-add.png) + +For a new project, it can be convenient to populate the dashboard by dropping a `.env` file into the provided pane as shown below: + +![project drop env file](/images/platform/project/project-secrets-drop-env.png) + +To delete a secret, hover over it and press the **X** button that appears on the right side. + +![project delete secret](/images/platform/project/project-secrets-delete.png) + +To delete multiple secrets at once, hover over and select the secrets you'd like to delete +and press the **Delete** button that appears at the top. + +![project delete secret batch](/images/platform/project/project-secrets-delete-batch.png) + +### Search + +To search for specific secrets by their key name, you can use the search bar. + +![project search](/images/platform/project/project-secrets-search.png) + +To assist you with finding secrets, you can also group them by similar prefixes and filter them by tags (if applicable). + +![project filter](/images/platform/project/project-secrets-filter.png) + +### Hide/Un-hide + +To view/hide all secrets at once, toggle the hide or un-hide button. + +![project filter](/images/platform/project/project-secrets-unhide.png) + +### Download as .env + +To download/export secrets back into a `.env` file, press the download button. + +![project download back env](/images/platform/project/project-secrets-download-env.png) + +### Tags + +To better organize similar secrets, hover over them and label them with a tag. + +![project tag secret](/images/platform/project/project-secrets-tag.png) + +### Comments + +To provide more context about a given secret, especially for your team, hover over it and press the comment button. + +![project comment secret](/images/platform/project/project-secrets-comment.png) + +### Personal overrides + +Infisical employs the concept of **shared** and **personal** secrets to address the need +for common and custom secret values, or branching, amongst members of a team during software development. +To provide a helpful analogy: A shared value is to a `main` branch as a personal value is to a custom branch. + +Consider: + +- A team with users A, B, user C. +- A project with an environment containing a shared secret called D with the value E. + +Suppose user A overrides the value of secret D with the value F. + +Then: + +- If user A fetches the secret D back, they get the value F. +- If users B and C fetch the secret D back, they both get the value E. + + + Please keep in mind that secret reminders won't work with personal overrides. + + +![project override secret](/images/platform/project/project-secrets-override.png) + +### Drawer + +To view the full details of each secret, you can hover over it and press on the ellipses button. + +![project secrets ellipses](/images/platform/project/project-secrets-ellipses.png) + +This opens up a side-drawer: + +![project secrets drawer](/images/platform/project/project-secrets-drawer.png) diff --git a/docs/documentation/platform/ssh/host-groups.mdx b/docs/documentation/platform/ssh/host-groups.mdx index 877291183..373141e05 100644 --- a/docs/documentation/platform/ssh/host-groups.mdx +++ b/docs/documentation/platform/ssh/host-groups.mdx @@ -1,5 +1,5 @@ --- -title: "Infisical SSH" +title: "Host Groups" sidebarTitle: "Host Groups" description: "Learn how to organize SSH hosts into groups and manage access policies at scale." --- diff --git a/docs/documentation/platform/ssh/overview.mdx b/docs/documentation/platform/ssh/overview.mdx index a252e1f71..d9606214d 100644 --- a/docs/documentation/platform/ssh/overview.mdx +++ b/docs/documentation/platform/ssh/overview.mdx @@ -1,5 +1,5 @@ --- -title: "Infisical SSH" +title: "Overview" sidebarTitle: "Overview" description: "Learn how to securely provision user SSH access to your infrastructure using SSH certificates." --- diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 000000000..a76dd0339 Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/favicon.png b/docs/favicon.png deleted file mode 100644 index 2a2ec8d1c..000000000 Binary files a/docs/favicon.png and /dev/null differ diff --git a/docs/images/app-connections/azure/app-configuration/create-client-secrets-method.png b/docs/images/app-connections/azure/app-configuration/create-client-secrets-method.png new file mode 100644 index 000000000..063fa1f90 Binary files /dev/null and b/docs/images/app-connections/azure/app-configuration/create-client-secrets-method.png differ diff --git a/docs/images/app-connections/azure/devops/create-client-secrets-method.png b/docs/images/app-connections/azure/devops/create-client-secrets-method.png new file mode 100644 index 000000000..b0563dcb9 Binary files /dev/null and b/docs/images/app-connections/azure/devops/create-client-secrets-method.png differ diff --git a/docs/images/app-connections/azure/key-vault/create-client-secrets-method.png b/docs/images/app-connections/azure/key-vault/create-client-secrets-method.png new file mode 100644 index 000000000..b857c99fb Binary files /dev/null and b/docs/images/app-connections/azure/key-vault/create-client-secrets-method.png differ diff --git a/docs/images/app-connections/cloudflare/cloudflare-dns-configure-permissions.png b/docs/images/app-connections/cloudflare/cloudflare-dns-configure-permissions.png new file mode 100644 index 000000000..d0ba4e68d Binary files /dev/null and b/docs/images/app-connections/cloudflare/cloudflare-dns-configure-permissions.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-create-application-bottom-secret-scanning.png b/docs/images/app-connections/gitlab/gitlab-create-application-bottom-secret-scanning.png new file mode 100644 index 000000000..3991af87b Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-create-application-bottom-secret-scanning.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-create-application-bottom.png b/docs/images/app-connections/gitlab/gitlab-create-application-bottom-secret-sync.png similarity index 100% rename from docs/images/app-connections/gitlab/gitlab-create-application-bottom.png rename to docs/images/app-connections/gitlab/gitlab-create-application-bottom-secret-sync.png diff --git a/docs/images/app-connections/gitlab/gitlab-create-application-top-secret-scanning.png b/docs/images/app-connections/gitlab/gitlab-create-application-top-secret-scanning.png new file mode 100644 index 000000000..49ff52e8d Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-create-application-top-secret-scanning.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-create-application-top.png b/docs/images/app-connections/gitlab/gitlab-create-application-top-secret-sync.png similarity index 100% rename from docs/images/app-connections/gitlab/gitlab-create-application-top.png rename to docs/images/app-connections/gitlab/gitlab-create-application-top-secret-sync.png diff --git a/docs/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-scanning.png b/docs/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-scanning.png new file mode 100644 index 000000000..519bc2fde Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-scanning.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-personal-access-token-form.png b/docs/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-sync.png similarity index 100% rename from docs/images/app-connections/gitlab/gitlab-personal-access-token-form.png rename to docs/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-sync.png diff --git a/docs/images/app-connections/gitlab/gitlab-project-access-token-form-secret-scanning.png b/docs/images/app-connections/gitlab/gitlab-project-access-token-form-secret-scanning.png new file mode 100644 index 000000000..c440d89c7 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-project-access-token-form-secret-scanning.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-project-access-token-form.png b/docs/images/app-connections/gitlab/gitlab-project-access-token-form-secret-sync.png similarity index 100% rename from docs/images/app-connections/gitlab/gitlab-project-access-token-form.png rename to docs/images/app-connections/gitlab/gitlab-project-access-token-form-secret-sync.png diff --git a/docs/images/app-connections/netlify/app-connection-create-api-key.png b/docs/images/app-connections/netlify/app-connection-create-api-key.png new file mode 100644 index 000000000..b7832db9e Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-create-api-key.png differ diff --git a/docs/images/app-connections/netlify/app-connection-create-form.png b/docs/images/app-connections/netlify/app-connection-create-form.png new file mode 100644 index 000000000..9435e3f94 Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-create-form.png differ diff --git a/docs/images/app-connections/netlify/app-connection-dashboard.png b/docs/images/app-connections/netlify/app-connection-dashboard.png new file mode 100644 index 000000000..aca7ccdd8 Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-dashboard.png differ diff --git a/docs/images/app-connections/netlify/app-connection-form.png b/docs/images/app-connections/netlify/app-connection-form.png new file mode 100644 index 000000000..13ebbdd42 Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-form.png differ diff --git a/docs/images/app-connections/netlify/app-connection-generated.png b/docs/images/app-connections/netlify/app-connection-generated.png new file mode 100644 index 000000000..67433626a Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-generated.png differ diff --git a/docs/images/app-connections/netlify/app-connection-key-generated.png b/docs/images/app-connections/netlify/app-connection-key-generated.png new file mode 100644 index 000000000..9c3aaf78b Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-key-generated.png differ diff --git a/docs/images/app-connections/netlify/app-connection-option.png b/docs/images/app-connections/netlify/app-connection-option.png new file mode 100644 index 000000000..5e9782a8c Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-option.png differ diff --git a/docs/images/app-connections/netlify/app-connection-profile.png b/docs/images/app-connections/netlify/app-connection-profile.png new file mode 100644 index 000000000..eeda6a5a8 Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-profile.png differ diff --git a/docs/images/platform/identities/auth-templates/attach-template-form.png b/docs/images/platform/identities/auth-templates/attach-template-form.png new file mode 100644 index 000000000..7ac01d797 Binary files /dev/null and b/docs/images/platform/identities/auth-templates/attach-template-form.png differ diff --git a/docs/images/platform/identities/auth-templates/create-template-button.png b/docs/images/platform/identities/auth-templates/create-template-button.png new file mode 100644 index 000000000..859031ea3 Binary files /dev/null and b/docs/images/platform/identities/auth-templates/create-template-button.png differ diff --git a/docs/images/platform/identities/auth-templates/ldap-template.png b/docs/images/platform/identities/auth-templates/ldap-template.png new file mode 100644 index 000000000..04e9c5a1a Binary files /dev/null and b/docs/images/platform/identities/auth-templates/ldap-template.png differ diff --git a/docs/images/platform/identities/auth-templates/machine-identity-page.png b/docs/images/platform/identities/auth-templates/machine-identity-page.png new file mode 100644 index 000000000..4574afa3f Binary files /dev/null and b/docs/images/platform/identities/auth-templates/machine-identity-page.png differ diff --git a/docs/images/platform/identities/auth-templates/template-usages-modal.png b/docs/images/platform/identities/auth-templates/template-usages-modal.png new file mode 100644 index 000000000..7f53c7a45 Binary files /dev/null and b/docs/images/platform/identities/auth-templates/template-usages-modal.png differ diff --git a/docs/images/platform/identities/auth-templates/template-usages.png b/docs/images/platform/identities/auth-templates/template-usages.png new file mode 100644 index 000000000..67e71b53a Binary files /dev/null and b/docs/images/platform/identities/auth-templates/template-usages.png differ diff --git a/docs/images/platform/identities/auth-templates/templates-section.png b/docs/images/platform/identities/auth-templates/templates-section.png new file mode 100644 index 000000000..a26ebd820 Binary files /dev/null and b/docs/images/platform/identities/auth-templates/templates-section.png differ diff --git a/docs/images/platform/organization/organization-app-connections.png b/docs/images/platform/organization/organization-app-connections.png new file mode 100644 index 000000000..7da9221fd Binary files /dev/null and b/docs/images/platform/organization/organization-app-connections.png differ diff --git a/docs/images/platform/organization/organization-billing.png b/docs/images/platform/organization/organization-billing.png new file mode 100644 index 000000000..498812938 Binary files /dev/null and b/docs/images/platform/organization/organization-billing.png differ diff --git a/docs/images/platform/organization/organization-machine-identities.png b/docs/images/platform/organization/organization-machine-identities.png index 4400b9f62..85657e137 100644 Binary files a/docs/images/platform/organization/organization-machine-identities.png and b/docs/images/platform/organization/organization-machine-identities.png differ diff --git a/docs/images/platform/organization/organization-members-roles-add-perm.png b/docs/images/platform/organization/organization-members-roles-add-perm.png deleted file mode 100644 index 0e87e3e5f..000000000 Binary files a/docs/images/platform/organization/organization-members-roles-add-perm.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-members-roles.png b/docs/images/platform/organization/organization-members-roles.png deleted file mode 100644 index 08c2d1e90..000000000 Binary files a/docs/images/platform/organization/organization-members-roles.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-members.png b/docs/images/platform/organization/organization-members.png deleted file mode 100644 index a79d3bbe0..000000000 Binary files a/docs/images/platform/organization/organization-members.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-projects.png b/docs/images/platform/organization/organization-projects.png index 82c0aee57..b8a294632 100644 Binary files a/docs/images/platform/organization/organization-projects.png and b/docs/images/platform/organization/organization-projects.png differ diff --git a/docs/images/platform/organization/organization-roles.png b/docs/images/platform/organization/organization-roles.png new file mode 100644 index 000000000..04fd0043d Binary files /dev/null and b/docs/images/platform/organization/organization-roles.png differ diff --git a/docs/images/platform/organization/organization-settings-auth.png b/docs/images/platform/organization/organization-settings-auth.png deleted file mode 100644 index fd7a946e0..000000000 Binary files a/docs/images/platform/organization/organization-settings-auth.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-settings-general.png b/docs/images/platform/organization/organization-settings-general.png deleted file mode 100644 index 9467b6005..000000000 Binary files a/docs/images/platform/organization/organization-settings-general.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-settings.png b/docs/images/platform/organization/organization-settings.png new file mode 100644 index 000000000..873852b44 Binary files /dev/null and b/docs/images/platform/organization/organization-settings.png differ diff --git a/docs/images/platform/organization/organization-usage-billing.png b/docs/images/platform/organization/organization-usage-billing.png deleted file mode 100644 index 345a437f7..000000000 Binary files a/docs/images/platform/organization/organization-usage-billing.png and /dev/null differ diff --git a/docs/images/platform/organization/organization-users.png b/docs/images/platform/organization/organization-users.png new file mode 100644 index 000000000..489fd2346 Binary files /dev/null and b/docs/images/platform/organization/organization-users.png differ diff --git a/docs/images/platform/organization/organization.png b/docs/images/platform/organization/organization.png new file mode 100644 index 000000000..f72c53286 Binary files /dev/null and b/docs/images/platform/organization/organization.png differ diff --git a/docs/images/platform/project/project-roles.png b/docs/images/platform/project/project-roles.png new file mode 100644 index 000000000..c7ae3f7f3 Binary files /dev/null and b/docs/images/platform/project/project-roles.png differ diff --git a/docs/images/platform/project/project-types.png b/docs/images/platform/project/project-types.png new file mode 100644 index 000000000..8156094f9 Binary files /dev/null and b/docs/images/platform/project/project-types.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-2.png b/docs/images/platform/secret-scanning/gitlab/step-2.png new file mode 100644 index 000000000..416864f6b Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-2.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-3.png b/docs/images/platform/secret-scanning/gitlab/step-3.png new file mode 100644 index 000000000..cc37a64df Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-3.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-4.png b/docs/images/platform/secret-scanning/gitlab/step-4.png new file mode 100644 index 000000000..5e27dfd7e Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-4.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-5.png b/docs/images/platform/secret-scanning/gitlab/step-5.png new file mode 100644 index 000000000..696da80f1 Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-5.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-6.png b/docs/images/platform/secret-scanning/gitlab/step-6.png new file mode 100644 index 000000000..dc1daea17 Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-6.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-7.png b/docs/images/platform/secret-scanning/gitlab/step-7.png new file mode 100644 index 000000000..24df0f6ba Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-7.png differ diff --git a/docs/images/platform/secret-scanning/gitlab/step-8.png b/docs/images/platform/secret-scanning/gitlab/step-8.png new file mode 100644 index 000000000..019f81c1e Binary files /dev/null and b/docs/images/platform/secret-scanning/gitlab/step-8.png differ diff --git a/docs/images/secret-syncs/netlify/select-option.png b/docs/images/secret-syncs/netlify/select-option.png new file mode 100644 index 000000000..ee18232fc Binary files /dev/null and b/docs/images/secret-syncs/netlify/select-option.png differ diff --git a/docs/images/secret-syncs/netlify/sync-created.png b/docs/images/secret-syncs/netlify/sync-created.png new file mode 100644 index 000000000..dff4f2c98 Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-created.png differ diff --git a/docs/images/secret-syncs/netlify/sync-destination.png b/docs/images/secret-syncs/netlify/sync-destination.png new file mode 100644 index 000000000..055ca0f69 Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-destination.png differ diff --git a/docs/images/secret-syncs/netlify/sync-details.png b/docs/images/secret-syncs/netlify/sync-details.png new file mode 100644 index 000000000..82c7fac8b Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-details.png differ diff --git a/docs/images/secret-syncs/netlify/sync-options.png b/docs/images/secret-syncs/netlify/sync-options.png new file mode 100644 index 000000000..d91e843df Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-options.png differ diff --git a/docs/images/secret-syncs/netlify/sync-review.png b/docs/images/secret-syncs/netlify/sync-review.png new file mode 100644 index 000000000..3795526f8 Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-review.png differ diff --git a/docs/images/secret-syncs/netlify/sync-source.png b/docs/images/secret-syncs/netlify/sync-source.png new file mode 100644 index 000000000..9f01e3475 Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-source.png differ diff --git a/docs/integrations/app-connections/azure-app-configuration.mdx b/docs/integrations/app-connections/azure-app-configuration.mdx index 679839878..4efd19f30 100644 --- a/docs/integrations/app-connections/azure-app-configuration.mdx +++ b/docs/integrations/app-connections/azure-app-configuration.mdx @@ -3,7 +3,7 @@ title: "Azure App Configuration Connection" description: "Learn how to configure a Azure App Configuration Connection for Infisical." --- -Infisical currently only supports one method for connecting to Azure, which is OAuth. +Infisical currently only supports two methods for connecting to Azure, which are OAuth and Client Secrets. Using the Azure App Configuration connection on a self-hosted instance of Infisical requires configuring an application in Azure @@ -58,6 +58,26 @@ Infisical currently only supports one method for connecting to Azure, which is O
+ + To use client secret authentication, ensure your Azure Service Principal has the required permissions and is connected to the Azure App Configuration resources you want to use. + + **Prerequisites:** + - Set up Azure and have an existing App Configuration instance. + - The service principal must be connected to your target Azure App Configuration resource(s) + + + + + Configure the required API permissions for your App Registration to interact with Azure App Configuration: + + #### Azure App Configuration permissions + + Set the API permissions of your Azure service principal to include the following Azure App Configuration permissions: `KeyValue.Delete`, `KeyValue.Read`, and `KeyValue.Write`. + + ![Azure app config](../../images/integrations/azure-app-configuration/app-api-permissions.png) + + + ## Setup Azure Connection in Infisical @@ -66,25 +86,35 @@ Infisical currently only supports one method for connecting to Azure, which is O Navigate to the **App Connections** tab on the **Organization Settings** page. ![App Connections Tab](/images/app-connections/general/add-connection.png)
- - Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/app-configuration/select-connection.png) - - - You can optionally authenticate against a specific tenant by providing the Azure Tenant or Directory ID. + + Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/app-configuration/select-connection.png) + + + + + + You can optionally authenticate against a specific tenant by providing the Azure Tenant or Directory ID. - Now select the **OAuth** method and click **Connect to Azure**. + Now select the **OAuth** method and click **Connect to Azure**. - ![Connect via Azure OAUth](/images/app-connections/azure/app-configuration/create-oauth-method.png) + ![Connect via Azure OAUth](/images/app-connections/azure/app-configuration/create-oauth-method.png) + + + You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, + you will redirect you back to Infisical's App Connections page. ![Azure App Configuration + Authorization](/images/app-connections/azure/grant-access.png) + + + + + Fill in the **Tenant ID**, **Client ID** and **Client Secret** fields with the Directory (Tenant) ID, Application (Client) ID and Client Secret you obtained in the previous step. - - - - - You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, - you will redirect you back to Infisical's App Connections page. ![Azure App Configuration - Authorization](/images/app-connections/azure/grant-access.png) - - + ![Connect via Azure OAUth](/images/app-connections/azure/app-configuration/create-client-secrets-method.png) + + + + + Your **Azure App Configuration Connection** is now available for use. ![Assume Role AWS Connection](/images/app-connections/azure/app-configuration/oauth-connection.png)
diff --git a/docs/integrations/app-connections/azure-client-secrets.mdx b/docs/integrations/app-connections/azure-client-secrets.mdx index 1fb1c753f..37e7d49b0 100644 --- a/docs/integrations/app-connections/azure-client-secrets.mdx +++ b/docs/integrations/app-connections/azure-client-secrets.mdx @@ -3,7 +3,7 @@ title: "Azure Client Secrets Connection" description: "Learn how to configure an Azure Client Secrets Connection for Infisical." --- -Infisical currently only supports one method for connecting to Azure, which is OAuth. +Infisical currently only supports two methods for connecting to Azure, which are OAuth and Client Secrets. Using the Azure Client Secrets connection on a self-hosted instance of Infisical requires configuring an application in Azure diff --git a/docs/integrations/app-connections/azure-devops.mdx b/docs/integrations/app-connections/azure-devops.mdx index 6a9e71430..4744a35c6 100644 --- a/docs/integrations/app-connections/azure-devops.mdx +++ b/docs/integrations/app-connections/azure-devops.mdx @@ -3,7 +3,7 @@ title: "Azure DevOps Connection" description: "Learn how to configure an Azure DevOps Connection for Infisical." --- -Infisical currently supports two methods for connecting to Azure DevOps, which are OAuth and Azure DevOps Personal Access Token. +Infisical currently supports three methods for connecting to Azure DevOps, which are OAuth, Azure DevOps Personal Access Token and Client Secrets. Using the Azure DevOps OAuth connection on a self-hosted instance of Infisical requires configuring an application in Azure @@ -87,6 +87,32 @@ Infisical currently supports two methods for connecting to Azure DevOps, which a + + To use client secret authentication, ensure your Azure Service Principal has the required permissions and is connected to the Azure DevOps organization and projects you want to use. + + **Prerequisites:** + - Set up Azure and have an existing Azure DevOps organization. + - The service principal must be connected to your target Azure DevOps organization and project(s) + + + + + Configure the required API permissions for your App Registration to interact with Azure DevOps: + + #### Azure DevOps permissions + + Set the API permissions of your Azure service principal to include the following Azure DevOps permissions: + - Azure DevOps + - `user_impersonation` + - `vso.project_write` + - `vso.variablegroups_manage` + - `vso.variablegroups_write` + + ![Azure devops](/images/integrations/azure-devops/app-api-permissions.png) + + + + ## Setup Azure Connection in Infisical @@ -129,6 +155,17 @@ Infisical currently supports two methods for connecting to Azure DevOps, which a + + + + Fill in the **Tenant ID**, **Client ID**, **Client Secret** and **Organization Name** fields with the Directory (Tenant) ID, Application (Client) ID, Client Secret and the organization name you obtained in the previous step. + + You can find the **Organization Name** on https://dev.azure.com/ + + ![Connect via Azure OAUth](/images/app-connections/azure/devops/create-client-secrets-method.png) + + + diff --git a/docs/integrations/app-connections/azure-key-vault.mdx b/docs/integrations/app-connections/azure-key-vault.mdx index 22cdcf637..866a1de82 100644 --- a/docs/integrations/app-connections/azure-key-vault.mdx +++ b/docs/integrations/app-connections/azure-key-vault.mdx @@ -3,7 +3,7 @@ title: "Azure Key Vault Connection" description: "Learn how to configure a Azure Key Vault Connection for Infisical." --- -Infisical currently only supports one method for connecting to Azure, which is OAuth. +Infisical currently only supports two methods for connecting to Azure, which are OAuth and Client Secrets. Using the Azure Key Vault connection on a self-hosted instance of Infisical requires configuring an application in Azure @@ -58,6 +58,27 @@ Infisical currently only supports one method for connecting to Azure, which is O + + To use client secret authentication, ensure your Azure Service Principal has the required permissions and is connected to the Azure Key Vault instances you want to use. + + **Prerequisites:** + - Set up Azure and have an existing Key Vault instance. + - The service principal must be connected to your target Azure Key Vault instance(s) + + + + + Configure the required API permissions for your App Registration to interact with Azure Key Vault: + + #### Azure Key Vault permissions + + Set the API permissions of your Azure service principal to include `user_impersonation` for the Key Vault API. + ![Azure key vault](/images/app-connections/azure/keyvault-azure-permissions.png) + + + + + ## Setup Azure Connection in Infisical @@ -68,21 +89,37 @@ Infisical currently only supports one method for connecting to Azure, which is O Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/key-vault/select-connection.png) - - You can optionally authenticate against a specific tenant by providing the Azure Tenant or Directory ID. + + + + + + You can optionally authenticate against a specific tenant by providing the Azure Tenant or Directory ID. - Now select the **OAuth** method and click **Connect to Azure**. + Now select the **OAuth** method and click **Connect to Azure**. - ![Connect via Azure OAUth](/images/app-connections/azure/key-vault/create-oauth-method.png) + ![Connect via Azure OAUth](/images/app-connections/azure/key-vault/create-oauth-method.png) - + - - - You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, - you will redirect you back to Infisical's App Connections page. ![Azure Key Vault - Authorization](/images/app-connections/azure/grant-access.png) - + + + You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, + you will redirect you back to Infisical's App Connections page. ![Azure Key Vault + Authorization](/images/app-connections/azure/grant-access.png) + + + + + + + Fill in the **Tenant ID**, **Client ID**, **Client Secret** fields with the Directory (Tenant) ID, Application (Client) ID, Client Secret you obtained in the previous step. + ![Connect via Azure OAUth](/images/app-connections/azure/key-vault/create-client-secrets-method.png) + + + + + Your **Azure Key Vault Connection** is now available for use. ![Assume Role AWS Connection](/images/app-connections/azure/key-vault/oauth-connection.png) diff --git a/docs/integrations/app-connections/cloudflare.mdx b/docs/integrations/app-connections/cloudflare.mdx index 6d79d2567..241c737bc 100644 --- a/docs/integrations/app-connections/cloudflare.mdx +++ b/docs/integrations/app-connections/cloudflare.mdx @@ -50,6 +50,17 @@ Infisical supports connecting to Cloudflare using API tokens and Account ID for + + Use the following permissions to grant Infisical access to verify certificates using DNS TXT records with ACME: + + ![Configure Token](/images/app-connections/cloudflare/cloudflare-dns-configure-permissions.png) + + **Required Permissions:** + - **Account** - **Account Settings** - **Read** + - **Zone** - **DNS** - **Edit** + + Add these permissions to your API token and click **Continue to summary**, then **Create Token** to generate your API token. + diff --git a/docs/integrations/app-connections/gitlab.mdx b/docs/integrations/app-connections/gitlab.mdx index b81c0fbcd..60e9236ca 100644 --- a/docs/integrations/app-connections/gitlab.mdx +++ b/docs/integrations/app-connections/gitlab.mdx @@ -26,8 +26,22 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access Create the application. As part of the form, set the **Redirect URI** to `https://your-domain.com/organization/app-connections/gitlab/oauth/callback`. - ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-top.png) - ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-bottom.png) + Depending on your use case, add one or more of the following scopes to your application: + + + + For Secret Syncs, your application will require the `api` scope: + + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-top-secret-sync.png) + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-bottom-secret-sync.png) + + + For Secret Scanning, your application will require the `api` and `read_repository` scopes: + + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-top-secret-scanning.png) + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-bottom-secret-scanning.png) + + The domain you defined in the Redirect URI should be equivalent to the `SITE_URL` configured in your Infisical instance. @@ -96,16 +110,22 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access ![GitLab Personal Access Tokens](/images/app-connections/gitlab/gitlab-add-access-token.png) - - - For Secret Syncs, your token will require the ability to access the API: Fill in the token details: - **Token name**: A descriptive name for the token (e.g., "connection-token") - **Expiration date**: Set an appropriate expiration date - - **Select scopes**: Choose the **api** scope for full API access + - **Select scopes**: Depending on your use case, add one or more of the following scopes: - ![GitLab Personal Token Form](/images/app-connections/gitlab/gitlab-personal-access-token-form.png) + + + For Secret Syncs, your token will require the `api` scope: + + ![GitLab Personal Token Form](/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-sync.png) + + For Secret Scanning, your token will require the `api` and `read_repository` scopes: + + ![GitLab Personal Token Form](/images/app-connections/gitlab/gitlab-personal-access-token-form-secret-scanning.png) + @@ -134,17 +154,22 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access ![GitLab Project Access Tokens](/images/app-connections/gitlab/gitlab-project-access-token-list.png) - - - For Secret Syncs, your token will require the ability to access the API and be at least an **Owner**: - Fill in the token details: - - **Token name**: A descriptive name for the token - - **Expiration date**: Set an appropriate expiration date - - **Select role**: Choose **Owner** or higher role - - **Select scopes**: Choose the **api** scope for API access + Fill in the token details: + - **Token name**: A descriptive name for the token + - **Expiration date**: Set an appropriate expiration date + - **Select role and scopes**: Depending on your use case, add the required role and one or more of the following scopes: - ![GitLab Create Project Token](/images/app-connections/gitlab/gitlab-project-access-token-form.png) - + + + For Secret Syncs, your token will require the `api` scope and at least the **Owner** role: + + ![GitLab Create Project Token](/images/app-connections/gitlab/gitlab-project-access-token-form-secret-sync.png) + + + For Secret Scanning, your token will require the `api` and `read_repository` scopes and the **Maintainer** role: + + ![GitLab Create Project Token](/images/app-connections/gitlab/gitlab-project-access-token-form-secret-scanning.png) + diff --git a/docs/integrations/app-connections/netlify.mdx b/docs/integrations/app-connections/netlify.mdx new file mode 100644 index 000000000..cd4dfb1b5 --- /dev/null +++ b/docs/integrations/app-connections/netlify.mdx @@ -0,0 +1,102 @@ +--- +title: "Netlify Connection" +description: "Learn how to configure a Netlify Connection for Infisical." +--- + +Infisical supports the use of [Personal Access Tokens](https://docs.netlify.com/api/get-started/#get-access-tokens) to connect with Netlify. + + Netlify requires the token to have **full access** to enable secret management for your sites and services. + + +## Create a Netlify Personal Access Token + + + + ![Netlify User Settings](/images/app-connections/netlify/app-connection-profile.png) + + + ![Applications Tab](/images/app-connections/netlify/app-connection-create-api-key.png) + + + Provide a name for your token and generate it. + + ![Token Form](/images/app-connections/netlify/app-connection-create-form.png) + + + Make sure to copy the token now—you won’t be able to access it again. + + ![Token Generated](/images/app-connections/netlify/app-connection-key-generated.png) + + + +## Create a Netlify Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Netlify Connection** from the list of integrations. + + ![Select Netlify Connection](/images/app-connections/netlify/app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - The API Token from the previous step + + ![Netlify Connection Modal](/images/app-connections/netlify/app-connection-form.png) + + + After submitting the form, your **Netlify Connection** will be successfully created and ready to use with your Infisical projects. + + ![Netlify Connection Created](/images/app-connections/netlify/app-connection-generated.png) + + + + + + To create a Netlify Connection via API, send a request to the [Create Netlify Connection](/api-reference/endpoints/app-connections/netlify/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/netlify \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-netlify-connection", + "method": "access-token", + "credentials": { + "accessToken": "[ACCESS TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "name": "my-netlify-connection", + "description": null, + "version": 1, + "orgId": "abcdef12-3456-7890-abcd-ef1234567890", + "createdAt": "2025-07-19T10:15:00.000Z", + "updatedAt": "2025-07-19T10:15:00.000Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "d41d8cd98f00b204e9800998ecf8427e", + "app": "netlify", + "method": "access-token", + "credentials": {} + } + } + ``` + + diff --git a/docs/integrations/platforms/kubernetes-injector.mdx b/docs/integrations/platforms/kubernetes-injector.mdx index aacdcfbbb..7fea3dc92 100644 --- a/docs/integrations/platforms/kubernetes-injector.mdx +++ b/docs/integrations/platforms/kubernetes-injector.mdx @@ -105,44 +105,93 @@ The templates hold an array of templates that will be rendered and injected into ### Authentication -The Infisical Agent Injector only supports Machine Identity [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) authentication at the moment. +The Infisical Agent Injector supports Machine Identity [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) and [LDAP Auth](/documentation/platform/identities/ldap-auth) authentication. -To configure Kubernetes Auth, you need to set the `auth.type` field to `kubernetes` and set the `auth.config.identity-id` to the ID of the machine identity you wish to use for authentication. -```yaml -auth: - type: "kubernetes" - config: - identity-id: "" -``` + + -### Example ConfigMap -```yaml config-map.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: demo-config-map -data: - config.yaml: | - infisical: - address: "https://app.infisical.com" - auth: - type: "kubernetes" - config: - identity-id: "" - templates: - - destination-path: "/path/to/save/secrets/file.txt" - template-content: | - {{- with secret "" "dev" "/" }} - {{- range . }} - {{ .Key }}={{ .Value }} - {{- end }} - {{- end }} -``` + To configure Kubernetes Auth, you need to set the `auth.type` field to `kubernetes` and set the `auth.config.identity-id` to the ID of the machine identity you wish to use for authentication. + ```yaml + auth: + type: "kubernetes" + config: + identity-id: "" + ``` -```bash -kubectl apply -f config-map.yaml -``` + ### Example ConfigMap + ```yaml config-map.yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: demo-config-map + data: + config.yaml: | + infisical: + address: "https://app.infisical.com" + auth: + type: "kubernetes" + config: + identity-id: "" + templates: + - destination-path: "/path/to/save/secrets/file.txt" + template-content: | + {{- with secret "" "dev" "/" }} + {{- range . }} + {{ .Key }}={{ .Value }} + {{- end }} + {{- end }} + ``` + + ```bash + kubectl apply -f config-map.yaml + ``` + + + + To configure LDAP Auth, you need to set the `auth.type` field to `ldap-auth` and set the `auth.config.identity-id` to the ID of the machine identity you wish to use for authentication. Configure the `auth.config.username` and `auth.config.password` to the username and password of the LDAP user to authenticate with. + + ```yaml + auth: + type: "ldap-auth" + config: + identity-id: "" + username: "" + password: "" + ``` + + ### Example ConfigMap + ```yaml config-map.yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: demo-config-map + data: + config.yaml: | + infisical: + address: "https://app.infisical.com" + auth: + type: "ldap-auth" + config: + identity-id: "" + username: "" + password: "" + templates: + - destination-path: "/path/to/save/secrets/file.txt" + template-content: | + {{- with secret "" "dev" "/" }} + {{- range . }} + {{ .Key }}={{ .Value }} + {{- end }} + {{- end }} + ``` + + ```bash + kubectl apply -f config-map.yaml + ``` + + + To use the config map in your pod, you will need to add the `org.infisical.com/agent-config-map` annotation to your pod's deployment. The value of the annotation is the name of the config map you created above. ```yaml diff --git a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx index 5962e4c10..76dd24cf7 100644 --- a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx @@ -68,6 +68,11 @@ spec: serviceAccountKeyFilePath: gcpIdTokenAuth: identityId: + ldapAuth: + identityId: + credentialsRef: + secretName: # ldap-auth-credentials + secretNamespace: # default kubernetesAuth: identityId: serviceAccountRef: @@ -105,15 +110,15 @@ kind: Secret ### InfisicalDynamicSecret CRD properties - If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to - ` https://your-self-hosted-instace.com/api` + If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to + `https://your-self-hosted-instace.com/api` When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. - If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. + If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. To achieve this, use the following address for the hostAPI field: - + ``` bash http://..svc.cluster.local:4000/api ``` @@ -126,18 +131,19 @@ When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. The `leaseTTL` is a string-formatted duration that defines the time the lease should last for the dynamic secret. - The format of the field is `[duration][unit]` where `duration` is a number and `unit` is a string representing the unit of time. +The format of the field is `[duration][unit]` where `duration` is a number and `unit` is a string representing the unit of time. - The following units are supported: +The following units are supported: - - `s` for seconds (must be at least 5 seconds) - - `m` for minutes - - `h` for hours - - `d` for days +- `s` for seconds (must be at least 5 seconds) +- `m` for minutes +- `h` for hours +- `d` for days - - The lease duration at most be 1 day (24 hours). And the TTL must be less than the max TTL defined on the dynamic secret. - + + The lease duration at most be 1 day (24 hours). And the TTL must be less than the max TTL defined on the dynamic secret. + + @@ -212,7 +218,7 @@ spec: The `dynamicSecret` field is used to specify which dynamic secret to create leases for. The required fields are `secretName`, `projectId`, `secretsPath`, and `environmentSlug`. - + ```yaml spec: dynamicSecret: @@ -300,7 +306,6 @@ The available authentication methods are `universalAuth`, `kubernetesAuth`, `aws - `autoCreateServiceAccountToken`: If set to `true`, the operator will automatically create a short-lived service account token on-demand for the service account. Defaults to `false`. - `serviceAccountTokenAudiences`: Optionally specify audience for the service account token. This field is only relevant if you have set `autoCreateServiceAccountToken` to `true`. No audience is specified by default. - Example: ```yaml @@ -316,7 +321,40 @@ The available authentication methods are `universalAuth`, `kubernetesAuth`, `aws ``` + + The LDAP machine identity authentication method is used to authenticate with a configured LDAP directory. [Read more about LDAP Auth](/documentation/platform/identities/ldap-auth). + Valid fields: + - `identityId`: The identity ID of the machine identity you created. + - `credentialsRef`: The name and namespace of the Kubernetes secret that stores the LDAP credentials. + - `credentialsRef.secretName`: The name of the Kubernetes secret. + - `credentialsRef.secretNamespace`: The namespace of the Kubernetes secret. + + Example: + + ```yaml + # infisical-push-secret.yaml + spec: + ldapAuth: + identityId: + credentialsRef: + secretName: + secretNamespace: + ``` + + ```yaml + # machine-identity-credentials.yaml + apiVersion: v1 + kind: Secret + metadata: + name: ldap-auth-credentials + type: Opaque + stringData: + username: + password: + ``` + + The AWS IAM machine identity authentication method is used to authenticate with Infisical. [Read more about AWS IAM Auth](/documentation/platform/identities/aws-auth). @@ -391,7 +429,7 @@ The available authentication methods are `universalAuth`, `kubernetesAuth`, `aws This block defines the TLS settings to use for connecting to the Infisical instance. - + Fields: This block defines the reference to the CA certificate to use for connecting to the Infisical instance with SSL/TLS. @@ -444,7 +482,7 @@ metadata: name: nginx-deployment labels: app: nginx - annotations: + annotations: secrets.infisical.com/auto-reload: "true" # <- redeployment annotation spec: replicas: 1 @@ -467,7 +505,7 @@ spec: ``` - #### How it works - When the lease changes, the operator will check to see which deployments are using the operator-managed Kubernetes secret that received the update. + #### How it works + When the lease changes, the operator will check to see which deployments are using the operator-managed Kubernetes secret that received the update. Then, for each deployment that has this annotation present, a rolling update will be triggered. A redeployment won't happen if the lease is renewed, only if it's recreated. diff --git a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx index d87648bbf..d5a9c1ef4 100644 --- a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx @@ -5,9 +5,9 @@ description: "Learn how to use the InfisicalPushSecret CRD to push and manage se --- -## Overview +## Overview -The **InfisicalPushSecret** CRD allows you to create secrets in your Kubernetes cluster and push them to Infisical. +The **InfisicalPushSecret** CRD allows you to create secrets in your Kubernetes cluster and push them to Infisical. This CRD offers the following features: @@ -70,6 +70,11 @@ Before applying the InfisicalPushSecret CRD, you need to create a Kubernetes sec serviceAccountRef: name: namespace: + ldapAuth: + identityId: + credentialsRef: + secretName: # ldap-auth-credentials + secretNamespace: # default universalAuth: credentialsRef: secretName: # universal-auth-credentials @@ -104,15 +109,15 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y ## InfisicalPushSecret CRD properties - If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to - ` https://your-self-hosted-instace.com/api` + If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to + `https://your-self-hosted-instace.com/api` When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. - If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. + If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. To achieve this, use the following address for the hostAPI field: - + ``` bash http://..svc.cluster.local:4000/api ``` @@ -187,7 +192,7 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y The `destination` field is used to specify where you want to create the secrets in Infisical. The required fields are `projectId`, `environmentSlug`, and `secretsPath`. - + ```yaml spec: destination: @@ -212,7 +217,7 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y The `push` field is used to define what you want to push to Infisical. Currently the operator only supports pushing Kubernetes secrets to Infisical. An example of the `push` field is shown below. - + @@ -220,7 +225,7 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y - Example usage of the `push.secret` field: + Example usage of the `push.secret` field: ```yaml infisical-push-secret.yaml push: @@ -282,7 +287,7 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y spec: universalAuth: credentialsRef: - secretName: + secretName: secretNamespace: ``` @@ -324,7 +329,39 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y namespace: ``` + + The LDAP machine identity authentication method is used to authenticate with a configured LDAP directory. [Read more about LDAP Auth](/documentation/platform/identities/ldap-auth). + Valid fields: + - `identityId`: The identity ID of the machine identity you created. + - `credentialsRef`: The name and namespace of the Kubernetes secret that stores the LDAP credentials. + - `credentialsRef.secretName`: The name of the Kubernetes secret. + - `credentialsRef.secretNamespace`: The namespace of the Kubernetes secret. + + Example: + + ```yaml + # infisical-push-secret.yaml + spec: + ldapAuth: + identityId: + credentialsRef: + secretName: + secretNamespace: + ``` + + ```yaml + # machine-identity-credentials.yaml + apiVersion: v1 + kind: Secret + metadata: + name: ldap-auth-credentials + type: Opaque + stringData: + username: + password: + ``` + The AWS IAM machine identity authentication method is used to authenticate with Infisical. [Read more about AWS IAM Auth](/documentation/platform/identities/aws-auth). @@ -398,7 +435,7 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y This block defines the TLS settings to use for connecting to the Infisical instance. - + Fields: This block defines the reference to the CA certificate to use for connecting to the Infisical instance with SSL/TLS. @@ -438,7 +475,7 @@ Using Go templates, you can format, combine, and create new key-value pairs of s Use this option when you would like to push **only** a subset of secrets from the Kubernetes secret to Infisical. - Define secret keys and their corresponding templates. + Define secret keys and their corresponding templates. Each data value uses a Golang template with access to all secrets defined in the `push.secret.secretName` Kubernetes secret. Secrets are structured as follows: @@ -483,7 +520,7 @@ A generator is defined as a custom resource (`ClusterGenerator`) within the clus Because of this behavior, you may want to disable automatic syncing for the `InfisicalPushSecret` resource to avoid continuous regeneration of secrets. This can be done by omitting the `resyncInterval` field from the InfisicalPushSecret CRD. -### Example usage +### Example usage ```yaml push: secret: @@ -625,4 +662,4 @@ After applying, you should notice that the secrets have been pushed to Infisical ```bash kubectl apply -f source-push-secret.yaml # The secret that you're referencing in the InfisicalPushSecret CRD push.secret field kubectl apply -f example-infisical-push-secret-crd.yaml # The InfisicalPushSecret CRD itself -``` \ No newline at end of file +``` diff --git a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx index 145737e96..d0e403b79 100644 --- a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx @@ -44,15 +44,15 @@ spec: The following properties help define what instance of Infisical the operator will interact with, the interval it will sync secrets and any CA certificates that may be required to connect. - If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to + If you are fetching secrets from a self-hosted instance of Infisical set the value of `hostAPI` to ` https://your-self-hosted-instace.com/api` When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. - If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. + If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. To achieve this, use the following address for the hostAPI field: - + ``` bash http://..svc.cluster.local:4000/api ``` @@ -110,7 +110,7 @@ The list of available authentication methods are shown below. Once you have created your machine identity and added it to your project(s), you will need to create a Kubernetes secret containing the identity credentials. To quickly create a Kubernetes secret containing the identity credentials, you can run the command below. - + Make sure you replace `` with the identity client ID and `` with the identity client secret. ``` bash @@ -525,49 +525,6 @@ spec: ... ``` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -747,6 +704,59 @@ spec: + + The LDAP machine identity authentication method is used to authenticate with Infisical using the configured LDAP directory. The username and password needs to be stored in a Kubernetes secret. This block defines the reference to the name and namespace of secret that stores these credentials. + + + + You need to create a machine identity, and give it access to the project(s) you want to interact with. You can [read more about machine identities here](/documentation/platform/identities/universal-auth). + + + Once you have created your machine identity and added it to your project(s), you will need to create a Kubernetes secret containing the identity credentials. + To quickly create a Kubernetes secret containing the identity credentials, you can run the command below. + + Make sure you replace `` with the identity LDAP username and `` with the identity LDAP password. + + ``` bash + kubectl create secret generic ldap-auth-credentials --from-literal=username="" --from-literal=password="" + ``` + + + + Once the secret is created, add the `secretName` and `secretNamespace` of the secret that was just created under `authentication.ldapAuth.credentialsRef` field in the InfisicalSecret resource. + + + + + + Make sure to also populate the `secretsScope` field with the project slug + _`projectSlug`_, environment slug _`envSlug`_, and secrets path + _`secretsPath`_ that you want to fetch secrets from. Please see the example + below. + + +## Example + +```yaml +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample-crd +spec: + authentication: + ldapAuth: + secretsScope: + projectSlug: # <-- project slug + envSlug: # "dev", "staging", "prod", etc.. + secretsPath: "" # Root is "/" + identityId: + credentialsRef: + secretName: ldap-auth-credentials # <-- name of the Kubernetes secret that stores our machine identity credentials + secretNamespace: default # <-- namespace of the Kubernetes secret that stores our machine identity credentials +``` + + + The service token required to authenticate with Infisical needs to be stored in a Kubernetes secret. This block defines the reference to the name and namespace of secret that stores this service token. @@ -826,7 +836,7 @@ managedKubeSecretReferences: The name of the managed Kubernetes secret to be created -The namespace of the managed Kubernetes secret to be created. +The namespace of the managed Kubernetes secret to be created. Override the default Opaque type for managed secrets with this field. Useful for creating kubernetes.io/dockerconfigjson secrets. @@ -855,8 +865,8 @@ Using Go templates, you can format, combine, and create new key-value pairs from - This property controls what secrets are included in your managed secret when using templates. - When set to `true`, all secrets fetched from your Infisical project will be added into your managed Kubernetes secret resource. + This property controls what secrets are included in your managed secret when using templates. + When set to `true`, all secrets fetched from your Infisical project will be added into your managed Kubernetes secret resource. **Use this option when you would like to sync all secrets from Infisical to Kubernetes but want to template a subset of them.** When set to `false`, only secrets defined in the `managedKubeSecretReferences[].template.data` field of the template will be included in the managed secret. @@ -864,7 +874,7 @@ Use this option when you would like to sync **only** a subset of secrets from In -Define secret keys and their corresponding templates. +Define secret keys and their corresponding templates. Each data value uses a Golang template with access to all secrets retrieved from the specified scope. Secrets are structured as follows: @@ -928,7 +938,9 @@ The properties includes defining the name and namespace of the Kubernetes config The Infisical operator will automatically create the Kubernetes config map in the specified name/namespace and ensure it stays up-to-date. If a config map already exists in the specified namespace, the operator will update the existing config map with the new data. - The usage of config maps is only intended for storing non-sensitive data. If you are looking to store sensitive data, please use the [managed secret](#operator-managed-secrets) property instead. + The usage of config maps is only intended for storing non-sensitive data. If + you are looking to store sensitive data, please use the [managed + secret](#operator-managed-secrets) property instead. @@ -937,25 +949,24 @@ The Infisical operator will automatically create the Kubernetes config map in th The name of the managed Kubernetes config map that your Infisical data will be stored in. - The namespace of the managed Kubernetes config map that your Infisical data will be stored in. + The namespace of the managed Kubernetes config map that your Infisical data will be stored in. Creation policies allow you to control whether or not owner references should be added to the managed Kubernetes config map that is generated by the Infisical operator. This is useful for tools such as ArgoCD, where every resource requires an owner reference; otherwise, it will be pruned automatically. - #### Available options +#### Available options - - `Orphan` (default) - - `Owner` +- `Orphan` (default) +- `Owner` - - When creation policy is set to `Owner`, the `InfisicalSecret` CRD must be in - the same namespace as where the managed kubernetes config map. - + + When creation policy is set to `Owner`, the `InfisicalSecret` CRD must be in + the same namespace as where the managed kubernetes config map. + - #### Managed ConfigMap Templating Fetching secrets from Infisical as is via the operator may not be enough. This is where templating functionality may be helpful. @@ -964,67 +975,68 @@ Using Go templates, you can format, combine, and create new key-value pairs from - This property controls what secrets are included in your managed config map when using templates. - When set to `true`, all secrets fetched from your Infisical project will be added into your managed Kubernetes config map resource. + This property controls what secrets are included in your managed config map when using templates. + When set to `true`, all secrets fetched from your Infisical project will be added into your managed Kubernetes config map resource. **Use this option when you would like to sync all secrets from Infisical to Kubernetes but want to template a subset of them.** - When set to `false`, only secrets defined in the `managedKubeConfigMapReferences[].template.data` field of the template will be included in the managed config map. - Use this option when you would like to sync **only** a subset of secrets from Infisical to Kubernetes. +When set to `false`, only secrets defined in the `managedKubeConfigMapReferences[].template.data` field of the template will be included in the managed config map. +Use this option when you would like to sync **only** a subset of secrets from Infisical to Kubernetes. - Define secret keys and their corresponding templates. + Define secret keys and their corresponding templates. Each data value uses a Golang template with access to all secrets retrieved from the specified scope. - Secrets are structured as follows: +Secrets are structured as follows: - ```golang - type TemplateSecret struct { - Value string `json:"value"` - SecretPath string `json:"secretPath"` - } - ``` +```golang +type TemplateSecret struct { + Value string `json:"value"` + SecretPath string `json:"secretPath"` +} +``` - #### Example template configuration: +#### Example template configuration: - ```yaml - managedKubeConfigMapReferences: - - configMapName: managed-configmap - configMapNamespace: default - template: - includeAllSecrets: true - data: - # Create new key that doesn't exist in your Infisical project using values of other secrets - SITE_URL: "{{ .SITE_URL.Value }}" - # Override an existing key in Infisical project with a new value using values of other secrets - API_URL: "https://api.{{.SITE_URL.Value}}.{{.REGION.Value}}.com" - ``` +```yaml +managedKubeConfigMapReferences: + - configMapName: managed-configmap + configMapNamespace: default + template: + includeAllSecrets: true + data: + # Create new key that doesn't exist in your Infisical project using values of other secrets + SITE_URL: "{{ .SITE_URL.Value }}" + # Override an existing key in Infisical project with a new value using values of other secrets + API_URL: "https://api.{{.SITE_URL.Value}}.{{.REGION.Value}}.com" +``` - For this example, let's assume the following secrets exist in your Infisical project: +For this example, let's assume the following secrets exist in your Infisical project: - ``` - SITE_URL = "https://example.com" - REGION = "us-east-1" - API_URL = "old-url" # This will be overridden - ``` +``` +SITE_URL = "https://example.com" +REGION = "us-east-1" +API_URL = "old-url" # This will be overridden +``` - The resulting managed Kubernetes config map will then contain: +The resulting managed Kubernetes config map will then contain: - ``` - # Original config map data (from includeAllSecrets: true) - SITE_URL = "https://example.com" - REGION = "us-east-1" +``` +# Original config map data (from includeAllSecrets: true) +SITE_URL = "https://example.com" +REGION = "us-east-1" - # New and overridden config map data - SITE_URL = "https://example.com" - API_URL = "https://api.example.com.us-east-1.com" # Existing secret overridden by template - ``` +# New and overridden config map data +SITE_URL = "https://example.com" +API_URL = "https://api.example.com.us-east-1.com" # Existing secret overridden by template +``` - To help transform your config map data further, the operator provides a set of built-in functions that you can use in your templates. +To help transform your config map data further, the operator provides a set of built-in functions that you can use in your templates. + +### Available templating functions + +Please refer to the [templating functions documentation](/integrations/platforms/kubernetes/overview#available-helper-functions) for more information. - ### Available templating functions - - Please refer to the [templating functions documentation](/integrations/platforms/kubernetes/overview#available-helper-functions) for more information. ## Applying CRD @@ -1061,8 +1073,6 @@ To verify that the operator has successfully created the managed secret, you can - - ## Using Managed Secret In Your Deployment To make use of the managed secret created by the operator into your deployment can be achieved through several methods. @@ -1071,45 +1081,45 @@ Here, we will highlight three of the most common ways to utilize it. Learn more This will take all the secrets from your managed secret and expose them to your container - ````yaml - envFrom: - - secretRef: - name: managed-secret # managed secret name - ``` +````yaml + envFrom: + - secretRef: + name: managed-secret # managed secret name + ``` - Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: + Example usage in a deployment + ```yaml + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - envFrom: - - secretRef: - name: managed-secret # <- name of managed secret - ports: - - containerPort: 80 - ```` + spec: + containers: + - name: nginx + image: nginx:1.14.2 + envFrom: + - secretRef: + name: managed-secret # <- name of managed secret + ports: + - containerPort: 80 +```` - - This will allow you to select individual secrets by key name from your managed secret and expose them to your container - + + This will allow you to select individual secrets by key name from your managed secret and expose them to your container + ```yaml env: - name: SECRET_NAME # The environment variable's name which is made available in the container @@ -1119,37 +1129,38 @@ Here, we will highlight three of the most common ways to utilize it. Learn more key: SOME_SECRET_KEY # The name of the key which exists in the managed secret ``` - Example usage in a deployment +Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - env: - - name: STRIPE_API_SECRET - valueFrom: - secretKeyRef: - name: managed-secret # <- name of managed secret - key: STRIPE_API_SECRET - ports: - - containerPort: 80 - ``` + spec: + containers: + - name: nginx + image: nginx:1.14.2 + env: + - name: STRIPE_API_SECRET + valueFrom: + secretKeyRef: + name: managed-secret # <- name of managed secret + key: STRIPE_API_SECRET + ports: + - containerPort: 80 +``` + @@ -1161,48 +1172,48 @@ Here, we will highlight three of the most common ways to utilize it. Learn more secretName: managed-secret # managed secret name ```` - You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets +You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets - ```yaml - volumeMounts: - - name: secrets-volume-name - mountPath: /etc/secrets - readOnly: true - ``` +```yaml +volumeMounts: + - name: secrets-volume-name + mountPath: /etc/secrets + readOnly: true +``` - Example usage in a deployment +Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - volumeMounts: - - name: secrets-volume-name - mountPath: /etc/secrets - readOnly: true - ports: - - containerPort: 80 - volumes: - - name: secrets-volume-name - secret: - secretName: managed-secret # <- managed secrets - ``` + spec: + containers: + - name: nginx + image: nginx:1.14.2 + volumeMounts: + - name: secrets-volume-name + mountPath: /etc/secrets + readOnly: true + ports: + - containerPort: 80 + volumes: + - name: secrets-volume-name + secret: + secretName: managed-secret # <- managed secrets +``` @@ -1244,7 +1255,7 @@ secrets.infisical.com/auto-reload: "true" name: nginx-deployment labels: app: nginx - annotations: + annotations: secrets.infisical.com/auto-reload: "true" # <- redeployment annotation spec: replicas: 1 @@ -1339,9 +1350,11 @@ secrets.infisical.com/auto-reload: "true" - #### How it works - When a managed secret is updated, the operator checks for any Deployments, DaemonSets, or StatefulSets that consume the updated secret and have the annotation - `secrets.infisical.com/auto-reload: "true"`. For each matching workload, the operator triggers a rolling restart to ensure it picks up the latest secret values. + #### How it works When a managed secret is updated, the operator checks for + any Deployments, DaemonSets, or StatefulSets that consume the updated secret + and have the annotation `secrets.infisical.com/auto-reload: "true"`. For each + matching workload, the operator triggers a rolling restart to ensure it picks + up the latest secret values. ## Using Managed ConfigMap In Your Deployment @@ -1350,52 +1363,52 @@ To make use of the managed ConfigMap created by the operator into your deploymen Here, we will highlight three of the most common ways to utilize it. Learn more about Kubernetes ConfigMaps [here](https://kubernetes.io/docs/concepts/configuration/configmap/) - Automatic redeployment of deployments using managed ConfigMaps is not yet supported. + Automatic redeployment of deployments using managed ConfigMaps is not yet + supported. - This will take all the secrets from your managed ConfigMap and expose them to your container - ````yaml - envFrom: - - configMapRef: - name: managed-configmap # managed configmap name - ``` +````yaml + envFrom: + - configMapRef: + name: managed-configmap # managed configmap name + ``` - Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: + Example usage in a deployment + ```yaml + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - envFrom: - - configMapRef: - name: managed-configmap # <- name of managed configmap - ports: - - containerPort: 80 - ```` + spec: + containers: + - name: nginx + image: nginx:1.14.2 + envFrom: + - configMapRef: + name: managed-configmap # <- name of managed configmap + ports: + - containerPort: 80 +```` - - This will allow you to select individual secrets by key name from your managed ConfigMap and expose them to your container - + + This will allow you to select individual secrets by key name from your managed ConfigMap and expose them to your container + ```yaml env: - name: CONFIG_NAME # The environment variable's name which is made available in the container @@ -1405,37 +1418,37 @@ Here, we will highlight three of the most common ways to utilize it. Learn more key: SOME_CONFIG_KEY # The name of the key which exists in the managed configmap ``` - Example usage in a deployment +Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - env: - - name: STRIPE_API_SECRET - valueFrom: - configMapKeyRef: - name: managed-configmap # <- name of managed configmap - key: STRIPE_API_SECRET - ports: - - containerPort: 80 - ``` + spec: + containers: + - name: nginx + image: nginx:1.14.2 + env: + - name: STRIPE_API_SECRET + valueFrom: + configMapKeyRef: + name: managed-configmap # <- name of managed configmap + key: STRIPE_API_SECRET + ports: + - containerPort: 80 +``` @@ -1448,48 +1461,49 @@ Here, we will highlight three of the most common ways to utilize it. Learn more name: managed-configmap # managed configmap name ```` - You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets +You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets - ```yaml - volumeMounts: - - name: configmaps-volume-name - mountPath: /etc/config - readOnly: true - ``` +```yaml +volumeMounts: + - name: configmaps-volume-name + mountPath: /etc/config + readOnly: true +``` - Example usage in a deployment +Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - volumeMounts: - - name: configmaps-volume-name - mountPath: /etc/config - readOnly: true - ports: - - containerPort: 80 - volumes: - - name: configmaps-volume-name - configMap: - name: managed-configmap # <- managed configmap - ``` + spec: + containers: + - name: nginx + image: nginx:1.14.2 + volumeMounts: + - name: configmaps-volume-name + mountPath: /etc/config + readOnly: true + ports: + - containerPort: 80 + volumes: + - name: configmaps-volume-name + configMap: + name: managed-configmap # <- managed configmap +``` + The definition file of the Kubernetes secret for the CA certificate can be structured like the following: @@ -1532,20 +1546,21 @@ Thus, if a specific label is required on the resulting secret, it can be applied ... ``` - This would result in the following managed secret to be created: +This would result in the following managed secret to be created: + +```yaml +apiVersion: v1 +data: ... +kind: Secret +metadata: + annotations: + example.com/annotation-to-be-passed-to-managed-secret: sample-value + secrets.infisical.com/version: W/"3f1-ZyOSsrCLGSkAhhCkY2USPu2ivRw" + labels: + label-to-be-passed-to-managed-secret: sample-value + name: managed-token + namespace: default +type: Opaque +``` - ```yaml - apiVersion: v1 - data: ... - kind: Secret - metadata: - annotations: - example.com/annotation-to-be-passed-to-managed-secret: sample-value - secrets.infisical.com/version: W/"3f1-ZyOSsrCLGSkAhhCkY2USPu2ivRw" - labels: - label-to-be-passed-to-managed-secret: sample-value - name: managed-token - namespace: default - type: Opaque - ``` diff --git a/docs/integrations/secret-syncs/netlify.mdx b/docs/integrations/secret-syncs/netlify.mdx new file mode 100644 index 000000000..fd98fac41 --- /dev/null +++ b/docs/integrations/secret-syncs/netlify.mdx @@ -0,0 +1,161 @@ +--- +title: "Netlify Sync" +description: "Learn how to configure a Netlify Sync for Infisical." +--- + +**Prerequisites:** + +- Create a [Netlify Connection](/integrations/app-connections/netlify) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Netlify](/images/secret-syncs/netlify/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/netlify/sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/netlify/sync-destination.png) + + - **Netlify Connection**: The Netlify Connection to authenticate with. + - **Account**: The Netlify Account to be used. + - **Site**: The Netlify site where secrets should be synced. + - **Context**: The Netlify deployment context where secrets should be created in. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/netlify/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 Netlify when keys conflict. + - **Import Secrets (Prioritize Netlify)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Netlify over Infisical when keys conflict. + + Netlify only supports importing non-secret values. + + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Netlify Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/netlify/sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Netlify Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/netlify/sync-review.png) + + + If enabled, your Netlify Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/netlify/sync-created.png) + + + + + + To create a **Netlify Sync**, make an API request to the [Create Netlify Sync](/api-reference/endpoints/secret-syncs/netlify/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/netlify \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-netlify-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "sync to netlify site", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "production", + "secretPath": "/site-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "destinationConfig": { + "siteId": "site-abc123", + "accountId": "account-abc123", + "context": "dev" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-netlify-sync", + "description": "sync to netlify site", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-07-19T12:00:00Z", + "updatedAt": "2025-07-19T12:00:00Z", + "syncStatus": "succeeded", + "lastSyncJobId": "job-1234", + "lastSyncMessage": null, + "lastSyncedAt": "2025-07-19T12:00:00Z", + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "netlify", + "name": "my-netlify-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "production", + "name": "Production", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/site-secrets" + }, + "destination": "netlify", + "destinationConfig": { + "siteId": "site-abc123", + "accountId": "account-abc123", + "context": "dev" + } + } + } + ``` + + diff --git a/docs/internals/permissions/organization-permissions.mdx b/docs/internals/permissions/organization-permissions.mdx index 80c843851..5f5fc962f 100644 --- a/docs/internals/permissions/organization-permissions.mdx +++ b/docs/internals/permissions/organization-permissions.mdx @@ -217,3 +217,14 @@ Supports conditions and permission inversion | `edit-gateways` | Modify existing gateway settings | | `delete-gateways` | Remove gateways from organization | | `attach-gateways` | Attach gateways to resources | + +#### Subject: `machine-identity-auth-template` + +| Action | Description | +| ------------------ | ---------------------------------------------- | +| `list-templates` | View identity auth templates | +| `create-templates` | Create new identity auth templates | +| `edit-templates` | Modify existing identity auth templates | +| `delete-templates` | Remove identity auth templates | +| `unlink-templates` | Unlink identity auth templates from identities | +| `attach-templates` | Attach identity auth templates to identities | diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 1e050ff14..53adf95a4 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -65,7 +65,14 @@ Example values: default="false" optional > - Determines whether your Infisical instance can automatically read the service account token of the pod it's running on. Used for features such as the IRSA auth method. + Determines whether your Infisical instance can automatically read the service + account token of the pod it's running on. Used for features such as the IRSA + auth method. + + + + Disable storing audit logs in the database. This is useful if you're using + audit log streams and don't want to store them in the database. ## CORS diff --git a/frontend/public/infisical.ico b/frontend/public/infisical.ico index 45d31d20e..a76dd0339 100644 Binary files a/frontend/public/infisical.ico and b/frontend/public/infisical.ico differ diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitLabDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitLabDataSourceConfigFields.tsx new file mode 100644 index 000000000..d36f0553d --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitLabDataSourceConfigFields.tsx @@ -0,0 +1,272 @@ +import { useEffect } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { MultiValue, SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { FilterableSelect, FormControl, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { + TGitLabGroup, + TGitLabProject, + useGitLabConnectionListGroups, + useGitLabConnectionListProjects +} from "@app/hooks/api/appConnections/gitlab"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; +import { GitLabDataSourceScope } from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { SecretScanningDataSourceConnectionField } from "../SecretScanningDataSourceConnectionField"; + +enum ScanMethod { + AllProjects = "all-projects", + SelectProjects = "select-projects" +} + +export const GitLabDataSourceConfigFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretScanningDataSourceForm & { + type: SecretScanningDataSource.GitLab; + } + >(); + + const connectionId = useWatch({ control, name: "connection.id" }); + const isUpdate = Boolean(watch("id")); + + const scope = watch("config.scope"); + const groupName = watch("config.groupName"); + const includeProjects = watch("config.includeProjects"); + + const { data: projects, isPending: isProjectsPending } = useGitLabConnectionListProjects( + connectionId, + { enabled: Boolean(connectionId) } + ); + + const { data: groups, isPending: isGroupsPending } = useGitLabConnectionListGroups(connectionId, { + enabled: Boolean(connectionId) && scope === GitLabDataSourceScope.Group + }); + + const scanMethod = + !includeProjects || includeProjects.includes("*") + ? ScanMethod.AllProjects + : ScanMethod.SelectProjects; + + useEffect(() => { + if (!includeProjects) { + setValue("config.includeProjects", ["*"]); + } + }, [includeProjects]); + + const clearAllFields = () => { + setValue("config.includeProjects", []); + setValue("config.projectName", ""); + setValue("config.groupName", ""); + // @ts-expect-error rhf doesn't like this but we need to reset + setValue("config.projectId", undefined); + // @ts-expect-error rhf doesn't like this but we need to reset + setValue("config.groupId", undefined); + }; + + return ( + <> + + ( + +

Specify the GitLab scope scanning should be performed at:

+
    +
  • +

    + Project: Scan an + individual GitLab project. +

    +
  • +
  • +

    + Group: Scan one or more + projects belonging to a GitLab group. +

    +
  • +
+ + } + > + +
+ )} + /> + {scope === GitLabDataSourceScope.Project ? ( + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + value === Number.parseInt(project.id, 10))} + onChange={(newValue) => { + const project = newValue as SingleValue; + + onChange(project ? Number.parseInt(project.id, 10) : null); + setValue("config.projectName", project?.name ?? ""); + }} + options={projects} + placeholder="Select project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
+ )} + /> + ) : ( + <> + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the group you're looking for?{" "} + +
+ + } + > + value === Number.parseInt(group.id, 10))} + onChange={(newValue) => { + const group = newValue as SingleValue; + + onChange(group ? Number.parseInt(group.id, 10) : null); + setValue("config.groupName", group?.name ?? ""); + setValue("config.includeProjects", ["*"]); + }} + options={groups} + placeholder="Select group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
+ )} + /> + + + + {scanMethod === ScanMethod.SelectProjects && ( + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + value.includes(project.name))} + onChange={(newValue) => { + onChange( + newValue + ? (newValue as MultiValue).map((p) => p.name) + : null + ); + }} + options={projects?.filter((project) => project.name.startsWith(groupName))} + placeholder="Select projects..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
+ )} + /> + )} + + )} + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx index cbdc0f0b8..c4334c29a 100644 --- a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx @@ -7,10 +7,12 @@ import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; import { TSecretScanningDataSourceForm } from "../schemas"; import { BitbucketDataSourceConfigFields } from "./BitbucketDataSourceConfigFields"; import { GitHubDataSourceConfigFields } from "./GitHubDataSourceConfigFields"; +import { GitLabDataSourceConfigFields } from "./GitLabDataSourceConfigFields"; const COMPONENT_MAP: Record = { [SecretScanningDataSource.GitHub]: GitHubDataSourceConfigFields, - [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceConfigFields + [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceConfigFields, + [SecretScanningDataSource.GitLab]: GitLabDataSourceConfigFields }; export const SecretScanningDataSourceConfigFields = () => { diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/GitLabDataSourceReviewFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/GitLabDataSourceReviewFields.tsx new file mode 100644 index 000000000..ad421e57e --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/GitLabDataSourceReviewFields.tsx @@ -0,0 +1,49 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; +import { GitLabDataSourceScope } from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { SecretScanningDataSourceConfigReviewSection } from "./shared"; + +export const GitLabDataSourceReviewFields = () => { + const { watch } = useFormContext< + TSecretScanningDataSourceForm & { + type: SecretScanningDataSource.GitLab; + } + >(); + + const [config, connection] = watch(["config", "connection"]); + + if (config.scope === GitLabDataSourceScope.Project) { + const { projectName, projectId } = config; + return ( + + {connection && {connection.name}} + + {config.scope} + + {projectName || projectId} + + ); + } + + // group-scope + + const { includeProjects, groupName, groupId } = config; + const shouldScanAll = includeProjects.includes("*"); + + return ( + + {connection && {connection.name}} + + {config.scope} + + {groupName || groupId} + + {shouldScanAll ? "All" : includeProjects.join(", ")} + + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx index e29dc9ebd..69d630e8a 100644 --- a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceReviewFields/SecretScanningDataSourceReviewFields.tsx @@ -6,10 +6,12 @@ import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; import { TSecretScanningDataSourceForm } from "../schemas"; import { BitbucketDataSourceReviewFields } from "./BitbucketDataSourceReviewFields"; import { GitHubDataSourceReviewFields } from "./GitHubDataSourceReviewFields"; +import { GitLabDataSourceReviewFields } from "./GitLabDataSourceReviewFields"; const COMPONENT_MAP: Record = { [SecretScanningDataSource.GitHub]: GitHubDataSourceReviewFields, - [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceReviewFields + [SecretScanningDataSource.Bitbucket]: BitbucketDataSourceReviewFields, + [SecretScanningDataSource.GitLab]: GitLabDataSourceReviewFields }; export const SecretScanningDataSourceReviewFields = () => { diff --git a/frontend/src/components/secret-scanning/forms/schemas/gitlab-data-source-schema.ts b/frontend/src/components/secret-scanning/forms/schemas/gitlab-data-source-schema.ts new file mode 100644 index 000000000..ef577a7d2 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/schemas/gitlab-data-source-schema.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; +import { GitLabDataSourceScope } from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; + +import { BaseSecretScanningDataSourceSchema } from "./base-secret-scanning-data-source-schema"; + +export const GitLabDataSourceSchema = z + .object({ + type: z.literal(SecretScanningDataSource.GitLab), + config: z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(GitLabDataSourceScope.Group), + groupId: z.number(), + groupName: z.string(), + includeProjects: z + .array(z.string().min(1).max(256)) + .min(1, "One or more projects required") + .max(100, "Cannot configure more than 100 projects") + .default(["*"]) + }), + z.object({ + scope: z.literal(GitLabDataSourceScope.Project), + projectId: z.number(), + projectName: z.string() + }) + ]) + }) + .merge(BaseSecretScanningDataSourceSchema({ isConnectionRequired: true })); diff --git a/frontend/src/components/secret-scanning/forms/schemas/index.ts b/frontend/src/components/secret-scanning/forms/schemas/index.ts index 23e6d86e4..82a25e1ae 100644 --- a/frontend/src/components/secret-scanning/forms/schemas/index.ts +++ b/frontend/src/components/secret-scanning/forms/schemas/index.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { BitbucketDataSourceSchema } from "./bitbucket-data-source-schema"; import { GitHubDataSourceSchema } from "./github-data-source-schema"; +import { GitLabDataSourceSchema } from "./gitlab-data-source-schema"; export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [ GitHubDataSourceSchema, - BitbucketDataSourceSchema + BitbucketDataSourceSchema, + GitLabDataSourceSchema ]); export type TSecretScanningDataSourceForm = z.infer; diff --git a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx index 25d992735..dc2ab385a 100644 --- a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx @@ -31,7 +31,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => environment: secretSync.environment ?? undefined, secretPath: secretSync.folder?.path, description: secretSync.description ?? "" - }, + } as Partial, reValidateMode: "onChange" }); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitLabSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitLabSyncFields.tsx index 42387e6df..fe73ea5d5 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitLabSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitLabSyncFields.tsx @@ -16,8 +16,8 @@ import { import { TGitLabGroup, TGitLabProject, - useGitlabConnectionListGroups, - useGitlabConnectionListProjects + useGitLabConnectionListGroups, + useGitLabConnectionListProjects } from "@app/hooks/api/appConnections/gitlab"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { GitLabSyncScope } from "@app/hooks/api/secretSyncs/types/gitlab-sync"; @@ -70,11 +70,11 @@ export const GitLabSyncFields = () => { const scope = useWatch({ name: "destinationConfig.scope", control }); const shouldMaskSecrets = useWatch({ name: "destinationConfig.shouldMaskSecrets", control }); - const { data: groups, isLoading: isGroupsLoading } = useGitlabConnectionListGroups(connectionId, { + const { data: groups, isLoading: isGroupsLoading } = useGitLabConnectionListGroups(connectionId, { enabled: Boolean(connectionId) && scope === GitLabSyncScope.Group }); - const { data: projects, isLoading: isProjectsLoading } = useGitlabConnectionListProjects( + const { data: projects, isLoading: isProjectsLoading } = useGitLabConnectionListProjects( connectionId, { enabled: Boolean(connectionId) diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NetlifySyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NetlifySyncFields.tsx new file mode 100644 index 000000000..fd977ec2a --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NetlifySyncFields.tsx @@ -0,0 +1,144 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { + TNetlifyAccount, + TNetlifySite, + useNetlifyConnectionListAccounts, + useNetlifyConnectionListSites +} from "@app/hooks/api/appConnections/netlify"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; +import { NetlifySyncContext } from "../schemas/netlify-sync-destination-schema"; + +export const NetlifySyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Netlify } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const accountId = useWatch({ name: "destinationConfig.accountId", control }); + + const { data: accounts = [], isPending: isAccountsLoading } = useNetlifyConnectionListAccounts( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const { data: sites = [], isPending: isSitesLoading } = useNetlifyConnectionListSites( + connectionId, + accountId, + { + enabled: Boolean(accountId) + } + ); + + const contexts = Object.entries(NetlifySyncContext).map(([key, value]) => ({ + label: key, + value + })); + + return ( + <> + { + setValue("destinationConfig.accountId", ""); + setValue("destinationConfig.accountName", ""); + }} + /> + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.accountName", v?.name ?? ""); + }} + options={accounts} + placeholder="Select an account..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + if (v?.id === value) { + onChange(undefined); + setValue("destinationConfig.siteName", undefined); + } else { + onChange(v?.id); + setValue("destinationConfig.siteName", v?.name); + } + }} + options={sites} + placeholder="Select a site..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + ( + + p.value === value) ?? undefined} + onChange={(option) => { + const v = option as SingleValue<{ label: string; value: NetlifySyncContext }>; + if (v) onChange(v.value); + }} + options={contexts} + placeholder="Select a context..." + getOptionLabel={(option) => option.label} + getOptionValue={(option) => option.value} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 478447541..b2186fed1 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -23,6 +23,7 @@ import { GitLabSyncFields } from "./GitLabSyncFields"; import { HCVaultSyncFields } from "./HCVaultSyncFields"; import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; +import { NetlifySyncFields } from "./NetlifySyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; import { RailwaySyncFields } from "./RailwaySyncFields"; import { RenderSyncFields } from "./RenderSyncFields"; @@ -95,6 +96,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.DigitalOceanAppPlatform: return ; + case SecretSync.Netlify: + return ; case SecretSync.Bitbucket: return ; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index cb7a40559..93e71cfc6 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -67,6 +67,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Checkly: case SecretSync.Supabase: case SecretSync.DigitalOceanAppPlatform: + case SecretSync.Netlify: case SecretSync.Bitbucket: AdditionalSyncOptionsFieldsComponent = null; break; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NetlifySyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NetlifySyncReviewFields.tsx new file mode 100644 index 000000000..5898de7c3 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NetlifySyncReviewFields.tsx @@ -0,0 +1,22 @@ +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 NetlifySyncReviewFields = () => { + const { watch } = useFormContext(); + const accountName = watch("destinationConfig.accountName"); + const accountId = watch("destinationConfig.accountId"); + const siteName = watch("destinationConfig.siteName"); + const siteId = watch("destinationConfig.siteId"); + const context = watch("destinationConfig.context"); + + return ( + <> + {accountName || accountId} + {siteName || siteId || "None"} + {context || "None"} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 5ee53f2e3..eaeab2e66 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -32,6 +32,7 @@ import { GitLabSyncReviewFields } from "./GitLabSyncReviewFields"; import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { NetlifySyncReviewFields } from "./NetlifySyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; import { RailwaySyncReviewFields } from "./RailwaySyncReviewFields"; @@ -150,6 +151,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.DigitalOceanAppPlatform: DestinationFieldsComponent = ; break; + case SecretSync.Netlify: + DestinationFieldsComponent = ; + break; case SecretSync.Bitbucket: DestinationFieldsComponent = ; break; diff --git a/frontend/src/components/secret-syncs/forms/schemas/netlify-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/netlify-sync-destination-schema.ts new file mode 100644 index 000000000..b0412bda1 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/netlify-sync-destination-schema.ts @@ -0,0 +1,26 @@ +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 enum NetlifySyncContext { + // All = "all", - Netlify doesn't allow "all" as the context when using is_secret: true + DeployPreview = "deploy-preview", + Production = "production", + BranchDeploy = "branch-deploy", + Dev = "dev", + Branch = "branch" +} + +export const NetlifySyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Netlify), + destinationConfig: z.object({ + accountId: z.string(), + accountName: z.string(), + siteId: z.string().optional(), + siteName: z.string().optional(), + context: z.nativeEnum(NetlifySyncContext).optional() + }) + }) +); 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 5ffd5940a..f4616c711 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 @@ -20,6 +20,7 @@ import { GitlabSyncDestinationSchema } from "./gitlab-sync-destination-schema"; import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema"; import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; +import { NetlifySyncDestinationSchema } from "./netlify-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; import { RailwaySyncDestinationSchema } from "./railway-sync-destination-schema"; import { RenderSyncDestinationSchema } from "./render-sync-destination-schema"; @@ -59,6 +60,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ RailwaySyncDestinationSchema, ChecklySyncDestinationSchema, DigitalOceanAppPlatformSyncDestinationSchema, + NetlifySyncDestinationSchema, BitbucketSyncDestinationSchema ]); diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 59446eb07..50e147aa0 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -21,6 +21,15 @@ export enum OrgGatewayPermissionActions { AttachGateways = "attach-gateways" } +export enum OrgPermissionMachineIdentityAuthTemplateActions { + ListTemplates = "list-templates", + CreateTemplates = "create-templates", + EditTemplates = "edit-templates", + DeleteTemplates = "delete-templates", + UnlinkTemplates = "unlink-templates", + AttachTemplates = "attach-templates" +} + export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", @@ -42,7 +51,8 @@ export enum OrgPermissionSubjects { Kmip = "kmip", Gateway = "gateway", SecretShare = "secret-share", - GithubOrgSync = "github-org-sync" + GithubOrgSync = "github-org-sync", + MachineIdentityAuthTemplate = "machine-identity-auth-template" } export enum OrgPermissionAdminConsoleAction { @@ -113,6 +123,10 @@ export type OrgPermissionSet = | [OrgPermissionAppConnectionActions, OrgPermissionSubjects.AppConnections] | [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity] | [OrgPermissionKmipActions, OrgPermissionSubjects.Kmip] + | [ + OrgPermissionMachineIdentityAuthTemplateActions, + OrgPermissionSubjects.MachineIdentityAuthTemplate + ] | [OrgGatewayPermissionActions, OrgPermissionSubjects.Gateway] | [OrgPermissionSecretShareAction, OrgPermissionSubjects.SecretShare]; // TODO(scott): add back once org UI refactored diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 3f301942b..3c0f9df32 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -21,7 +21,8 @@ export enum ProjectPermissionSecretActions { ReadValue = "readValue", Create = "create", Edit = "edit", - Delete = "delete" + Delete = "delete", + Subscribe = "subscribe" } export enum ProjectPermissionDynamicSecretActions { diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 8c8475767..7ecc92811 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -45,6 +45,7 @@ import { BitbucketConnectionMethod } from "@app/hooks/api/appConnections/types/b import { ChecklyConnectionMethod } from "@app/hooks/api/appConnections/types/checkly-connection"; import { DigitalOceanConnectionMethod } from "@app/hooks/api/appConnections/types/digital-ocean"; import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; +import { NetlifyConnectionMethod } from "@app/hooks/api/appConnections/types/netlify-connection"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection"; import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection"; @@ -94,7 +95,7 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Heroku]: { name: "Heroku", image: "Heroku.png" }, [AppConnection.Render]: { name: "Render", image: "Render.png" }, [AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" }, - [AppConnection.Gitlab]: { name: "GitLab", image: "GitLab.png" }, + [AppConnection.GitLab]: { name: "GitLab", image: "GitLab.png" }, [AppConnection.Cloudflare]: { name: "Cloudflare", image: "Cloudflare.png" }, [AppConnection.Zabbix]: { name: "Zabbix", image: "Zabbix.png" }, [AppConnection.Railway]: { name: "Railway", image: "Railway.png" }, @@ -105,6 +106,10 @@ export const APP_CONNECTION_MAP: Record< name: "Digital Ocean", image: "Digital Ocean.png" }, + [AppConnection.Netlify]: { + name: "Netlify", + image: "Netlify.png" + }, [AppConnection.Okta]: { name: "Okta", image: "Okta.png" } }; @@ -152,6 +157,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case AzureDevOpsConnectionMethod.AccessToken: case WindmillConnectionMethod.AccessToken: case FlyioConnectionMethod.AccessToken: + case NetlifyConnectionMethod.AccessToken: return { name: "Access Token", icon: faKey }; case Auth0ConnectionMethod.ClientCredentials: return { name: "Client Credentials", icon: faServer }; @@ -172,6 +178,9 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case ChecklyConnectionMethod.ApiKey: return { name: "API Key", icon: faKey }; case AzureClientSecretsConnectionMethod.ClientSecret: + case AzureAppConfigurationConnectionMethod.ClientSecret: + case AzureKeyVaultConnectionMethod.ClientSecret: + case AzureDevOpsConnectionMethod.ClientSecret: return { name: "Client Secret", icon: faKey }; default: throw new Error(`Unhandled App Connection Method: ${method}`); diff --git a/frontend/src/helpers/secretScanningV2.ts b/frontend/src/helpers/secretScanningV2.ts index 8021142b5..4e88b8482 100644 --- a/frontend/src/helpers/secretScanningV2.ts +++ b/frontend/src/helpers/secretScanningV2.ts @@ -24,6 +24,11 @@ export const SECRET_SCANNING_DATA_SOURCE_MAP: Record< name: "Bitbucket", image: "Bitbucket.png", size: 45 + }, + [SecretScanningDataSource.GitLab]: { + name: "GitLab", + image: "GitLab.png", + size: 45 } }; @@ -32,7 +37,8 @@ export const SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP: Record< AppConnection > = { [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar, - [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket + [SecretScanningDataSource.Bitbucket]: AppConnection.Bitbucket, + [SecretScanningDataSource.GitLab]: AppConnection.GitLab }; export const RESOURCE_DESCRIPTION_HELPER: Record< @@ -58,6 +64,13 @@ export const RESOURCE_DESCRIPTION_HELPER: Record< singularNoun: "repository", pluralTitle: "Repositories", singularTitle: "Repository" + }, + [SecretScanningDataSource.GitLab]: { + verb: "push", + pluralNoun: "projects", + singularNoun: "project", + pluralTitle: "Projects", + singularTitle: "Project" } }; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index b4c0df352..8da61583e 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -106,6 +106,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Heroku]: AppConnection.Heroku, [SecretSync.Render]: AppConnection.Render, [SecretSync.Flyio]: AppConnection.Flyio, - [SecretSync.GitLab]: AppConnection.Gitlab, + [SecretSync.GitLab]: AppConnection.GitLab, [SecretSync.CloudflarePages]: AppConnection.Cloudflare, [SecretSync.CloudflareWorkers]: AppConnection.Cloudflare, [SecretSync.Supabase]: AppConnection.Supabase, @@ -141,6 +145,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Railway]: AppConnection.Railway, [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, + [SecretSync.Netlify]: AppConnection.Netlify, [SecretSync.Bitbucket]: AppConnection.Bitbucket }; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index 916067f00..b196f19e7 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -68,6 +68,25 @@ export const useAdminDeleteUser = () => { }); }; +export const useAdminBulkDeleteUsers = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (userIds: string[]) => { + await apiRequest.delete("/api/v1/admin/user-management/users", { + data: { userIds } + }); + + return {}; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [adminStandaloneKeys.getUsers] + }); + queryClient.invalidateQueries({ queryKey: adminStandaloneKeys.getOrganizations }); + } + }); +}; + export const useAdminDeleteOrganizationMembership = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 51ff5a908..fe2cc3eed 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -35,6 +35,7 @@ export type TServerConfig = { initialized: boolean; allowSignUp: boolean; allowedSignUpDomain?: string | null; + disableAuditLogStorage: boolean; isMigrationModeOn?: boolean; trustSamlEmails: boolean; trustLdapEmails: boolean; diff --git a/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx b/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx index f4ca87ad1..973943d1e 100644 --- a/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx +++ b/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx @@ -3,14 +3,16 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "../queries"; -import { TCloudflarePagesProject, TCloudflareWorkersScript } from "./types"; +import { TCloudflarePagesProject, TCloudflareWorkersScript, TCloudflareZone } from "./types"; const cloudflareConnectionKeys = { all: [...appConnectionKeys.all, "cloudflare"] as const, listPagesProjects: (connectionId: string) => [...cloudflareConnectionKeys.all, "pages-projects", connectionId] as const, listWorkersScripts: (connectionId: string) => - [...cloudflareConnectionKeys.all, "workers-scripts", connectionId] as const + [...cloudflareConnectionKeys.all, "workers-scripts", connectionId] as const, + listZones: (connectionId: string) => + [...cloudflareConnectionKeys.all, "zones", connectionId] as const }; export const useCloudflareConnectionListPagesProjects = ( @@ -62,3 +64,28 @@ export const useCloudflareConnectionListWorkersScripts = ( ...options }); }; + +export const useCloudflareConnectionListZones = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TCloudflareZone[], + unknown, + TCloudflareZone[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: cloudflareConnectionKeys.listZones(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/cloudflare/${connectionId}/cloudflare-zones` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/cloudflare/types.ts b/frontend/src/hooks/api/appConnections/cloudflare/types.ts index 1d5ea53d4..e8905e16c 100644 --- a/frontend/src/hooks/api/appConnections/cloudflare/types.ts +++ b/frontend/src/hooks/api/appConnections/cloudflare/types.ts @@ -6,3 +6,8 @@ export type TCloudflarePagesProject = { export type TCloudflareWorkersScript = { id: string; }; + +export type TCloudflareZone = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index c3a1fec05..eff3c9b62 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -26,7 +26,7 @@ export enum AppConnection { Heroku = "heroku", Render = "render", Flyio = "flyio", - Gitlab = "gitlab", + GitLab = "gitlab", Cloudflare = "cloudflare", Bitbucket = "bitbucket", Zabbix = "zabbix", @@ -34,5 +34,6 @@ export enum AppConnection { Checkly = "checkly", Supabase = "supabase", DigitalOcean = "digital-ocean", + Netlify = "netlify", Okta = "okta" } diff --git a/frontend/src/hooks/api/appConnections/gitlab/queries.tsx b/frontend/src/hooks/api/appConnections/gitlab/queries.tsx index 155d56a23..baa9deecf 100644 --- a/frontend/src/hooks/api/appConnections/gitlab/queries.tsx +++ b/frontend/src/hooks/api/appConnections/gitlab/queries.tsx @@ -13,7 +13,7 @@ const gitlabConnectionKeys = { [...gitlabConnectionKeys.all, "groups", connectionId] as const }; -export const useGitlabConnectionListProjects = ( +export const useGitLabConnectionListProjects = ( connectionId: string, options?: Omit< UseQueryOptions< @@ -38,7 +38,7 @@ export const useGitlabConnectionListProjects = ( }); }; -export const useGitlabConnectionListGroups = ( +export const useGitLabConnectionListGroups = ( connectionId: string, options?: Omit< UseQueryOptions< diff --git a/frontend/src/hooks/api/appConnections/netlify/index.ts b/frontend/src/hooks/api/appConnections/netlify/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/netlify/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/netlify/queries.tsx b/frontend/src/hooks/api/appConnections/netlify/queries.tsx new file mode 100644 index 000000000..d196a69b5 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/netlify/queries.tsx @@ -0,0 +1,65 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TNetlifyAccount, TNetlifySite } from "./types"; + +const netlifyConnectionKeys = { + all: [...appConnectionKeys.all, "netlify"] as const, + listAccounts: (connectionId: string) => + [...netlifyConnectionKeys.all, "accounts", connectionId] as const, + listSites: (connectionId: string, accountId: string) => + [...netlifyConnectionKeys.all, "sites", connectionId, accountId] as const +}; + +export const useNetlifyConnectionListAccounts = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TNetlifyAccount[], + unknown, + TNetlifyAccount[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: netlifyConnectionKeys.listAccounts(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ accounts: TNetlifyAccount[] }>( + `/api/v1/app-connections/netlify/${connectionId}/accounts` + ); + + return data.accounts; + }, + ...options + }); +}; + +export const useNetlifyConnectionListSites = ( + connectionId: string, + accountId: string, + options?: Omit< + UseQueryOptions< + TNetlifySite[], + unknown, + TNetlifySite[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: netlifyConnectionKeys.listSites(connectionId, accountId), + queryFn: async () => { + const { data } = await apiRequest.get<{ sites: TNetlifySite[] }>( + `/api/v1/app-connections/netlify/${connectionId}/accounts/${accountId}/sites` + ); + + return data.sites; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/netlify/types.ts b/frontend/src/hooks/api/appConnections/netlify/types.ts new file mode 100644 index 000000000..aa514e41b --- /dev/null +++ b/frontend/src/hooks/api/appConnections/netlify/types.ts @@ -0,0 +1,9 @@ +export type TNetlifyAccount = { + id: string; + name: string; +}; + +export type TNetlifySite = { + 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 7dc5907e3..4191cb890 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -124,7 +124,7 @@ export type TFlyioConnectionOption = TAppConnectionOptionBase & { }; export type TGitlabConnectionOption = TAppConnectionOptionBase & { - app: AppConnection.Gitlab; + app: AppConnection.GitLab; oauthClientId?: string; }; @@ -156,6 +156,10 @@ export type TDigitalOceanConnectionOption = TAppConnectionOptionBase & { app: AppConnection.DigitalOcean; }; +export type TNetlifyConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Netlify; +}; + export type TOktaConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Okta; }; @@ -193,6 +197,7 @@ export type TAppConnectionOption = | TRailwayConnectionOption | TChecklyConnectionOption | TDigitalOceanConnectionOption + | TNetlifyConnectionOption | TOktaConnectionOption; export type TAppConnectionOptionMap = { @@ -223,7 +228,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Heroku]: THerokuConnectionOption; [AppConnection.Render]: TRenderConnectionOption; [AppConnection.Flyio]: TFlyioConnectionOption; - [AppConnection.Gitlab]: TGitlabConnectionOption; + [AppConnection.GitLab]: TGitlabConnectionOption; [AppConnection.Cloudflare]: TCloudflareConnectionOption; [AppConnection.Bitbucket]: TBitbucketConnectionOption; [AppConnection.Zabbix]: TZabbixConnectionOption; @@ -231,5 +236,6 @@ export type TAppConnectionOptionMap = { [AppConnection.Checkly]: TChecklyConnectionOption; [AppConnection.Supabase]: TSupabaseConnectionOption; [AppConnection.DigitalOcean]: TDigitalOceanConnectionOption; + [AppConnection.Netlify]: TNetlifyConnectionOption; [AppConnection.Okta]: TOktaConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/azure-app-configuration-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-app-configuration-connection.ts index b56003cad..092822da2 100644 --- a/frontend/src/hooks/api/appConnections/types/azure-app-configuration-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/azure-app-configuration-connection.ts @@ -2,15 +2,26 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; export enum AzureAppConfigurationConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } export type TAzureAppConfigurationConnection = TRootAppConnection & { app: AppConnection.AzureAppConfiguration; -} & { - method: AzureAppConfigurationConnectionMethod.OAuth; - credentials: { - code: string; - tenantId?: string; - }; -}; +} & ( + | { + method: AzureAppConfigurationConnectionMethod.OAuth; + credentials: { + code: string; + tenantId?: string; + }; + } + | { + method: AzureAppConfigurationConnectionMethod.ClientSecret; + credentials: { + clientId: string; + clientSecret: string; + tenantId: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts index ef30936e6..9334ac06a 100644 --- a/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts @@ -3,7 +3,8 @@ import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-con export enum AzureDevOpsConnectionMethod { OAuth = "oauth", - AccessToken = "access-token" + AccessToken = "access-token", + ClientSecret = "client-secret" } export type TAzureDevOpsConnection = TRootAppConnection & { @@ -24,4 +25,13 @@ export type TAzureDevOpsConnection = TRootAppConnection & { orgName: string; }; } + | { + method: AzureDevOpsConnectionMethod.ClientSecret; + credentials: { + clientSecret: string; + tenantId: string; + clientId: string; + orgName: string; + }; + } ); diff --git a/frontend/src/hooks/api/appConnections/types/azure-key-vault-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-key-vault-connection.ts index 68888531a..5cea148fa 100644 --- a/frontend/src/hooks/api/appConnections/types/azure-key-vault-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/azure-key-vault-connection.ts @@ -2,13 +2,24 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; export enum AzureKeyVaultConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } -export type TAzureKeyVaultConnection = TRootAppConnection & { app: AppConnection.AzureKeyVault } & { - method: AzureKeyVaultConnectionMethod.OAuth; - credentials: { - code: string; - tenantId?: string; - }; -}; +export type TAzureKeyVaultConnection = TRootAppConnection & { app: AppConnection.AzureKeyVault } & ( + | { + method: AzureKeyVaultConnectionMethod.OAuth; + credentials: { + code: string; + tenantId?: string; + }; + } + | { + method: AzureKeyVaultConnectionMethod.ClientSecret; + credentials: { + clientId: string; + clientSecret: string; + tenantId: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/github-connection.ts b/frontend/src/hooks/api/appConnections/types/github-connection.ts index a8b734aa9..27bed2dcb 100644 --- a/frontend/src/hooks/api/appConnections/types/github-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/github-connection.ts @@ -11,6 +11,7 @@ export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub method: GitHubConnectionMethod.OAuth; credentials: { code: string; + instanceType?: "cloud" | "server"; host?: string; }; } @@ -19,6 +20,7 @@ export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub credentials: { code: string; installationId: string; + instanceType?: "cloud" | "server"; host?: string; }; } diff --git a/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts b/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts index 667475126..d4f215dd7 100644 --- a/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts @@ -8,7 +8,7 @@ export enum GitLabConnectionMethod { OAuth = "oauth" } -export type TGitLabConnection = TRootAppConnection & { app: AppConnection.Gitlab } & ( +export type TGitLabConnection = TRootAppConnection & { app: AppConnection.GitLab } & ( | { method: GitLabConnectionMethod.AccessToken; credentials: { diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 2924c3009..f07dcd283 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -24,6 +24,7 @@ import { THumanitecConnection } from "./humanitec-connection"; import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TMySqlConnection } from "./mysql-connection"; +import { TNetlifyConnection } from "./netlify-connection"; import { TOCIConnection } from "./oci-connection"; import { TOktaConnection } from "./okta-connection"; import { TOracleDBConnection } from "./oracledb-connection"; @@ -109,6 +110,7 @@ export type TAppConnection = | TChecklyConnection | TSupabaseConnection | TDigitalOceanConnection + | TNetlifyConnection | TOktaConnection; export type TAvailableAppConnection = Pick; @@ -173,7 +175,7 @@ export type TAppConnectionMap = { [AppConnection.Heroku]: THerokuConnection; [AppConnection.Render]: TRenderConnection; [AppConnection.Flyio]: TFlyioConnection; - [AppConnection.Gitlab]: TGitLabConnection; + [AppConnection.GitLab]: TGitLabConnection; [AppConnection.Cloudflare]: TCloudflareConnection; [AppConnection.Bitbucket]: TBitbucketConnection; [AppConnection.Zabbix]: TZabbixConnection; @@ -181,5 +183,6 @@ export type TAppConnectionMap = { [AppConnection.Checkly]: TChecklyConnection; [AppConnection.Supabase]: TSupabaseConnection; [AppConnection.DigitalOcean]: TDigitalOceanConnection; + [AppConnection.Netlify]: TNetlifyConnection; [AppConnection.Okta]: TOktaConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/netlify-connection.ts b/frontend/src/hooks/api/appConnections/types/netlify-connection.ts new file mode 100644 index 000000000..567664d96 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/netlify-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 NetlifyConnectionMethod { + AccessToken = "access-token" +} + +export type TNetlifyConnection = TRootAppConnection & { + app: AppConnection.Netlify; + method: NetlifyConnectionMethod.AccessToken; + credentials: { + accessToken: string; + }; +}; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 6d05b5ef3..fa59b3ae0 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,6 +1,7 @@ +import { AppConnection } from "../appConnections/enums"; import { SshCaStatus } from "../sshCa"; import { SshCertTemplateStatus } from "../sshCertificateTemplates"; -import { CaStatus, InternalCaType } from "./enums"; +import { AcmeDnsProvider, CaStatus, InternalCaType } from "./enums"; export const caTypeToNameMap: { [K in InternalCaType]: string } = { [InternalCaType.ROOT]: "Root", @@ -13,6 +14,16 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" }; +export const ACME_DNS_PROVIDER_NAME_MAP: Record = { + [AcmeDnsProvider.ROUTE53]: "Route53", + [AcmeDnsProvider.Cloudflare]: "Cloudflare" +}; + +export const ACME_DNS_PROVIDER_APP_CONNECTION_MAP: Record = { + [AcmeDnsProvider.ROUTE53]: AppConnection.AWS, + [AcmeDnsProvider.Cloudflare]: AppConnection.Cloudflare +}; + export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => { switch (status) { case CaStatus.ACTIVE: diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index b2b725612..b010faa07 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -19,5 +19,6 @@ export enum CaRenewalType { } export enum AcmeDnsProvider { - ROUTE53 = "route53" + ROUTE53 = "route53", + Cloudflare = "cloudflare" } diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index f3703fe52..f9f812cb2 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -11,7 +11,7 @@ export type TAcmeCertificateAuthority = { configuration: { dnsAppConnectionId: string; dnsProviderConfig: { - provider: AcmeDnsProvider.ROUTE53; + provider: AcmeDnsProvider; hostedZoneId: string; }; directoryUrl: string; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 165c94395..c01f1aa85 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -1385,6 +1385,7 @@ export const useAddIdentityLdapAuth = () => { return useMutation({ mutationFn: async ({ identityId, + templateId, url, bindDN, bindPass, @@ -1400,6 +1401,7 @@ export const useAddIdentityLdapAuth = () => { const { data } = await apiRequest.post<{ identityLdapAuth: IdentityLdapAuth }>( `/api/v1/auth/ldap-auth/identities/${identityId}`, { + templateId, url, bindDN, bindPass, @@ -1432,6 +1434,7 @@ export const useUpdateIdentityLdapAuth = () => { return useMutation({ mutationFn: async ({ identityId, + templateId, url, bindDN, bindPass, @@ -1447,6 +1450,7 @@ export const useUpdateIdentityLdapAuth = () => { const { data } = await apiRequest.patch<{ identityLdapAuth: IdentityLdapAuth }>( `/api/v1/auth/ldap-auth/identities/${identityId}`, { + templateId, url, bindDN, bindPass, diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 1af1cc24c..c0e49e987 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -567,10 +567,11 @@ export type IdentityTokenAuth = { export type AddIdentityLdapAuthDTO = { organizationId: string; identityId: string; - url: string; - bindDN: string; - bindPass: string; - searchBase: string; + templateId?: string; + url?: string; + bindDN?: string; + bindPass?: string; + searchBase?: string; searchFilter: string; ldapCaCertificate?: string; allowedFields?: { @@ -588,6 +589,7 @@ export type AddIdentityLdapAuthDTO = { export type UpdateIdentityLdapAuthDTO = { identityId: string; organizationId: string; + templateId?: string; url?: string; bindDN?: string; bindPass?: string; @@ -612,10 +614,11 @@ export type DeleteIdentityLdapAuthDTO = { }; export type IdentityLdapAuth = { - url: string; - bindDN: string; - bindPass: string; - searchBase: string; + url?: string; + bindDN?: string; + templateId?: string; + bindPass?: string; + searchBase?: string; searchFilter: string; ldapCaCertificate?: string; allowedFields?: { diff --git a/frontend/src/hooks/api/identityAuthTemplates/index.tsx b/frontend/src/hooks/api/identityAuthTemplates/index.tsx new file mode 100644 index 000000000..177955438 --- /dev/null +++ b/frontend/src/hooks/api/identityAuthTemplates/index.tsx @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/identityAuthTemplates/mutations.tsx b/frontend/src/hooks/api/identityAuthTemplates/mutations.tsx new file mode 100644 index 000000000..8c05f1e46 --- /dev/null +++ b/frontend/src/hooks/api/identityAuthTemplates/mutations.tsx @@ -0,0 +1,97 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { identityAuthTemplatesKeys } from "./queries"; +import { + CreateIdentityAuthTemplateDTO, + DeleteIdentityAuthTemplateDTO, + IdentityAuthTemplate, + MachineAuthTemplateUsage, + UnlinkTemplateUsageDTO, + UpdateIdentityAuthTemplateDTO +} from "./types"; + +export const useCreateIdentityAuthTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: CreateIdentityAuthTemplateDTO) => { + const { data } = await apiRequest.post<{ template: IdentityAuthTemplate }>( + "/api/v1/identity-templates", + dto + ); + return data.template; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplates({ organizationId }) + }); + } + }); +}; + +export const useUpdateIdentityAuthTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: UpdateIdentityAuthTemplateDTO) => { + const { data } = await apiRequest.patch<{ template: IdentityAuthTemplate }>( + `/api/v1/identity-templates/${dto.templateId}`, + dto + ); + return data.template; + }, + onSuccess: (_, { organizationId, templateId }) => { + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplates({ organizationId }) + }); + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplate(templateId) + }); + } + }); +}; + +export const useDeleteIdentityAuthTemplate = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: DeleteIdentityAuthTemplateDTO) => { + await apiRequest.delete(`/api/v1/identity-templates/${dto.templateId}`, { + params: { organizationId: dto.organizationId } + }); + }, + onSuccess: (_, { organizationId, templateId }) => { + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplates({ organizationId }) + }); + queryClient.removeQueries({ + queryKey: identityAuthTemplatesKeys.getTemplate(templateId) + }); + } + }); +}; + +export const useUnlinkTemplateUsage = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: UnlinkTemplateUsageDTO) => { + const { data } = await apiRequest.post( + `/api/v1/identity-templates/${dto.templateId}/delete-usage`, + { identityIds: dto.identityIds }, + { params: { organizationId: dto.organizationId } } + ); + return data; + }, + onSuccess: (_, { templateId, organizationId }) => { + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplateUsages(templateId) + }); + queryClient.invalidateQueries({ + queryKey: identityAuthTemplatesKeys.getTemplates({ organizationId }) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/identityAuthTemplates/queries.tsx b/frontend/src/hooks/api/identityAuthTemplates/queries.tsx new file mode 100644 index 000000000..a9981c0c4 --- /dev/null +++ b/frontend/src/hooks/api/identityAuthTemplates/queries.tsx @@ -0,0 +1,89 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + GetIdentityAuthTemplatesDTO, + GetTemplateUsagesDTO, + IdentityAuthTemplate, + MachineAuthTemplateUsage, + MachineIdentityAuthMethod +} from "./types"; + +export const identityAuthTemplatesKeys = { + all: ["identity-auth-templates"] as const, + getTemplates: (dto: GetIdentityAuthTemplatesDTO) => + [...identityAuthTemplatesKeys.all, "list", dto] as const, + getTemplate: (templateId: string) => + [...identityAuthTemplatesKeys.all, "single", templateId] as const, + getAvailableTemplates: (authMethod: MachineIdentityAuthMethod) => + [...identityAuthTemplatesKeys.all, "available", authMethod] as const, + getTemplateUsages: (templateId: string) => + [...identityAuthTemplatesKeys.all, "usages", templateId] as const +}; + +export const useGetIdentityAuthTemplates = (dto: GetIdentityAuthTemplatesDTO) => { + return useQuery({ + queryKey: identityAuthTemplatesKeys.getTemplates(dto), + queryFn: async () => { + const { data } = await apiRequest.get<{ + templates: IdentityAuthTemplate[]; + totalCount: number; + }>("/api/v1/identity-templates/search", { + params: { + organizationId: dto.organizationId, + limit: dto.limit || 50, + offset: dto.offset || 0, + ...(dto.search && { search: dto.search }) + } + }); + return data; + }, + enabled: Boolean(dto.organizationId) + }); +}; + +export const useGetIdentityAuthTemplate = (templateId: string, organizationId: string) => { + return useQuery({ + queryKey: identityAuthTemplatesKeys.getTemplate(templateId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/identity-templates/${templateId}`, + { + params: { organizationId } + } + ); + return data; + }, + enabled: Boolean(templateId) && Boolean(organizationId) + }); +}; + +export const useGetAvailableTemplates = (authMethod: MachineIdentityAuthMethod) => { + return useQuery({ + queryKey: identityAuthTemplatesKeys.getAvailableTemplates(authMethod), + queryFn: async () => { + const { data } = await apiRequest.get("/api/v1/identity-templates", { + params: { authMethod } + }); + return data; + }, + enabled: Boolean(authMethod) + }); +}; + +export const useGetTemplateUsages = (dto: GetTemplateUsagesDTO) => { + return useQuery({ + queryKey: identityAuthTemplatesKeys.getTemplateUsages(dto.templateId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/identity-templates/${dto.templateId}/usage`, + { + params: { organizationId: dto.organizationId } + } + ); + return data; + }, + enabled: Boolean(dto.templateId) && Boolean(dto.organizationId) + }); +}; diff --git a/frontend/src/hooks/api/identityAuthTemplates/types.ts b/frontend/src/hooks/api/identityAuthTemplates/types.ts new file mode 100644 index 000000000..860f9a9fc --- /dev/null +++ b/frontend/src/hooks/api/identityAuthTemplates/types.ts @@ -0,0 +1,78 @@ +export enum MachineIdentityAuthMethod { + LDAP = "ldap" +} + +export interface LdapTemplateFields { + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + ldapCaCertificate?: string; +} + +export interface IdentityAuthTemplate { + id: string; + name: string; + authMethod: MachineIdentityAuthMethod; + organizationId: string; + templateFields: LdapTemplateFields; + createdAt: string; + updatedAt: string; +} + +export interface CreateIdentityAuthTemplateDTO { + organizationId: string; + name: string; + authMethod: MachineIdentityAuthMethod; + templateFields: LdapTemplateFields; +} + +export interface UpdateIdentityAuthTemplateDTO { + templateId: string; + organizationId: string; + name?: string; + templateFields?: Partial; +} + +export interface DeleteIdentityAuthTemplateDTO { + templateId: string; + organizationId: string; +} + +export interface GetIdentityAuthTemplatesDTO { + organizationId: string; + limit?: number; + offset?: number; + search?: string; +} + +export interface MachineAuthTemplateUsage { + identityId: string; + identityName: string; +} + +export interface GetTemplateUsagesDTO { + templateId: string; + organizationId: string; +} + +export interface UnlinkTemplateUsageDTO { + templateId: string; + identityIds: string[]; + organizationId: string; +} + +export const TEMPLATE_ERROR_MESSAGES = { + UNLINK_SUCCESS: "Successfully unlinked template usages", + UNLINK_FAILED: "Failed to unlink template usages", + SINGLE_UNLINK_SUCCESS: "Successfully unlinked template usage", + SINGLE_UNLINK_FAILED: "Failed to unlink template usage" +} as const; + +export const TEMPLATE_UI_LABELS = { + VIEW_USAGES: "View Usages", + EDIT_TEMPLATE: "Edit Template", + DELETE_TEMPLATE: "Delete Template", + UNLINK: "Unlink", + UNSELECT_ALL: "Unselect All" +} as const; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 4b4967f16..78dcd3b79 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -15,6 +15,7 @@ export * from "./gateways"; export * from "./githubOrgSyncConfig"; export * from "./groups"; export * from "./identities"; +export * from "./identityAuthTemplates"; export * from "./identityProjectAdditionalPrivilege"; export * from "./incidentContacts"; export * from "./integrationAuth"; diff --git a/frontend/src/hooks/api/reminders/queries.tsx b/frontend/src/hooks/api/reminders/queries.tsx index 68c00913d..557bc24ea 100644 --- a/frontend/src/hooks/api/reminders/queries.tsx +++ b/frontend/src/hooks/api/reminders/queries.tsx @@ -12,14 +12,15 @@ export const useCreateReminder = (secretId: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ message, repeatDays, nextReminderDate, recipients }) => { + mutationFn: async ({ message, repeatDays, nextReminderDate, recipients, fromDate }) => { const { data } = await apiRequest.post<{ reminder: Reminder }>( `/api/v1/reminders/secrets/${secretId}`, { message, repeatDays, nextReminderDate, - recipients + recipients, + fromDate } ); return data.reminder; diff --git a/frontend/src/hooks/api/reminders/types.ts b/frontend/src/hooks/api/reminders/types.ts index 245805aea..cbe96aeaf 100644 --- a/frontend/src/hooks/api/reminders/types.ts +++ b/frontend/src/hooks/api/reminders/types.ts @@ -2,6 +2,7 @@ export type CreateReminderDTO = { message?: string | null; repeatDays?: number | null; nextReminderDate?: Date | null; + fromDate?: Date | null; secretId: string; recipients?: string[]; }; diff --git a/frontend/src/hooks/api/secretScanningV2/enums.ts b/frontend/src/hooks/api/secretScanningV2/enums.ts index 40e5ea7dd..a4fd7bd34 100644 --- a/frontend/src/hooks/api/secretScanningV2/enums.ts +++ b/frontend/src/hooks/api/secretScanningV2/enums.ts @@ -1,6 +1,7 @@ export enum SecretScanningDataSource { GitHub = "github", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + GitLab = "gitlab" } export enum SecretScanningScanStatus { diff --git a/frontend/src/hooks/api/secretScanningV2/types/gitlab-data-source.ts b/frontend/src/hooks/api/secretScanningV2/types/gitlab-data-source.ts new file mode 100644 index 000000000..82f1002e0 --- /dev/null +++ b/frontend/src/hooks/api/secretScanningV2/types/gitlab-data-source.ts @@ -0,0 +1,31 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { SecretScanningDataSource } from "../enums"; +import { TSecretScanningDataSourceBase } from "./shared"; + +export enum GitLabDataSourceScope { + Project = "project", + Group = "group" +} + +export type TGitLabDataSource = TSecretScanningDataSourceBase & { + type: SecretScanningDataSource.GitLab; + config: + | { + groupId: number; + groupName?: string; + includeProjects: string[]; + scope: GitLabDataSourceScope.Group; + } + | { + projectName?: string; + projectId: number; + scope: GitLabDataSourceScope.Project; + }; +}; + +export type TGitLabDataSourceOption = { + name: string; + type: SecretScanningDataSource.GitLab; + connection: AppConnection.GitLab; +}; diff --git a/frontend/src/hooks/api/secretScanningV2/types/index.ts b/frontend/src/hooks/api/secretScanningV2/types/index.ts index 078a7051d..2c45c9bcf 100644 --- a/frontend/src/hooks/api/secretScanningV2/types/index.ts +++ b/frontend/src/hooks/api/secretScanningV2/types/index.ts @@ -1,3 +1,7 @@ +import { + TGitLabDataSource, + TGitLabDataSourceOption +} from "@app/hooks/api/secretScanningV2/types/gitlab-data-source"; import { DiscriminativePick } from "@app/types"; import { @@ -11,7 +15,10 @@ import { import { TBitbucketDataSource, TBitbucketDataSourceOption } from "./bitbucket-data-source"; import { TGitHubDataSource, TGitHubDataSourceOption } from "./github-data-source"; -export type TSecretScanningDataSource = TGitHubDataSource | TBitbucketDataSource; +export type TSecretScanningDataSource = + | TGitHubDataSource + | TBitbucketDataSource + | TGitLabDataSource; export type TSecretScanningDataSourceWithDetails = TSecretScanningDataSource & { lastScannedAt: string | null; @@ -24,7 +31,10 @@ export type TListSecretScanningDataSources = { dataSources: TSecretScanningDataSourceWithDetails[]; }; -export type TSecretScanningDataSourceOption = TGitHubDataSourceOption | TBitbucketDataSourceOption; +export type TSecretScanningDataSourceOption = + | TGitHubDataSourceOption + | TBitbucketDataSourceOption + | TGitLabDataSourceOption; export type TListSecretScanningDataSourceOptions = { dataSourceOptions: TSecretScanningDataSourceOption[]; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 11de8cd62..c230cd9c6 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -27,6 +27,7 @@ export enum SecretSync { Railway = "railway", Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", + Netlify = "netlify", Bitbucket = "bitbucket" } diff --git a/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts b/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts index ed88ab685..cf5fc5e0e 100644 --- a/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts @@ -29,7 +29,7 @@ export type TGitLabSync = TRootSecretSync & { shouldHideSecrets?: boolean; }; connection: { - app: AppConnection.Gitlab; + app: AppConnection.GitLab; name: string; id: string; }; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 2ab76341b..f7acda1f4 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -21,6 +21,7 @@ import { TGitLabSync } from "./gitlab-sync"; import { THCVaultSync } from "./hc-vault-sync"; import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; +import { TNetlifySync } from "./netlify-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; import { TRailwaySync } from "./railway-sync"; import { TRenderSync } from "./render-sync"; @@ -67,6 +68,7 @@ export type TSecretSync = | TChecklySync | TSupabaseSync | TDigitalOceanAppPlatformSync + | TNetlifySync | TBitbucketSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/netlify-sync.ts b/frontend/src/hooks/api/secretSyncs/types/netlify-sync.ts new file mode 100644 index 000000000..1163224ef --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/netlify-sync.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ +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 TNetlifySync = TRootSecretSync & { + destination: SecretSync.Netlify; + destinationConfig: { + accountId: string; + accountName: string; + siteId?: string; + siteName?: string; + context?: string; + }; + connection: { + app: AppConnection.Netlify; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/serverDetails/types.ts b/frontend/src/hooks/api/serverDetails/types.ts index 3e22c2684..d43521382 100644 --- a/frontend/src/hooks/api/serverDetails/types.ts +++ b/frontend/src/hooks/api/serverDetails/types.ts @@ -5,4 +5,5 @@ export type ServerStatus = { secretScanningConfigured: boolean; redisConfigured: boolean; samlDefaultOrgSlug: string; + auditLogStorageDisabled: boolean; }; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 87a02231f..4ded71cfe 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -53,4 +53,5 @@ export type SubscriptionPlan = { secretScanning: boolean; enterpriseSecretSyncs: boolean; enterpriseAppConnections: boolean; + machineIdentityAuthTemplates: boolean; }; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index b53b980fd..77289fee6 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -17,7 +17,8 @@ import { APIKeyData, AuthMethod, CreateAPIKeyRes, - DeletOrgMembershipDTO, + DeleteOrgMembershipBatchDTO, + DeleteOrgMembershipDTO, OrgUser, RenameUserDTO, TokenVersion, @@ -243,7 +244,7 @@ export const useGetOrgMembershipProjectMemberships = ( export const useDeleteOrgMembership = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: ({ membershipId, orgId }) => { return apiRequest.delete(`/api/v2/organizations/${orgId}/memberships/${membershipId}`); }, @@ -253,10 +254,27 @@ export const useDeleteOrgMembership = () => { }); }; +export const useDeleteOrgMembershipBatch = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ membershipIds, orgId }) => { + return apiRequest.delete(`/api/v2/organizations/${orgId}/memberships`, { + data: { + membershipIds + } + }); + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries({ queryKey: userKeys.getOrgUsers(orgId) }); + } + }); +}; + export const useDeactivateOrgMembership = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: ({ membershipId, orgId }) => { return apiRequest.post( `/api/v2/organizations/${orgId}/memberships/${membershipId}/deactivate` diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 963ab100e..48da6f7d1 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -143,11 +143,16 @@ export type UpdateOrgMembershipDTO = { metadata?: { key: string; value: string }[]; }; -export type DeletOrgMembershipDTO = { +export type DeleteOrgMembershipDTO = { membershipId: string; orgId: string; }; +export type DeleteOrgMembershipBatchDTO = { + membershipIds: string[]; + orgId: string; +}; + export type AddUserToOrgDTO = { inviteeEmails: string[]; organizationRoleSlug: string; diff --git a/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx b/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx index 9064eec7f..c33f81699 100644 --- a/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx +++ b/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx @@ -6,12 +6,16 @@ import { twMerge } from "tailwind-merge"; import { CreateOrgModal } from "@app/components/organization/CreateOrgModal"; import { Banner } from "@app/components/page-frames/Banner"; -import { useServerConfig } from "@app/context"; +import { useServerConfig, useSubscription } from "@app/context"; import { usePopUp } from "@app/hooks"; +import { useFetchServerStatus } from "@app/hooks/api"; +import { AuditLogBanner } from "./components/AuditLogBanner"; import { InsecureConnectionBanner } from "./components/InsecureConnectionBanner"; import { Navbar } from "./components/NavBar"; import { OrgSidebar } from "./components/OrgSidebar"; +import { RedisBanner } from "./components/RedisBanner"; +import { SmtpBanner } from "./components/SmtpBanner"; export const OrganizationLayout = () => { const { config } = useServerConfig(); @@ -27,6 +31,9 @@ export const OrganizationLayout = () => { const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen"; + const { data: serverDetails, isLoading } = useFetchServerStatus(); + const { subscription } = useSubscription(); + return ( <> @@ -34,6 +41,9 @@ export const OrganizationLayout = () => { className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`} > + {!isLoading && !serverDetails?.redisConfigured && } + {!isLoading && !serverDetails?.emailConfigured && } + {!isLoading && subscription.auditLogs && } {!window.isSecureContext && }
diff --git a/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx b/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx new file mode 100644 index 000000000..9f7494076 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/AuditLogBanner.tsx @@ -0,0 +1,23 @@ +import { useOrganization } from "@app/context"; +import { useFetchServerStatus, useGetAuditLogStreams } from "@app/hooks/api"; + +import { OrgAlertBanner } from "../OrgAlertBanner"; + +export const AuditLogBanner = () => { + const org = useOrganization(); + const { data: status, isLoading: isLoadingStatus } = useFetchServerStatus(); + const { data: streams, isLoading: isLoadingStreams } = useGetAuditLogStreams(org.currentOrg.id); + + if (isLoadingStreams || isLoadingStatus || !streams) return null; + + if (status?.auditLogStorageDisabled && !streams.length) { + return ( + + ); + } + + return null; +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/index.ts b/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/index.ts new file mode 100644 index 000000000..c7bf7d475 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/AuditLogBanner/index.ts @@ -0,0 +1 @@ +export * from "./AuditLogBanner"; diff --git a/frontend/src/layouts/OrganizationLayout/components/InsecureConnectionBanner/InsecureConnectionBanner.tsx b/frontend/src/layouts/OrganizationLayout/components/InsecureConnectionBanner/InsecureConnectionBanner.tsx index e213276b3..084fb6ff5 100644 --- a/frontend/src/layouts/OrganizationLayout/components/InsecureConnectionBanner/InsecureConnectionBanner.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/InsecureConnectionBanner/InsecureConnectionBanner.tsx @@ -1,37 +1,10 @@ -import { useState } from "react"; -import { faWarning, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { IconButton } from "@app/components/v2"; +import { OrgAlertBanner } from "../OrgAlertBanner"; export const InsecureConnectionBanner = () => { - const [isAcknowledged, setIsAcknowledged] = useState( - localStorage.getItem("insecureConnectionAcknowledged") ?? false - ); - - const handleDismiss = () => { - setIsAcknowledged(true); - localStorage.setItem("insecureConnectionAcknowledged", "true"); - }; - - if (isAcknowledged) return null; - return ( -
- - - Your connection to this Infisical instance is not secured via HTTPS. Some features may not - behave as expected. - - - - -
+ ); }; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/OrgAlertBanner.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/OrgAlertBanner.tsx new file mode 100644 index 000000000..de4b65a71 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/OrgAlertBanner.tsx @@ -0,0 +1,52 @@ +import { faArrowUpRightFromSquare, faWarning, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +type Props = { + text: string; + link?: string; +}; + +export const OrgAlertBanner = ({ text, link }: Props) => { + const [isDismissed, setIsDismissed] = useToggle(false); + + if (isDismissed) return null; + + return ( +
+ + {text}{" "} + {link && ( + <> + Learn how to configure it + + + here + + + + . + + )} + setIsDismissed.on()} + > + + +
+ ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/index.ts b/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/index.ts new file mode 100644 index 000000000..277fc4064 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/OrgAlertBanner/index.ts @@ -0,0 +1 @@ +export * from "./OrgAlertBanner"; diff --git a/frontend/src/layouts/OrganizationLayout/components/RedisBanner/RedisBanner.tsx b/frontend/src/layouts/OrganizationLayout/components/RedisBanner/RedisBanner.tsx new file mode 100644 index 000000000..4fe89d238 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/RedisBanner/RedisBanner.tsx @@ -0,0 +1,10 @@ +import { OrgAlertBanner } from "../OrgAlertBanner"; + +export const RedisBanner = () => { + return ( + + ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/RedisBanner/index.ts b/frontend/src/layouts/OrganizationLayout/components/RedisBanner/index.ts new file mode 100644 index 000000000..d5d6821e3 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/RedisBanner/index.ts @@ -0,0 +1 @@ +export * from "./RedisBanner"; diff --git a/frontend/src/layouts/OrganizationLayout/components/SmtpBanner/SmtpBanner.tsx b/frontend/src/layouts/OrganizationLayout/components/SmtpBanner/SmtpBanner.tsx new file mode 100644 index 000000000..624dde04d --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/SmtpBanner/SmtpBanner.tsx @@ -0,0 +1,10 @@ +import { OrgAlertBanner } from "../OrgAlertBanner"; + +export const SmtpBanner = () => { + return ( + + ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/SmtpBanner/index.ts b/frontend/src/layouts/OrganizationLayout/components/SmtpBanner/index.ts new file mode 100644 index 000000000..305503a79 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/SmtpBanner/index.ts @@ -0,0 +1 @@ +export * from "./SmtpBanner"; diff --git a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx b/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx index 7d4527032..9c44b72c4 100644 --- a/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx +++ b/frontend/src/pages/admin/MachineIdentitiesResourcesPage/components/MachineIdentitiesTable.tsx @@ -1,5 +1,11 @@ import { useState } from "react"; -import { faEllipsis, faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons"; +import { + faEllipsisV, + faMagnifyingGlass, + faServer, + faShieldHalved, + faXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; @@ -12,6 +18,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, EmptyState, + IconButton, Input, Table, TableContainer, @@ -88,18 +95,33 @@ const IdentityPanelTable = ({ {isInstanceAdmin && (
- -
- -
+ + + + - + {isInstanceAdmin && ( { e.stopPropagation(); handlePopUpOpen("removeServerAdmin", { name, id }); }} + icon={ +
+ + +
+ } > Remove Server Admin
@@ -125,7 +147,7 @@ const IdentityPanelTable = ({ isDisabled={isFetchingNextPage || !hasNextPage} onClick={() => fetchNextPage()} > - {hasNextPage ? "Load More" : "End of list"} + {hasNextPage ? "Load More" : "End of List"} )}
diff --git a/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx b/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx index 500796797..1f2471aa2 100644 --- a/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx +++ b/frontend/src/pages/admin/UserIdentitiesResourcesPage/components/UserIdentitiesTable.tsx @@ -1,13 +1,19 @@ -import { useState } from "react"; +import { Dispatch, SetStateAction, useState } from "react"; import { faCheckCircle, - faEllipsis, + faEllipsisV, faFilter, faMagnifyingGlass, + faShieldHalved, + faTrash, faUsers, - faUserShield + faUserShield, + faUserXmark, + faWarning, + faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { InfiniteData } from "@tanstack/react-query"; import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; @@ -15,6 +21,7 @@ import { createNotification } from "@app/components/notifications"; import { Badge, Button, + Checkbox, DeleteActionModal, DropdownMenu, DropdownMenuContent, @@ -31,23 +38,37 @@ import { Td, Th, THead, + Tooltip, Tr } from "@app/components/v2"; -import { useSubscription } from "@app/context"; +import { useSubscription, useUser } from "@app/context"; import { useDebounce, usePopUp } from "@app/hooks"; import { + useAdminBulkDeleteUsers, useAdminDeleteUser, useAdminGetUsers, useAdminGrantServerAdminAccess, useRemoveUserServerAdminAccess } from "@app/hooks/api"; +import { User } from "@app/hooks/api/users/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; const addServerAdminUpgradePlanMessage = "Granting another user Server Admin permissions"; const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user"; const UserPanelTable = ({ - handlePopUpOpen + handlePopUpOpen, + users: usersPages, + isPending, + adminsOnly, + searchUserFilter, + setSearchUserFilter, + setAdminsOnly, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + selectedUsers, + setSelectedUsers }: { handlePopUpOpen: ( popUpName: keyof UsePopUpState< @@ -59,21 +80,38 @@ const UserPanelTable = ({ message?: string; } ) => void; + isPending: boolean; + users: InfiniteData | undefined; + adminsOnly: boolean; + setAdminsOnly: (adminsOnly: boolean) => void; + searchUserFilter: string; + setSearchUserFilter: (filter: string) => void; + selectedUsers: User[]; + setSelectedUsers: Dispatch>; + isFetchingNextPage: boolean; + fetchNextPage: () => void; + hasNextPage: boolean; }) => { - const [searchUserFilter, setSearchUserFilter] = useState(""); - const [adminsOnly, setAdminsOnly] = useState(false); - const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); const { subscription } = useSubscription(); - const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({ - limit: 20, - searchTerm: debouncedSearchTerm, - adminsOnly - }); + const users = usersPages?.pages.flat(); - const isEmpty = !isPending && !data?.pages?.[0].length; + const isEmpty = !isPending && !users?.length; const isTableFiltered = Boolean(adminsOnly); + const selectedUserIds = selectedUsers.map((user) => user.id); + + const isPageSelected = users?.length + ? users.every((user) => selectedUserIds.includes(user.id)) + : false; + + // eslint-disable-next-line no-nested-ternary + const isPageIndeterminate = isPageSelected + ? false + : users?.length + ? users?.some((user) => selectedUserIds.includes(user.id)) + : false; + return ( <>
@@ -121,90 +159,144 @@ const UserPanelTable = ({ + - - + {isPending && } {!isPending && - data?.pages?.map((users) => - users.map(({ username, email, firstName, lastName, id, superAdmin }) => { - const name = firstName || lastName ? `${firstName} ${lastName}` : "-"; + users?.map((user) => { + const { username, email, firstName, lastName, id, superAdmin } = user; + const name = firstName || lastName ? `${firstName} ${lastName}` : null; - return ( - - + + - - + + - - ); - }) - )} + )} + {superAdmin && ( + + + + + } + onClick={(e) => { + e.stopPropagation(); + if (!subscription?.instanceUserManagement) { + handlePopUpOpen("upgradePlan", { + username, + id, + message: removeServerAdminUpgradePlanMessage + }); + return; + } + handlePopUpOpen("removeServerAdmin", { username, id }); + }} + > + Remove Server Admin + + )} + + + + + + ); + })}
+ { + if (isPageSelected) { + setSelectedUsers((prev) => + prev.filter((u) => !users?.find((user) => user.id === u.id)) + ); + } else { + setSelectedUsers((prev) => [ + ...prev, + ...(users?.filter((u) => !prev.find((user) => user.id === u.id)) ?? []) + ]); + } + }} + /> + NameUsername + Username
- {name} + const isSelected = selectedUserIds.includes(id); + return ( +
+ { + e.stopPropagation(); + setSelectedUsers((prev) => + isSelected ? prev.filter((u) => u.id !== id) : [...prev, user] + ); + }} + /> + +
+

+ {name ?? Not Set} +

{superAdmin && ( - + Server Admin )} -
{email} -
- - -
- -
-
- +
+
+

{email}

+
+
+ + + + + + + + { + e.stopPropagation(); + handlePopUpOpen("removeUser", { username, id }); + }} + icon={} + > + Remove User + + {!superAdmin && ( } onClick={(e) => { e.stopPropagation(); - handlePopUpOpen("removeUser", { username, id }); + if (!subscription?.instanceUserManagement) { + handlePopUpOpen("upgradePlan", { + username, + id, + message: addServerAdminUpgradePlanMessage + }); + return; + } + handlePopUpOpen("upgradeToServerAdmin", { username, id }); }} > - Remove User + Make User Server Admin - {!superAdmin && ( - { - e.stopPropagation(); - if (!subscription?.instanceUserManagement) { - handlePopUpOpen("upgradePlan", { - username, - id, - message: addServerAdminUpgradePlanMessage - }); - return; - } - handlePopUpOpen("upgradeToServerAdmin", { username, id }); - }} - > - Make User Server Admin - - )} - {superAdmin && ( - { - e.stopPropagation(); - if (!subscription?.instanceUserManagement) { - handlePopUpOpen("upgradePlan", { - username, - id, - message: removeServerAdminUpgradePlanMessage - }); - return; - } - handlePopUpOpen("removeServerAdmin", { username, id }); - }} - > - Remove Server Admin - - )} - - -
-
{!isPending && isEmpty && } @@ -218,7 +310,7 @@ const UserPanelTable = ({ isDisabled={isFetchingNextPage || !hasNextPage} onClick={() => fetchNextPage()} > - {hasNextPage ? "Load More" : "End of list"} + {hasNextPage ? "Load More" : "End of List"} )}
@@ -231,13 +323,36 @@ export const UserIdentitiesTable = () => { "removeUser", "upgradePlan", "upgradeToServerAdmin", - "removeServerAdmin" + "removeServerAdmin", + "removeUsers" ] as const); + const { + user: { id: userId } + } = useUser(); + const { mutateAsync: deleteUser } = useAdminDeleteUser(); + const { mutateAsync: deleteUsers } = useAdminBulkDeleteUsers(); const { mutateAsync: grantAdminAccess } = useAdminGrantServerAdminAccess(); const { mutateAsync: removeAdminAccess } = useRemoveUserServerAdminAccess(); + const [selectedUsers, setSelectedUsers] = useState([]); + const [searchUserFilter, setSearchUserFilter] = useState(""); + const [adminsOnly, setAdminsOnly] = useState(false); + const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); + + const { + data: users, + isPending, + isFetchingNextPage, + hasNextPage, + fetchNextPage + } = useAdminGetUsers({ + limit: 20, + searchTerm: debouncedSearchTerm, + adminsOnly + }); + const handleRemoveUser = async () => { const { id } = popUp?.removeUser?.data as { id: string; username: string }; @@ -295,45 +410,158 @@ export const UserIdentitiesTable = () => { handlePopUpClose("removeServerAdmin"); }; + const handleRemoveUsers = async () => { + try { + await deleteUsers(selectedUsers.map((user) => user.id)); + + createNotification({ + text: "Successfully removed users", + type: "success" + }); + + setSelectedUsers([]); + handlePopUpClose("removeUsers"); + } catch { + createNotification({ + text: "Failed to remove users", + type: "error" + }); + } + }; + return ( -
- - handlePopUpToggle("removeUser", isOpen)} - onDeleteApproved={handleRemoveUser} - /> - handlePopUpToggle("upgradeToServerAdmin", isOpen)} - deleteKey="confirm" - onDeleteApproved={handleGrantServerAdminAccess} - buttonText="Grant Access" - /> - handlePopUpToggle("removeServerAdmin", isOpen)} - deleteKey="confirm" - onDeleteApproved={handleRemoveServerAdminAccess} - buttonText="Remove Access" - /> - handlePopUpToggle("upgradePlan", isOpen)} - text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`} - /> -
+ <> +
0 && "h-16" + )} + > +
+
{selectedUsers.length} Selected
+ + +
+
+
+ + handlePopUpToggle("removeUser", isOpen)} + onDeleteApproved={handleRemoveUser} + /> + handlePopUpToggle("upgradeToServerAdmin", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleGrantServerAdminAccess} + buttonText="Grant Access" + /> + handlePopUpToggle("removeServerAdmin", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleRemoveServerAdminAccess} + buttonText="Remove Access" + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`} + /> + handlePopUpToggle("removeUsers", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => handleRemoveUsers()} + buttonText="Remove" + > +
+ The following members will be removed: +
+
+
    + {selectedUsers?.map((user) => { + const email = user.email ?? user.username; + return ( +
  • +
    +

    + {user.firstName || user.lastName ? ( + <> + {`${`${user.firstName} ${user.lastName}`.trim()} `}( + {email}) + + ) : ( + {email} + )}{" "} +

    + {userId === user.id && ( + +
    + + + Removing Yourself + +
    +
    + )} +
    +
  • + ); + })} +
+
+
+
+ ); }; diff --git a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx index b347a78d8..9666a1af4 100644 --- a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx @@ -11,7 +11,7 @@ import { createNotification } from "@app/components/notifications"; import attemptCliLogin from "@app/components/utilities/attemptCliLogin"; import attemptLogin from "@app/components/utilities/attemptLogin"; import SecurityClient from "@app/components/utilities/SecurityClient"; -import { Button, Input, Spinner } from "@app/components/v2"; +import { Button, ContentLoader, Input } from "@app/components/v2"; import { envConfig } from "@app/config/env"; import { SessionStorageKeys } from "@app/const"; import { useToggle } from "@app/hooks"; @@ -326,9 +326,8 @@ export const PasswordStep = ({ if (hasExchangedPrivateKey) { return ( -
- -

Loading, please wait

+
+
); } diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx index f66be2d4b..b22f0f0fa 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx @@ -1,26 +1,21 @@ import { useState } from "react"; -import { Spinner } from "@app/components/v2"; +import { ContentLoader } from "@app/components/v2"; import { useGetMyDuplicateAccount } from "@app/hooks/api"; import { EmailDuplicationConfirmation } from "./EmailDuplicationConfirmation"; import { SelectOrganizationSection } from "./SelectOrgSection"; -const LoadingScreen = () => { - return ( -
- -

Loading, please wait

-
- ); -}; - export const SelectOrganizationPage = () => { const duplicateAccounts = useGetMyDuplicateAccount(); const [removeDuplicateLater, setRemoveDuplicateLater] = useState(false); if (duplicateAccounts.isPending) { - return ; + return ( +
+ +
+ ); } if (duplicateAccounts.data?.duplicateAccounts?.length && !removeDuplicateLater) { diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index 9518b4ae3..f331e4bda 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -12,7 +12,7 @@ import { Mfa } from "@app/components/auth/Mfa"; import { createNotification } from "@app/components/notifications"; import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin"; import SecurityClient from "@app/components/utilities/SecurityClient"; -import { Button, Spinner } from "@app/components/v2"; +import { Button, ContentLoader, Spinner } from "@app/components/v2"; import { SessionStorageKeys } from "@app/const"; import { OrgMembershipRole } from "@app/helpers/roles"; import { useToggle } from "@app/hooks"; @@ -29,15 +29,6 @@ import { AuthMethod } from "@app/hooks/api/users/types"; import { navigateUserToOrg } from "../LoginPage/Login.utils"; -const LoadingScreen = () => { - return ( -
- -

Loading, please wait

-
- ); -}; - export const SelectOrganizationSection = () => { const navigate = useNavigate(); const { t } = useTranslation(); @@ -225,7 +216,11 @@ export const SelectOrganizationSection = () => { !user || ((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa) ) { - return ; + return ( +
+ +
+ ); } return ( diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index b7d91a851..faba9ef37 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; +import { SingleValue } from "react-select"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -16,7 +17,15 @@ import { Switch } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { + TAvailableAppConnection, + useListAvailableAppConnections +} from "@app/hooks/api/appConnections"; +import { + TCloudflareZone, + useCloudflareConnectionListZones +} from "@app/hooks/api/appConnections/cloudflare"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AcmeDnsProvider, @@ -26,6 +35,10 @@ import { useGetCa, useUpdateCa } from "@app/hooks/api/ca"; +import { + ACME_DNS_PROVIDER_APP_CONNECTION_MAP, + ACME_DNS_PROVIDER_NAME_MAP +} from "@app/hooks/api/ca/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { slugSchema } from "@app/lib/schemas"; @@ -42,7 +55,7 @@ const schema = z id: z.string(), name: z.string() }), - // currently specific to Route53 but can be extended to others by differentiating via the provider property + // currently specific to Route53 & Cloudflare but can be extended to others by differentiating via the provider property dnsProviderConfig: z.object({ provider: z.nativeEnum(AcmeDnsProvider), hostedZoneId: z.string() @@ -105,12 +118,29 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { const caType = watch("type"); const dnsProvider = watch("configuration.dnsProviderConfig.provider"); - const { data: availableConnections, isPending } = useListAvailableAppConnections( - AppConnection.AWS, - { - enabled: dnsProvider === AcmeDnsProvider.ROUTE53 - } - ); + const { data: availableRoute53Connections, isPending: isRoute53Pending } = + useListAvailableAppConnections(AppConnection.AWS, { + enabled: caType === CaType.ACME + }); + + const { data: availableCloudflareConnections, isPending: isCloudflarePending } = + useListAvailableAppConnections(AppConnection.Cloudflare, { + enabled: caType === CaType.ACME + }); + + const availableConnections: TAvailableAppConnection[] = [ + ...(availableRoute53Connections || []), + ...(availableCloudflareConnections || []) + ]; + + const isPending = isRoute53Pending || isCloudflarePending; + + const dnsAppConnection = watch("configuration.dnsAppConnection"); + + const { data: cloudflareZones = [], isPending: isZonesPending } = + useCloudflareConnectionListZones(dnsAppConnection.id, { + enabled: dnsProvider === AcmeDnsProvider.Cloudflare && !!dnsAppConnection.id + }); useEffect(() => { if (ca) { @@ -138,25 +168,6 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { } }); } - } else { - reset({ - type: CaType.ACME, - name: "", - status: CaStatus.ACTIVE, - enableDirectIssuance: true, - configuration: { - dnsAppConnection: { - id: "", - name: "" - }, - dnsProviderConfig: { - provider: AcmeDnsProvider.ROUTE53, - hostedZoneId: "" - }, - directoryUrl: "", - accountEmail: "" - } - }); } }, [ca, availableConnections]); @@ -284,12 +295,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { className="w-full" isDisabled={Boolean(ca)} > - - Route53 - + {Object.values(AcmeDnsProvider).map((provider) => ( + + {ACME_DNS_PROVIDER_NAME_MAP[provider]} + + ))} )} @@ -297,7 +307,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { ( { control={control} name="configuration.dnsAppConnection" /> - ( - - - - )} - /> + {dnsProvider === AcmeDnsProvider.ROUTE53 && ( + ( + + + + )} + /> + )} + {dnsProvider === AcmeDnsProvider.Cloudflare && ( + ( + + zone.id === value)} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + }} + options={cloudflareZones} + placeholder="Select a zone..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + )} val || undefined) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["createTemplate", "editTemplate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["createTemplate", "editTemplate"]>, + state?: boolean + ) => void; +}; + +export const IdentityAuthTemplateModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { mutateAsync: createTemplate } = useCreateIdentityAuthTemplate(); + const { mutateAsync: updateTemplate } = useUpdateIdentityAuthTemplate(); + + const isEdit = popUp.editTemplate.isOpen; + const template = popUp.editTemplate?.data?.template; + + const { + control, + handleSubmit, + reset, + watch, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + method: MachineIdentityAuthMethod.LDAP, + url: "", + bindDN: "", + bindPass: "", + searchBase: "", + ldapCaCertificate: "" + } + }); + + useEffect(() => { + if (isEdit && template) { + reset({ + name: template.name || "", + method: MachineIdentityAuthMethod.LDAP, + url: template.templateFields?.url || "", + bindDN: template.templateFields?.bindDN || "", + bindPass: template.templateFields?.bindPass || "", + searchBase: template.templateFields?.searchBase || "", + ldapCaCertificate: template.templateFields?.ldapCaCertificate || "" + }); + } else { + reset({ + name: "", + method: MachineIdentityAuthMethod.LDAP, + url: "", + bindDN: "", + bindPass: "", + searchBase: "", + ldapCaCertificate: "" + }); + } + }, [isEdit, template, reset]); + + const selectedMethod = watch("method"); + + const onFormSubmit = async (data: FormData) => { + try { + if (isEdit && template) { + await updateTemplate({ + templateId: template.id, + organizationId: orgId, + name: data.name, + templateFields: { + url: data.url, + bindDN: data.bindDN, + bindPass: data.bindPass, + searchBase: data.searchBase, + ldapCaCertificate: data.ldapCaCertificate + } + }); + createNotification({ + text: "Successfully updated auth template", + type: "success" + }); + } else { + await createTemplate({ + organizationId: orgId, + name: data.name, + authMethod: data.method, + templateFields: { + url: data.url, + bindDN: data.bindDN, + bindPass: data.bindPass, + searchBase: data.searchBase, + ldapCaCertificate: data.ldapCaCertificate + } + }); + createNotification({ + text: "Successfully created auth template", + type: "success" + }); + } + + handlePopUpToggle(isEdit ? "editTemplate" : "createTemplate", false); + reset(); + } catch (err) { + console.error(err); + const error = err as any; + const text = + error?.response?.data?.message ?? `Failed to ${isEdit ? "update" : "create"} auth template`; + + createNotification({ + text, + type: "error" + }); + } + }; + + const handleClose = () => { + handlePopUpToggle(isEdit ? "editTemplate" : "createTemplate", false); + reset(); + }; + + return ( + + +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + {/* LDAP Configuration Fields */} + {selectedMethod === "ldap" && ( + <> + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + +