diff --git a/.env.example b/.env.example index 6489a1cc2..be7e0a621 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,3 @@ PLAIN_API_KEY= PLAIN_WISH_LABEL_IDS= SSL_CLIENT_CERTIFICATE_HEADER_KEY= - -WORKFLOW_SLACK_CLIENT_ID= -WORKFLOW_SLACK_CLIENT_SECRET= diff --git a/backend/package-lock.json b/backend/package-lock.json index 0fc41d3e3..6ea34f692 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -81,7 +81,7 @@ "pino": "^8.16.2", "pkijs": "^3.2.4", "posthog-node": "^3.6.2", - "probot": "^13.0.0", + "probot": "^13.3.8", "safe-regex": "^2.1.1", "scim-patch": "^0.8.3", "scim2-parse-filter": "^0.2.10", @@ -8018,6 +8018,7 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -8336,7 +8337,8 @@ "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.7", @@ -8814,9 +8816,10 @@ } }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -8826,7 +8829,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -8840,6 +8843,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -8848,6 +8852,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -8858,7 +8863,8 @@ "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "node_modules/bottleneck": { "version": "2.19.5", @@ -9006,6 +9012,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -9028,13 +9035,19 @@ } }, "node_modules/call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "license": "MIT", "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9379,6 +9392,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -9543,16 +9557,20 @@ } }, "node_modules/define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-lazy-prop": { @@ -9618,6 +9636,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -9724,7 +9743,8 @@ "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.4.816", @@ -9738,9 +9758,10 @@ "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -9827,6 +9848,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-set-tostringtag": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", @@ -10452,6 +10494,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -10495,36 +10538,37 @@ } }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -10588,6 +10632,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -10595,12 +10640,14 @@ "node_modules/express/node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -10608,7 +10655,8 @@ "node_modules/express/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "node_modules/extend": { "version": "3.0.2", @@ -10815,12 +10863,13 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -10835,6 +10884,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -10842,7 +10892,8 @@ "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "node_modules/find-my-way": { "version": "8.1.0", @@ -11008,6 +11059,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -11365,15 +11417,20 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "function-bind": "^1.1.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", "hasown": "^2.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11719,11 +11776,12 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", - "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.2" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -13276,6 +13334,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -13286,9 +13345,13 @@ "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -13309,6 +13372,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -13748,6 +13812,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -14099,6 +14164,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -14511,9 +14577,10 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -14716,20 +14783,78 @@ } }, "node_modules/pino-http": { - "version": "8.6.1", - "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-8.6.1.tgz", - "integrity": "sha512-J0hiJgUExtBXP2BjrK4VB305tHXS31sCmWJ9XJo2wPkLHa1NFPuW4V9wjG27PAc2fmBCigiNhQKpvrx+kntBPA==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.3.0.tgz", + "integrity": "sha512-kaHQqt1i5S9LXWmyuw6aPPqYW/TjoDPizPs4PnDW4hSpajz2Uo/oisNliLf7We1xzpiLacdntmw8yaZiEkppQQ==", + "license": "MIT", "dependencies": { "get-caller-file": "^2.0.5", - "pino": "^8.17.1", - "pino-std-serializers": "^6.2.2", - "process-warning": "^3.0.0" + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0" } }, + "node_modules/pino-http/node_modules/pino": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.4.0.tgz", + "integrity": "sha512-nbkQb5+9YPhQRz/BeQmrWpEknAaqjpAqRK8NwJpmrX/JHu7JuZC5G1CeAwJDJfGes4h+YihC6in3Q2nGb+Y09w==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.2.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-http/node_modules/pino-abstract-transport": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", + "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-http/node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "license": "MIT" + }, "node_modules/pino-http/node_modules/process-warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", - "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", + "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==", + "license": "MIT" + }, + "node_modules/pino-http/node_modules/sonic-boom": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.1.0.tgz", + "integrity": "sha512-NGipjjRicyJJ03rPiZCJYjwlsuP2d1/5QUviozRXC7S3WdVWNK5e3Ojieb9CCyfhq2UC+3+SRd9nG3I2lPRvUw==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/pino-http/node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } }, "node_modules/pino-pretty": { "version": "10.2.3", @@ -15096,9 +15221,10 @@ } }, "node_modules/probot": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/probot/-/probot-13.0.0.tgz", - "integrity": "sha512-3ht9kAJ+ISjLyWLLCKVdrLE5xs/x+zUx07J5kYTxAyIxUvwF6Acr8xT5fiNihbBHAsEl4+A4CMYZQvZ5hx5bgw==", + "version": "13.3.8", + "resolved": "https://registry.npmjs.org/probot/-/probot-13.3.8.tgz", + "integrity": "sha512-xc+KBC0mp1JKFMsPbMyj1SpmN0B7Q8uFO7ze4PBbNv74q8AyPGqYL3TmkZSOmcOjFTeFrZTnMYEoXi+z1anyLA==", + "license": "ISC", "dependencies": { "@octokit/core": "^5.0.2", "@octokit/plugin-enterprise-compatibility": "^4.0.1", @@ -15113,19 +15239,18 @@ "@probot/octokit-plugin-config": "^2.0.1", "@probot/pino": "^2.3.5", "@types/express": "^4.17.21", - "commander": "^11.1.0", + "bottleneck": "^2.19.5", + "commander": "^12.0.0", "deepmerge": "^4.3.1", "dotenv": "^16.3.1", - "eventsource": "^2.0.2", - "express": "^4.18.2", + "express": "^4.21.0", "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "lru-cache": "^10.0.3", "octokit-auth-probot": "^2.0.0", - "pino": "^8.16.1", - "pino-http": "^8.5.1", + "pino": "^9.0.0", + "pino-http": "^10.0.0", "pkg-conf": "^3.1.0", - "resolve": "^1.22.8", "update-dotenv": "^1.1.1" }, "bin": { @@ -15152,11 +15277,12 @@ } }, "node_modules/probot/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/probot/node_modules/lru-cache": { @@ -15167,6 +15293,68 @@ "node": "14 || >=16.14" } }, + "node_modules/probot/node_modules/pino": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.4.0.tgz", + "integrity": "sha512-nbkQb5+9YPhQRz/BeQmrWpEknAaqjpAqRK8NwJpmrX/JHu7JuZC5G1CeAwJDJfGes4h+YihC6in3Q2nGb+Y09w==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.2.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/probot/node_modules/pino-abstract-transport": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", + "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/probot/node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "license": "MIT" + }, + "node_modules/probot/node_modules/process-warning": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", + "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==", + "license": "MIT" + }, + "node_modules/probot/node_modules/sonic-boom": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.1.0.tgz", + "integrity": "sha512-NGipjjRicyJJ03rPiZCJYjwlsuP2d1/5QUviozRXC7S3WdVWNK5e3Ojieb9CCyfhq2UC+3+SRd9nG3I2lPRvUw==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/probot/node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -15282,11 +15470,12 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -15359,6 +15548,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -15367,6 +15557,7 @@ "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -15381,6 +15572,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -15961,9 +16153,10 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -15987,6 +16180,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -15994,12 +16188,23 @@ "node_modules/send/node_modules/debug/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, "node_modules/send/node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", "bin": { "mime": "cli.js" }, @@ -16013,14 +16218,15 @@ "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -16037,14 +16243,17 @@ "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" }, "node_modules/set-function-length": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", - "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", "dependencies": { - "define-data-property": "^1.1.1", - "get-intrinsic": "^1.2.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -16103,13 +16312,18 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -17704,6 +17918,7 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -17927,6 +18142,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -18051,6 +18267,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", "engines": { "node": ">= 0.8" } diff --git a/backend/package.json b/backend/package.json index c7aafcbf9..21f841da7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -178,7 +178,7 @@ "pino": "^8.16.2", "pkijs": "^3.2.4", "posthog-node": "^3.6.2", - "probot": "^13.0.0", + "probot": "^13.3.8", "safe-regex": "^2.1.1", "scim-patch": "^0.8.3", "scim2-parse-filter": "^0.2.10", diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index 049370743..4b1566c55 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -77,6 +77,39 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => } }); + server.route({ + method: "POST", + url: "/entra-id/users", + config: { + rateLimit: readLimit + }, + schema: { + body: z.object({ + tenantId: z.string().min(1).describe("The tenant ID of the Azure Entra ID"), + applicationId: z.string().min(1).describe("The application ID of the Azure Entra ID App Registration"), + clientSecret: z.string().min(1).describe("The client secret of the Azure Entra ID App Registration") + }), + response: { + 200: z + .object({ + name: z.string().min(1).describe("The name of the user"), + id: z.string().min(1).describe("The ID of the user"), + email: z.string().min(1).describe("The email of the user") + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.dynamicSecret.fetchAzureEntraIdUsers({ + tenantId: req.body.tenantId, + applicationId: req.body.applicationId, + clientSecret: req.body.clientSecret + }); + return data; + } + }); + server.route({ method: "PATCH", url: "/:name", @@ -237,7 +270,7 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const dynamicSecretCfgs = await server.services.dynamicSecret.list({ + const dynamicSecretCfgs = await server.services.dynamicSecret.listDynamicSecretsByEnv({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index 3ada2e50c..bbb03f4ae 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -101,6 +101,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { message: "Slug must be a valid" }), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), + description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description), permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() }), response: { diff --git a/backend/src/ee/routes/v1/project-router.ts b/backend/src/ee/routes/v1/project-router.ts index b3cff0322..fccfbd158 100644 --- a/backend/src/ee/routes/v1/project-router.ts +++ b/backend/src/ee/routes/v1/project-router.ts @@ -87,6 +87,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + /* + * Daniel: This endpoint is no longer is use. + * We are keeping it for now because it has been exposed in our public api docs for a while, so by removing it we are likely to break users workflows. + * + * Please refer to the new endpoint, GET /api/v1/organization/audit-logs, for the same (and more) functionality. + */ server.route({ method: "GET", url: "/:workspaceId/audit-logs", @@ -101,7 +107,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.workspaceId) + workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.projectId) }), querystring: z.object({ eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), @@ -122,10 +128,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) .merge( z.object({ - project: z.object({ - name: z.string(), - slug: z.string() - }), + project: z + .object({ + name: z.string(), + slug: z.string() + }) + .optional(), event: z.object({ type: z.string(), metadata: z.any() @@ -146,12 +154,16 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, - projectId: req.params.workspaceId, - ...req.query, - endDate: req.query.endDate, - startDate: req.query.startDate || getLastMidnightDateISO(), - auditLogActor: req.query.actor, - actor: req.permission.type + actor: req.permission.type, + + filter: { + ...req.query, + projectId: req.params.workspaceId, + endDate: req.query.endDate, + startDate: req.query.startDate || getLastMidnightDateISO(), + auditLogActorId: req.query.actor, + eventType: req.query.eventType ? [req.query.eventType] : undefined + } }); return { auditLogs }; } 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 3021beb0d..5e5e6872b 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -3,9 +3,12 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { AuditLogsSchema, TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols, stripUndefinedInWhere } from "@app/lib/knex"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; import { QueueName } from "@app/queue"; +import { ActorType } from "@app/services/auth/auth-type"; + +import { EventType } from "./audit-log-types"; export type TAuditLogDALFactory = ReturnType; @@ -25,37 +28,81 @@ export const auditLogDALFactory = (db: TDbClient) => { const auditLogOrm = ormify(db, TableName.AuditLog); const find = async ( - { orgId, projectId, userAgentType, startDate, endDate, limit = 20, offset = 0, actor, eventType }: TFindQuery, + { + orgId, + projectId, + userAgentType, + startDate, + endDate, + limit = 20, + offset = 0, + actorId, + actorType, + eventType, + eventMetadata + }: Omit & { + actorId?: string; + actorType?: ActorType; + eventType?: EventType[]; + eventMetadata?: Record; + }, tx?: Knex ) => { + if (!orgId && !projectId) { + throw new Error("Either orgId or projectId must be provided"); + } + try { + // Find statements const sqlQuery = (tx || db.replicaNode())(TableName.AuditLog) - .where( - stripUndefinedInWhere({ - projectId, - [`${TableName.AuditLog}.orgId`]: orgId, - eventType, - userAgentType - }) - ) - .leftJoin(TableName.Project, `${TableName.AuditLog}.projectId`, `${TableName.Project}.id`) + // eslint-disable-next-line func-names + .where(function () { + if (orgId) { + void this.where(`${TableName.Project}.orgId`, orgId).orWhere(`${TableName.AuditLog}.orgId`, orgId); + } else if (projectId) { + void this.where(`${TableName.AuditLog}.projectId`, projectId); + } + }); + if (userAgentType) { + void sqlQuery.where("userAgentType", userAgentType); + } + + // Select statements + void sqlQuery .select(selectAllTableCols(TableName.AuditLog)) - .select( db.ref("name").withSchema(TableName.Project).as("projectName"), db.ref("slug").withSchema(TableName.Project).as("projectSlug") ) - .limit(limit) .offset(offset) .orderBy(`${TableName.AuditLog}.createdAt`, "desc"); - if (actor) { - void sqlQuery.whereRaw(`"actorMetadata"->>'userId' = ?`, [actor]); + // Special case: Filter by actor ID + if (actorId) { + void sqlQuery.whereRaw(`"actorMetadata"->>'userId' = ?`, [actorId]); } + // Special case: Filter by key/value pairs in eventMetadata field + if (eventMetadata && Object.keys(eventMetadata).length) { + Object.entries(eventMetadata).forEach(([key, value]) => { + void sqlQuery.whereRaw(`"eventMetadata"->>'${key}' = ?`, [value]); + }); + } + + // Filter by actor type + if (actorType) { + void sqlQuery.where("actor", actorType); + } + + // Filter by event types + if (eventType?.length) { + void sqlQuery.whereIn("eventType", eventType); + } + + // Filter by date range if (startDate) { void sqlQuery.where(`${TableName.AuditLog}.createdAt`, ">=", startDate); } @@ -64,13 +111,21 @@ export const auditLogDALFactory = (db: TDbClient) => { } const docs = await sqlQuery; - return docs.map((doc) => ({ - ...AuditLogsSchema.parse(doc), - project: { - name: doc.projectName, - slug: doc.projectSlug - } - })); + return docs.map((doc) => { + // Our type system refuses to acknowledge that the project name and slug are present in the doc, due to the disjointed query structure above. + // This is a quick and dirty way to get around the types. + const projectDoc = doc as unknown as { projectName: string; projectSlug: string }; + + return { + ...AuditLogsSchema.parse(doc), + ...(projectDoc?.projectSlug && { + project: { + name: projectDoc.projectName, + slug: projectDoc.projectSlug + } + }) + }; + }); } catch (error) { throw new DatabaseError({ error }); } diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 11159c37b..747c53c1a 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -23,30 +23,19 @@ export const auditLogServiceFactory = ({ auditLogQueue, permissionService }: TAuditLogServiceFactoryDep) => { - const listAuditLogs = async ({ - userAgentType, - eventType, - offset, - limit, - endDate, - startDate, - actor, - actorId, - actorOrgId, - actorAuthMethod, - projectId, - auditLogActor - }: TListProjectAuditLogDTO) => { - if (projectId) { + const listAuditLogs = async ({ actorAuthMethod, actorId, actorOrgId, actor, filter }: TListProjectAuditLogDTO) => { + // Filter logs for specific project + if (filter.projectId) { const { permission } = await permissionService.getProjectPermission( actor, actorId, - projectId, + filter.projectId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); } else { + // Organization-wide logs const { permission } = await permissionService.getOrgPermission( actor, actorId, @@ -57,22 +46,23 @@ export const auditLogServiceFactory = ({ /** * NOTE (dangtony98): Update this to organization-level audit log permission check once audit logs are moved - * to the organization level + * to the organization level ✅ */ - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); } // If project ID is not provided, then we need to return all the audit logs for the organization itself. - const auditLogs = await auditLogDAL.find({ - startDate, - endDate, - limit, - offset, - eventType, - userAgentType, - actor: auditLogActor, - ...(projectId ? { projectId } : { orgId: actorOrgId }) + startDate: filter.startDate, + endDate: filter.endDate, + limit: filter.limit, + offset: filter.offset, + eventType: filter.eventType, + userAgentType: filter.userAgentType, + actorId: filter.auditLogActorId, + actorType: filter.actorType, + eventMetadata: filter.eventMetadata, + ...(filter.projectId ? { projectId: filter.projectId } : { orgId: actorOrgId }) }); return auditLogs.map(({ eventType: logEventType, actor: eActor, actorMetadata, eventMetadata, ...el }) => ({ 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 3b3a5b107..542471fac 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -5,19 +5,23 @@ import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { PkiItemType } from "@app/services/pki-collection/pki-collection-types"; export type TListProjectAuditLogDTO = { - auditLogActor?: string; - projectId?: string; - eventType?: string; - startDate?: string; - endDate?: string; - userAgentType?: string; - limit?: number; - offset?: number; + filter: { + userAgentType?: UserAgentType; + eventType?: EventType[]; + offset?: number; + limit: number; + endDate?: string; + startDate?: string; + projectId?: string; + auditLogActorId?: string; + actorType?: ActorType; + eventMetadata?: Record; + }; } & Omit; export type TCreateAuditLogDTO = { event: Event; - actor: UserActor | IdentityActor | ServiceActor | ScimClientActor; + actor: UserActor | IdentityActor | ServiceActor | ScimClientActor | PlatformActor; orgId?: string; projectId?: string; } & BaseAuthData; @@ -177,7 +181,8 @@ export enum EventType { UPDATE_SLACK_INTEGRATION = "update-slack-integration", DELETE_SLACK_INTEGRATION = "delete-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", - UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config" + UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", + INTEGRATION_SYNCED = "integration-synced" } interface UserActorMetadata { @@ -198,6 +203,8 @@ interface IdentityActorMetadata { interface ScimClientActorMetadata {} +interface PlatformActorMetadata {} + export interface UserActor { type: ActorType.USER; metadata: UserActorMetadata; @@ -208,6 +215,11 @@ export interface ServiceActor { metadata: ServiceActorMetadata; } +export interface PlatformActor { + type: ActorType.PLATFORM; + metadata: PlatformActorMetadata; +} + export interface IdentityActor { type: ActorType.IDENTITY; metadata: IdentityActorMetadata; @@ -218,7 +230,7 @@ export interface ScimClientActor { metadata: ScimClientActorMetadata; } -export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor; +export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor | PlatformActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -1518,6 +1530,16 @@ interface GetProjectSlackConfig { id: string; }; } +interface IntegrationSyncedEvent { + type: EventType.INTEGRATION_SYNCED; + metadata: { + integrationId: string; + lastSyncJobId: string; + lastUsed: Date; + syncMessage: string; + isSynced: boolean; + }; +} export type Event = | GetSecretsEvent @@ -1657,4 +1679,5 @@ export type Event = | DeleteSlackIntegration | GetSlackIntegration | UpdateProjectSlackConfig - | GetProjectSlackConfig; + | GetProjectSlackConfig + | IntegrationSyncedEvent; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts index 0cc4aca2f..e47d9102d 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts @@ -1,10 +1,70 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { OrderByDirection } from "@app/lib/types"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; export type TDynamicSecretDALFactory = ReturnType; export const dynamicSecretDALFactory = (db: TDbClient) => { const orm = ormify(db, TableName.DynamicSecret); - return orm; + + // find dynamic secrets for multiple environments (folder IDs are cross env, thus need to rank for pagination) + const listDynamicSecretsByFolderIds = async ( + { + folderIds, + search, + limit, + offset = 0, + orderBy = SecretsOrderBy.Name, + orderDirection = OrderByDirection.ASC + }: { + folderIds: string[]; + search?: string; + limit?: number; + offset?: number; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + }, + tx?: Knex + ) => { + try { + const query = (tx || db.replicaNode())(TableName.DynamicSecret) + .whereIn("folderId", folderIds) + .where((bd) => { + if (search) { + void bd.whereILike(`${TableName.DynamicSecret}.name`, `%${search}%`); + } + }) + .leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.DynamicSecret}.folderId`) + .leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .select( + selectAllTableCols(TableName.DynamicSecret), + db.ref("slug").withSchema(TableName.Environment).as("environment"), + db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.DynamicSecret}."name" ${orderDirection}) as rank`) + ) + .orderBy(`${TableName.DynamicSecret}.${orderBy}`, orderDirection); + + if (limit) { + const rankOffset = offset + 1; + return await (tx || db) + .with("w", query) + .select("*") + .from[number]>("w") + .where("w.rank", ">=", rankOffset) + .andWhere("w.rank", "<", rankOffset + limit); + } + + const dynamicSecrets = await query; + + return dynamicSecrets; + } catch (error) { + throw new DatabaseError({ error, name: "List dynamic secret multi env" }); + } + }; + + return { ...orm, listDynamicSecretsByFolderIds }; }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index ea08b212a..3f6492571 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -6,6 +6,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; +import { OrderByDirection } from "@app/lib/types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; @@ -17,9 +18,12 @@ import { TCreateDynamicSecretDTO, TDeleteDynamicSecretDTO, TDetailsDynamicSecretDTO, + TGetDynamicSecretsCountDTO, TListDynamicSecretsDTO, + TListDynamicSecretsMultiEnvDTO, TUpdateDynamicSecretDTO } from "./dynamic-secret-types"; +import { AzureEntraIDProvider } from "./providers/azure-entra-id"; import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models"; type TDynamicSecretServiceFactoryDep = { @@ -31,7 +35,7 @@ type TDynamicSecretServiceFactoryDep = { "pruneDynamicSecret" | "unsetLeaseRevocation" >; licenseService: Pick; - folderDAL: Pick; + folderDAL: Pick; projectDAL: Pick; permissionService: Pick; }; @@ -300,19 +304,55 @@ export const dynamicSecretServiceFactory = ({ return { ...dynamicSecretCfg, inputs: providerInputs }; }; - const list = async ({ + // get unique dynamic secret count across multiple envs + const getCountMultiEnv = async ({ actorAuthMethod, actorOrgId, actorId, actor, - projectSlug, + projectId, path, - environmentSlug - }: TListDynamicSecretsDTO) => { - const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); - if (!project) throw new BadRequestError({ message: "Project not found" }); + environmentSlugs, + search + }: TListDynamicSecretsMultiEnvDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); - const projectId = project.id; + // verify user has access to each env in request + environmentSlugs.forEach((environmentSlug) => + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ) + ); + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path); + if (!folders.length) throw new BadRequestError({ message: "Folders not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.find( + { $in: { folderId: folders.map((folder) => folder.id) }, $search: search ? { name: `%${search}%` } : undefined }, + { countDistinct: "name" } + ); + + return Number(dynamicSecretCfg[0]?.count ?? 0); + }; + + // get dynamic secret count for a single env + const getDynamicSecretCount = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + path, + environmentSlug, + search, + projectId + }: TGetDynamicSecretsCountDTO) => { const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -328,15 +368,127 @@ export const dynamicSecretServiceFactory = ({ const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); if (!folder) throw new BadRequestError({ message: "Folder not found" }); - const dynamicSecretCfg = await dynamicSecretDAL.find({ folderId: folder.id }); + const dynamicSecretCfg = await dynamicSecretDAL.find( + { folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined }, + { count: true } + ); + return Number(dynamicSecretCfg[0]?.count ?? 0); + }; + + const listDynamicSecretsByEnv = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectSlug, + path, + environmentSlug, + limit, + offset, + orderBy, + orderDirection = OrderByDirection.ASC, + search, + ...params + }: TListDynamicSecretsDTO) => { + let { projectId } = params; + + if (!projectId) { + if (!projectSlug) throw new BadRequestError({ message: "Project ID or slug required" }); + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + projectId = project.id; + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.find( + { folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined }, + { + limit, + offset, + sort: orderBy ? [[orderBy, orderDirection]] : undefined + } + ); return dynamicSecretCfg; }; + // get dynamic secrets for multiple envs + const listDynamicSecretsByFolderIds = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + path, + environmentSlugs, + projectId, + ...params + }: TListDynamicSecretsMultiEnvDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + // verify user has access to each env in request + environmentSlugs.forEach((environmentSlug) => + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ) + ); + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path); + if (!folders.length) throw new BadRequestError({ message: "Folders not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.listDynamicSecretsByFolderIds({ + folderIds: folders.map((folder) => folder.id), + ...params + }); + + return dynamicSecretCfg; + }; + + const fetchAzureEntraIdUsers = async ({ + tenantId, + applicationId, + clientSecret + }: { + tenantId: string; + applicationId: string; + clientSecret: string; + }) => { + const azureEntraIdUsers = await AzureEntraIDProvider().fetchAzureEntraIdUsers( + tenantId, + applicationId, + clientSecret + ); + return azureEntraIdUsers; + }; + return { create, updateByName, deleteByName, getDetails, - list + listDynamicSecretsByEnv, + listDynamicSecretsByFolderIds, + getDynamicSecretCount, + getCountMultiEnv, + fetchAzureEntraIdUsers }; }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts index 02f2cbb86..426135a4c 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts @@ -1,6 +1,7 @@ import { z } from "zod"; -import { TProjectPermission } from "@app/lib/types"; +import { OrderByDirection, TProjectPermission } from "@app/lib/types"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { DynamicSecretProviderSchema } from "./providers/models"; @@ -50,5 +51,20 @@ export type TDetailsDynamicSecretDTO = { export type TListDynamicSecretsDTO = { path: string; environmentSlug: string; - projectSlug: string; + projectSlug?: string; + projectId?: string; + offset?: number; + limit?: number; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + search?: string; } & Omit; + +export type TListDynamicSecretsMultiEnvDTO = Omit< + TListDynamicSecretsDTO, + "projectId" | "environmentSlug" | "projectSlug" +> & { projectId: string; environmentSlugs: string[] }; + +export type TGetDynamicSecretsCountDTO = Omit & { + projectId: string; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts new file mode 100644 index 000000000..e2dfe2d4b --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts @@ -0,0 +1,138 @@ +import axios from "axios"; +import { customAlphabet } from "nanoid"; + +import { BadRequestError } from "@app/lib/errors"; + +import { AzureEntraIDSchema, TDynamicProviderFns } from "./models"; + +const MSFT_GRAPH_API_URL = "https://graph.microsoft.com/v1.0/"; +const MSFT_LOGIN_URL = "https://login.microsoftonline.com"; + +const generatePassword = () => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + return customAlphabet(charset, 64)(); +}; + +type User = { name: string; id: string; email: string }; + +export const AzureEntraIDProvider = (): TDynamicProviderFns & { + fetchAzureEntraIdUsers: (tenantId: string, applicationId: string, clientSecret: string) => Promise; +} => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await AzureEntraIDSchema.parseAsync(inputs); + return providerInputs; + }; + + const getToken = async ( + tenantId: string, + applicationId: string, + clientSecret: string + ): Promise<{ token?: string; success: boolean }> => { + const response = await axios.post<{ access_token: string }>( + `${MSFT_LOGIN_URL}/${tenantId}/oauth2/v2.0/token`, + { + grant_type: "client_credentials", + client_id: applicationId, + client_secret: clientSecret, + scope: "https://graph.microsoft.com/.default" + }, + { + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + } + ); + + if (response.status === 200) { + return { token: response.data.access_token, success: true }; + } + return { success: false }; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const data = await getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); + return data.success; + }; + + const renew = async (inputs: unknown, entityId: string) => { + // Do nothing + return { entityId }; + }; + + const create = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const data = await getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); + if (!data.success) { + throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" }); + } + + const password = generatePassword(); + + const response = await axios.patch( + `${MSFT_GRAPH_API_URL}/users/${providerInputs.userId}`, + { + passwordProfile: { + forceChangePasswordNextSignIn: false, + password + } + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${data.token}` + } + } + ); + if (response.status !== 204) { + throw new BadRequestError({ message: "Failed to update password" }); + } + + return { entityId: providerInputs.userId, data: { email: providerInputs.email, password } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + // Creates a new password + await create(inputs); + return { entityId }; + }; + + const fetchAzureEntraIdUsers = async (tenantId: string, applicationId: string, clientSecret: string) => { + const data = await getToken(tenantId, applicationId, clientSecret); + if (!data.success) { + throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" }); + } + + const response = await axios.get<{ value: [{ id: string; displayName: string; userPrincipalName: string }] }>( + `${MSFT_GRAPH_API_URL}/users`, + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${data.token}` + } + } + ); + + if (response.status !== 200) { + throw new BadRequestError({ message: "Failed to fetch users" }); + } + + const users = response.data.value.map((user) => { + return { + name: user.displayName, + id: user.id, + email: user.userPrincipalName + }; + }); + return users; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew, + fetchAzureEntraIdUsers + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 6ae22c869..8f2dbdc13 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -1,5 +1,6 @@ import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache"; import { AwsIamProvider } from "./aws-iam"; +import { AzureEntraIDProvider } from "./azure-entra-id"; import { CassandraProvider } from "./cassandra"; import { ElasticSearchProvider } from "./elastic-search"; import { DynamicSecretProviders } from "./models"; @@ -18,5 +19,6 @@ export const buildDynamicSecretProviders = () => ({ [DynamicSecretProviders.MongoAtlas]: MongoAtlasProvider(), [DynamicSecretProviders.MongoDB]: MongoDBProvider(), [DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(), - [DynamicSecretProviders.RabbitMq]: RabbitMqProvider() + [DynamicSecretProviders.RabbitMq]: RabbitMqProvider(), + [DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index f23a60df7..18a7b3dc9 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -166,6 +166,14 @@ export const DynamicSecretMongoDBSchema = z.object({ ) }); +export const AzureEntraIDSchema = z.object({ + tenantId: z.string().trim().min(1), + userId: z.string().trim().min(1), + email: z.string().trim().min(1), + applicationId: z.string().trim().min(1), + clientSecret: z.string().trim().min(1) +}); + export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", @@ -175,7 +183,8 @@ export enum DynamicSecretProviders { MongoAtlas = "mongo-db-atlas", ElasticSearch = "elastic-search", MongoDB = "mongo-db", - RabbitMq = "rabbit-mq" + RabbitMq = "rabbit-mq", + AzureEntraID = "azure-entra-id" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -187,7 +196,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.MongoAtlas), inputs: DynamicSecretMongoAtlasSchema }), z.object({ type: z.literal(DynamicSecretProviders.ElasticSearch), inputs: DynamicSecretElasticSearchSchema }), z.object({ type: z.literal(DynamicSecretProviders.MongoDB), inputs: DynamicSecretMongoDBSchema }), - z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }) + z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }), + z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index c4a6ac3fc..b9a4980be 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -1,7 +1,5 @@ import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability"; -import { conditionsMatcher } from "@app/lib/casl"; - export enum OrgPermissionActions { Read = "read", Create = "create", @@ -27,7 +25,8 @@ export enum OrgPermissionSubjects { SecretScanning = "secret-scanning", Identity = "identity", Kms = "kms", - AdminConsole = "organization-admin-console" + AdminConsole = "organization-admin-console", + AuditLogs = "audit-logs" } export type OrgPermissionSet = @@ -45,10 +44,11 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Identity] | [OrgPermissionActions, OrgPermissionSubjects.Kms] + | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; const buildAdminPermission = () => { - const { can, build } = new AbilityBuilder>(createMongoAbility); + const { can, rules } = new AbilityBuilder>(createMongoAbility); // ws permissions can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); @@ -113,15 +113,20 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms); + can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionActions.Create, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); - return build({ conditionsMatcher }); + return rules; }; export const orgAdminPermissions = buildAdminPermission(); const buildMemberPermission = () => { - const { can, build } = new AbilityBuilder>(createMongoAbility); + const { can, rules } = new AbilityBuilder>(createMongoAbility); can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); @@ -142,14 +147,16 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); - return build({ conditionsMatcher }); + can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); + + return rules; }; export const orgMemberPermissions = buildMemberPermission(); const buildNoAccessPermission = () => { - const { build } = new AbilityBuilder>(createMongoAbility); - return build({ conditionsMatcher }); + const { rules } = new AbilityBuilder>(createMongoAbility); + return rules; }; export const orgNoAccessPermissions = buildNoAccessPermission(); diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index cd392d3c7..70a06739e 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -1,7 +1,13 @@ import { z } from "zod"; import { TDbClient } from "@app/db"; -import { IdentityProjectMembershipRoleSchema, ProjectUserMembershipRolesSchema, TableName } from "@app/db/schemas"; +import { + IdentityProjectMembershipRoleSchema, + OrgMembershipsSchema, + TableName, + TProjectRoles, + TProjects +} from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; @@ -10,18 +16,91 @@ export type TPermissionDALFactory = ReturnType; export const permissionDALFactory = (db: TDbClient) => { const getOrgPermission = async (userId: string, orgId: string) => { try { + const groupSubQuery = db(TableName.Groups) + .where(`${TableName.Groups}.orgId`, orgId) + .join(TableName.UserGroupMembership, (queryBuilder) => { + queryBuilder + .on(`${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`) + .andOn(`${TableName.UserGroupMembership}.userId`, db.raw("?", [userId])); + }) + .leftJoin(TableName.OrgRoles, `${TableName.Groups}.roleId`, `${TableName.OrgRoles}.id`) + .select( + db.ref("id").withSchema(TableName.Groups).as("groupId"), + db.ref("orgId").withSchema(TableName.Groups).as("groupOrgId"), + db.ref("name").withSchema(TableName.Groups).as("groupName"), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug"), + db.ref("role").withSchema(TableName.Groups).as("groupRole"), + db.ref("roleId").withSchema(TableName.Groups).as("groupRoleId"), + db.ref("createdAt").withSchema(TableName.Groups).as("groupCreatedAt"), + db.ref("updatedAt").withSchema(TableName.Groups).as("groupUpdatedAt"), + db.ref("permissions").withSchema(TableName.OrgRoles).as("groupCustomRolePermission") + ); + const membership = await db .replicaNode()(TableName.OrgMembership) - .leftJoin(TableName.OrgRoles, `${TableName.OrgMembership}.roleId`, `${TableName.OrgRoles}.id`) - .join(TableName.Organization, `${TableName.OrgMembership}.orgId`, `${TableName.Organization}.id`) - .where("userId", userId) .where(`${TableName.OrgMembership}.orgId`, orgId) - .select(db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced")) - .select("permissions") - .select(selectAllTableCols(TableName.OrgMembership)) - .first(); + .where(`${TableName.OrgMembership}.userId`, userId) + .leftJoin(TableName.OrgRoles, `${TableName.OrgRoles}.id`, `${TableName.OrgMembership}.roleId`) + .leftJoin[0]>( + groupSubQuery.as("userGroups"), + "userGroups.groupOrgId", + db.raw("?", [orgId]) + ) + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .select( + selectAllTableCols(TableName.OrgMembership), + db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"), + db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("groupId").withSchema("userGroups"), + db.ref("groupOrgId").withSchema("userGroups"), + db.ref("groupName").withSchema("userGroups"), + db.ref("groupSlug").withSchema("userGroups"), + db.ref("groupRole").withSchema("userGroups"), + db.ref("groupRoleId").withSchema("userGroups"), + db.ref("groupCreatedAt").withSchema("userGroups"), + db.ref("groupUpdatedAt").withSchema("userGroups"), + db.ref("groupCustomRolePermission").withSchema("userGroups") + ); - return membership; + const [formatedDoc] = sqlNestRelationships({ + data: membership, + key: "id", + parentMapper: (el) => + OrgMembershipsSchema.extend({ + permissions: z.unknown(), + orgAuthEnforced: z.boolean().optional().nullable(), + customRoleSlug: z.string().optional().nullable() + }).parse(el), + childrenMapper: [ + { + key: "groupId", + label: "groups" as const, + mapper: ({ + groupId, + groupUpdatedAt, + groupCreatedAt, + groupRole, + groupRoleId, + groupCustomRolePermission, + groupName, + groupSlug, + groupOrgId + }) => ({ + id: groupId, + updatedAt: groupUpdatedAt, + createdAt: groupCreatedAt, + role: groupRole, + roleId: groupRoleId, + customRolePermission: groupCustomRolePermission, + name: groupName, + slug: groupSlug, + orgId: groupOrgId + }) + } + ] + }); + + return formatedDoc; } catch (error) { throw new DatabaseError({ error, name: "GetOrgPermission" }); } @@ -47,74 +126,31 @@ export const permissionDALFactory = (db: TDbClient) => { const getProjectPermission = async (userId: string, projectId: string) => { try { - const groups: string[] = await db - .replicaNode()(TableName.GroupProjectMembership) - .where(`${TableName.GroupProjectMembership}.projectId`, projectId) - .pluck(`${TableName.GroupProjectMembership}.groupId`); - - const groupDocs = await db - .replicaNode()(TableName.UserGroupMembership) - .where(`${TableName.UserGroupMembership}.userId`, userId) - .whereIn(`${TableName.UserGroupMembership}.groupId`, groups) - .join( - TableName.GroupProjectMembership, - `${TableName.GroupProjectMembership}.groupId`, - `${TableName.UserGroupMembership}.groupId` - ) - .join( + const docs = await db + .replicaNode()(TableName.Users) + .where(`${TableName.Users}.id`, userId) + .leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.GroupProjectMembership, (queryBuilder) => { + void queryBuilder + .on(`${TableName.GroupProjectMembership}.projectId`, db.raw("?", [projectId])) + .andOn(`${TableName.GroupProjectMembership}.groupId`, `${TableName.UserGroupMembership}.groupId`); + }) + .leftJoin( TableName.GroupProjectMembershipRole, `${TableName.GroupProjectMembershipRole}.projectMembershipId`, `${TableName.GroupProjectMembership}.id` ) - - .leftJoin( - TableName.ProjectRoles, + .leftJoin( + { groupCustomRoles: TableName.ProjectRoles }, `${TableName.GroupProjectMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` + `groupCustomRoles.id` ) - .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) - .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) - + .leftJoin(TableName.ProjectMembership, (queryBuilder) => { + void queryBuilder + .on(`${TableName.ProjectMembership}.projectId`, db.raw("?", [projectId])) + .andOn(`${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`); + }) .leftJoin( - TableName.ProjectUserAdditionalPrivilege, - `${TableName.GroupProjectMembership}.projectId`, - `${TableName.Project}.id` - ) - .select(selectAllTableCols(TableName.GroupProjectMembershipRole)) - .select( - db.ref("id").withSchema(TableName.GroupProjectMembership).as("membershipId"), - db.ref("createdAt").withSchema(TableName.GroupProjectMembership).as("membershipCreatedAt"), - db.ref("updatedAt").withSchema(TableName.GroupProjectMembership).as("membershipUpdatedAt"), - db.ref("projectId").withSchema(TableName.GroupProjectMembership), - db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), - db.ref("orgId").withSchema(TableName.Project), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), - - db.ref("permissions").withSchema(TableName.ProjectRoles).as("permissions"), - // db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("apPermissions") - // Additional Privileges - db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApId"), - db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApPermissions"), - db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryMode"), - db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApIsTemporary"), - db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryRange"), - - db.ref("projectId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApProjectId"), - db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApUserId"), - - db - .ref("temporaryAccessStartTime") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userApTemporaryAccessStartTime"), - db - .ref("temporaryAccessEndTime") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userApTemporaryAccessEndTime") - ); - // .select(`${TableName.ProjectRoles}.permissions`); - - const docs = await db(TableName.ProjectMembership) - .join( TableName.ProjectUserMembershipRole, `${TableName.ProjectUserMembershipRole}.projectMembershipId`, `${TableName.ProjectMembership}.id` @@ -124,176 +160,229 @@ export const permissionDALFactory = (db: TDbClient) => { `${TableName.ProjectUserMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) - .leftJoin( - TableName.ProjectUserAdditionalPrivilege, - `${TableName.ProjectUserAdditionalPrivilege}.projectId`, - `${TableName.ProjectMembership}.projectId` - ) - - .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) + .leftJoin(TableName.ProjectUserAdditionalPrivilege, (queryBuilder) => { + void queryBuilder + .on(`${TableName.ProjectUserAdditionalPrivilege}.projectId`, db.raw("?", [projectId])) + .andOn(`${TableName.ProjectUserAdditionalPrivilege}.userId`, `${TableName.Users}.id`); + }) + .join(TableName.Project, `${TableName.Project}.id`, db.raw("?", [projectId])) .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) - .where(`${TableName.ProjectMembership}.userId`, userId) - .where(`${TableName.ProjectMembership}.projectId`, projectId) - .select(selectAllTableCols(TableName.ProjectUserMembershipRole)) .select( + db.ref("id").withSchema(TableName.Users).as("userId"), + // groups specific + db.ref("id").withSchema(TableName.GroupProjectMembership).as("groupMembershipId"), + db.ref("createdAt").withSchema(TableName.GroupProjectMembership).as("groupMembershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.GroupProjectMembership).as("groupMembershipUpdatedAt"), + db.ref("slug").withSchema("groupCustomRoles").as("userGroupProjectMembershipRoleCustomRoleSlug"), + db.ref("permissions").withSchema("groupCustomRoles").as("userGroupProjectMembershipRolePermission"), + db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("userGroupProjectMembershipRoleId"), + db.ref("role").withSchema(TableName.GroupProjectMembershipRole).as("userGroupProjectMembershipRole"), + db + .ref("customRoleId") + .withSchema(TableName.GroupProjectMembershipRole) + .as("userGroupProjectMembershipRoleCustomRoleId"), + db + .ref("isTemporary") + .withSchema(TableName.GroupProjectMembershipRole) + .as("userGroupProjectMembershipRoleIsTemporary"), + db + .ref("temporaryMode") + .withSchema(TableName.GroupProjectMembershipRole) + .as("userGroupProjectMembershipRoleTemporaryMode"), + db + .ref("temporaryRange") + .withSchema(TableName.GroupProjectMembershipRole) + .as("userGroupProjectMembershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.GroupProjectMembershipRole) + .as("userGroupProjectMembershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.GroupProjectMembershipRole) + .as("userGroupProjectMembershipRoleTemporaryAccessEndTime"), + // user specific db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"), db.ref("createdAt").withSchema(TableName.ProjectMembership).as("membershipCreatedAt"), db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"), - db.ref("projectId").withSchema(TableName.ProjectMembership), - db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), - db.ref("orgId").withSchema(TableName.Project), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), - db.ref("permissions").withSchema(TableName.ProjectRoles), - db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApId"), - db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApPermissions"), - db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryMode"), - db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApIsTemporary"), - db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryRange"), - - db.ref("projectId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApProjectId"), - db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApUserId"), - + db.ref("slug").withSchema(TableName.ProjectRoles).as("userProjectMembershipRoleCustomRoleSlug"), + db.ref("permissions").withSchema(TableName.ProjectRoles).as("userProjectCustomRolePermission"), + db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("userProjectMembershipRoleId"), + db.ref("role").withSchema(TableName.ProjectUserMembershipRole).as("userProjectMembershipRole"), + db + .ref("temporaryMode") + .withSchema(TableName.ProjectUserMembershipRole) + .as("userProjectMembershipRoleTemporaryMode"), + db + .ref("isTemporary") + .withSchema(TableName.ProjectUserMembershipRole) + .as("userProjectMembershipRoleIsTemporary"), + db + .ref("temporaryRange") + .withSchema(TableName.ProjectUserMembershipRole) + .as("userProjectMembershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.ProjectUserMembershipRole) + .as("userProjectMembershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.ProjectUserMembershipRole) + .as("userProjectMembershipRoleTemporaryAccessEndTime"), + db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userAdditionalPrivilegesId"), + db + .ref("permissions") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userAdditionalPrivilegesPermissions"), + db + .ref("temporaryMode") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userAdditionalPrivilegesTemporaryMode"), + db + .ref("isTemporary") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userAdditionalPrivilegesIsTemporary"), + db + .ref("temporaryRange") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userAdditionalPrivilegesTemporaryRange"), + db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userAdditionalPrivilegesUserId"), db .ref("temporaryAccessStartTime") .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userApTemporaryAccessStartTime"), + .as("userAdditionalPrivilegesTemporaryAccessStartTime"), db .ref("temporaryAccessEndTime") .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userApTemporaryAccessEndTime") + .as("userAdditionalPrivilegesTemporaryAccessEndTime"), + // general + db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("orgId").withSchema(TableName.Project), + db.ref("id").withSchema(TableName.Project).as("projectId") ); - const permission = sqlNestRelationships({ + const [userPermission] = sqlNestRelationships({ data: docs, key: "projectId", - parentMapper: ({ orgId, orgAuthEnforced, membershipId, membershipCreatedAt, membershipUpdatedAt }) => ({ + parentMapper: ({ + orgId, + orgAuthEnforced, + membershipId, + groupMembershipId, + membershipCreatedAt, + groupMembershipCreatedAt, + groupMembershipUpdatedAt, + membershipUpdatedAt + }) => ({ orgId, orgAuthEnforced, userId, - id: membershipId, projectId, - createdAt: membershipCreatedAt, - updatedAt: membershipUpdatedAt + id: membershipId || groupMembershipId, + createdAt: membershipCreatedAt || groupMembershipCreatedAt, + updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt }), childrenMapper: [ { - key: "id", - label: "roles" as const, - mapper: (data) => - ProjectUserMembershipRolesSchema.extend({ - permissions: z.unknown(), - customRoleSlug: z.string().optional().nullable() - }).parse(data) + key: "userGroupProjectMembershipRoleId", + label: "userGroupRoles" as const, + mapper: ({ + userGroupProjectMembershipRoleId, + userGroupProjectMembershipRole, + userGroupProjectMembershipRolePermission, + userGroupProjectMembershipRoleCustomRoleSlug, + userGroupProjectMembershipRoleIsTemporary, + userGroupProjectMembershipRoleTemporaryMode, + userGroupProjectMembershipRoleTemporaryAccessEndTime, + userGroupProjectMembershipRoleTemporaryAccessStartTime, + userGroupProjectMembershipRoleTemporaryRange + }) => ({ + id: userGroupProjectMembershipRoleId, + role: userGroupProjectMembershipRole, + customRoleSlug: userGroupProjectMembershipRoleCustomRoleSlug, + permissions: userGroupProjectMembershipRolePermission, + temporaryRange: userGroupProjectMembershipRoleTemporaryRange, + temporaryMode: userGroupProjectMembershipRoleTemporaryMode, + temporaryAccessStartTime: userGroupProjectMembershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: userGroupProjectMembershipRoleTemporaryAccessEndTime, + isTemporary: userGroupProjectMembershipRoleIsTemporary + }) }, { - key: "userApId", + key: "userProjectMembershipRoleId", + label: "projecMembershiptRoles" as const, + mapper: ({ + userProjectMembershipRoleId, + userProjectMembershipRole, + userProjectCustomRolePermission, + userProjectMembershipRoleIsTemporary, + userProjectMembershipRoleTemporaryMode, + userProjectMembershipRoleTemporaryRange, + userProjectMembershipRoleTemporaryAccessEndTime, + userProjectMembershipRoleTemporaryAccessStartTime, + userProjectMembershipRoleCustomRoleSlug + }) => ({ + id: userProjectMembershipRoleId, + role: userProjectMembershipRole, + customRoleSlug: userProjectMembershipRoleCustomRoleSlug, + permissions: userProjectCustomRolePermission, + temporaryRange: userProjectMembershipRoleTemporaryRange, + temporaryMode: userProjectMembershipRoleTemporaryMode, + temporaryAccessStartTime: userProjectMembershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: userProjectMembershipRoleTemporaryAccessEndTime, + isTemporary: userProjectMembershipRoleIsTemporary + }) + }, + { + key: "userAdditionalPrivilegesId", label: "additionalPrivileges" as const, mapper: ({ - userApId, - userApPermissions, - userApIsTemporary, - userApTemporaryMode, - userApTemporaryRange, - userApTemporaryAccessEndTime, - userApTemporaryAccessStartTime + userAdditionalPrivilegesId, + userAdditionalPrivilegesPermissions, + userAdditionalPrivilegesIsTemporary, + userAdditionalPrivilegesTemporaryMode, + userAdditionalPrivilegesTemporaryRange, + userAdditionalPrivilegesTemporaryAccessEndTime, + userAdditionalPrivilegesTemporaryAccessStartTime }) => ({ - id: userApId, - permissions: userApPermissions, - temporaryRange: userApTemporaryRange, - temporaryMode: userApTemporaryMode, - temporaryAccessEndTime: userApTemporaryAccessEndTime, - temporaryAccessStartTime: userApTemporaryAccessStartTime, - isTemporary: userApIsTemporary + id: userAdditionalPrivilegesId, + permissions: userAdditionalPrivilegesPermissions, + temporaryRange: userAdditionalPrivilegesTemporaryRange, + temporaryMode: userAdditionalPrivilegesTemporaryMode, + temporaryAccessStartTime: userAdditionalPrivilegesTemporaryAccessStartTime, + temporaryAccessEndTime: userAdditionalPrivilegesTemporaryAccessEndTime, + isTemporary: userAdditionalPrivilegesIsTemporary }) } ] }); - const groupPermission = groupDocs.length - ? sqlNestRelationships({ - data: groupDocs, - key: "projectId", - parentMapper: ({ orgId, orgAuthEnforced, membershipId, membershipCreatedAt, membershipUpdatedAt }) => ({ - orgId, - orgAuthEnforced, - userId, - id: membershipId, - projectId, - createdAt: membershipCreatedAt, - updatedAt: membershipUpdatedAt - }), - childrenMapper: [ - { - key: "id", - label: "roles" as const, - mapper: (data) => - ProjectUserMembershipRolesSchema.extend({ - permissions: z.unknown(), - customRoleSlug: z.string().optional().nullable() - }).parse(data) - }, - { - key: "userApId", - label: "additionalPrivileges" as const, - mapper: ({ - userApId, - userApProjectId, - userApUserId, - userApPermissions, - userApIsTemporary, - userApTemporaryMode, - userApTemporaryRange, - userApTemporaryAccessEndTime, - userApTemporaryAccessStartTime - }) => ({ - id: userApId, - userId: userApUserId, - projectId: userApProjectId, - permissions: userApPermissions, - temporaryRange: userApTemporaryRange, - temporaryMode: userApTemporaryMode, - temporaryAccessEndTime: userApTemporaryAccessEndTime, - temporaryAccessStartTime: userApTemporaryAccessStartTime, - isTemporary: userApIsTemporary - }) - } - ] - }) - : []; - - if (!permission?.[0] && !groupPermission[0]) return undefined; + if (!userPermission) return undefined; + if (!userPermission?.userGroupRoles?.[0] && !userPermission?.projecMembershiptRoles?.[0]) return undefined; // when introducting cron mode change it here const activeRoles = - permission?.[0]?.roles?.filter( + userPermission?.projecMembershiptRoles?.filter( ({ isTemporary, temporaryAccessEndTime }) => !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) ) ?? []; const activeGroupRoles = - groupPermission?.[0]?.roles?.filter( + userPermission?.userGroupRoles?.filter( ({ isTemporary, temporaryAccessEndTime }) => !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) ) ?? []; const activeAdditionalPrivileges = - permission?.[0]?.additionalPrivileges?.filter( + userPermission?.additionalPrivileges?.filter( ({ isTemporary, temporaryAccessEndTime }) => !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) ) ?? []; - const activeGroupAdditionalPrivileges = - groupPermission?.[0]?.additionalPrivileges?.filter( - ({ isTemporary, temporaryAccessEndTime, userId: apUserId, projectId: apProjectId }) => - apProjectId === projectId && - apUserId === userId && - (!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)) - ) ?? []; - return { - ...(permission[0] || groupPermission[0]), + ...userPermission, roles: [...activeRoles, ...activeGroupRoles], - additionalPrivileges: [...activeAdditionalPrivileges, ...activeGroupAdditionalPrivileges] + additionalPrivileges: activeAdditionalPrivileges }; } catch (error) { throw new DatabaseError({ error, name: "GetProjectPermission" }); diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 399a2950d..cb1e84677 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -20,7 +20,7 @@ import { TServiceTokenDALFactory } from "@app/services/service-token/service-tok import { orgAdminPermissions, orgMemberPermissions, orgNoAccessPermissions, OrgPermissionSet } from "./org-permission"; import { TPermissionDALFactory } from "./permission-dal"; import { validateOrgSAML } from "./permission-fns"; -import { TBuildProjectPermissionDTO } from "./permission-types"; +import { TBuildOrgPermissionDTO, TBuildProjectPermissionDTO } from "./permission-types"; import { buildServiceTokenProjectPermission, projectAdminPermissions, @@ -47,26 +47,29 @@ export const permissionServiceFactory = ({ serviceTokenDAL, projectDAL }: TPermissionServiceFactoryDep) => { - const buildOrgPermission = (role: string, permission?: unknown) => { - switch (role) { - case OrgMembershipRole.Admin: - return orgAdminPermissions; - case OrgMembershipRole.Member: - return orgMemberPermissions; - case OrgMembershipRole.NoAccess: - return orgNoAccessPermissions; - case OrgMembershipRole.Custom: - return createMongoAbility( - unpackRules>>( - permission as PackRule>>[] - ), - { - conditionsMatcher - } - ); - default: - throw new BadRequestError({ name: "OrgRoleInvalid", message: "Org role not found" }); - } + const buildOrgPermission = (orgUserRoles: TBuildOrgPermissionDTO) => { + const rules = orgUserRoles + .map(({ role, permissions }) => { + switch (role) { + case OrgMembershipRole.Admin: + return orgAdminPermissions; + case OrgMembershipRole.Member: + return orgMemberPermissions; + case OrgMembershipRole.NoAccess: + return orgNoAccessPermissions; + case OrgMembershipRole.Custom: + return unpackRules>>( + permissions as PackRule>>[] + ); + default: + throw new BadRequestError({ name: "OrgRoleInvalid", message: "Org role not found" }); + } + }) + .reduce((curr, prev) => prev.concat(curr), []); + + return createMongoAbility(rules, { + conditionsMatcher + }); }; const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => { @@ -129,7 +132,13 @@ export const permissionServiceFactory = ({ validateOrgSAML(authMethod, membership.orgAuthEnforced); - return { permission: buildOrgPermission(membership.role, membership.permissions), membership }; + const finalPolicyRoles = [{ role: membership.role, permissions: membership.permissions }].concat( + membership?.groups?.map(({ role, customRolePermission }) => ({ + role, + permissions: customRolePermission + })) || [] + ); + return { permission: buildOrgPermission(finalPolicyRoles), membership }; }; const getIdentityOrgPermission = async (identityId: string, orgId: string) => { @@ -138,7 +147,10 @@ export const permissionServiceFactory = ({ if (membership.role === OrgMembershipRole.Custom && !membership.permissions) { throw new BadRequestError({ name: "Custom permission not found" }); } - return { permission: buildOrgPermission(membership.role, membership.permissions), membership }; + return { + permission: buildOrgPermission([{ role: membership.role, permissions: membership.permissions }]), + membership + }; }; const getOrgPermission = async ( @@ -169,11 +181,11 @@ export const permissionServiceFactory = ({ const orgRole = await orgRoleDAL.findOne({ slug: role, orgId }); if (!orgRole) throw new BadRequestError({ message: "Role not found" }); return { - permission: buildOrgPermission(OrgMembershipRole.Custom, orgRole.permissions), + permission: buildOrgPermission([{ role: OrgMembershipRole.Custom, permissions: orgRole.permissions }]), role: orgRole }; } - return { permission: buildOrgPermission(role, []) }; + return { permission: buildOrgPermission([{ role, permissions: [] }]) }; }; // user permission for a project in an organization diff --git a/backend/src/ee/services/permission/permission-types.ts b/backend/src/ee/services/permission/permission-types.ts index a35958ffd..620e7a61c 100644 --- a/backend/src/ee/services/permission/permission-types.ts +++ b/backend/src/ee/services/permission/permission-types.ts @@ -2,3 +2,8 @@ export type TBuildProjectPermissionDTO = { permissions?: unknown; role: string; }[]; + +export type TBuildOrgPermissionDTO = { + permissions?: unknown; + role: string; +}[]; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 2cafc8b64..60daa14c4 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -145,6 +145,8 @@ export const fullProjectPermissionSet: [ProjectPermissionActions, ProjectPermiss [ProjectPermissionActions.Edit, ProjectPermissionSub.Tags], [ProjectPermissionActions.Delete, ProjectPermissionSub.Tags], + // TODO(Daniel): Remove the audit logs permissions from project-level permissions. + // TODO: We haven't done this yet because it might break existing roles, since those roles will become "invalid" since the audit log permission defined on those roles, no longer exist in the project-level defined permissions. [ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs], [ProjectPermissionActions.Create, ProjectPermissionSub.AuditLogs], [ProjectPermissionActions.Edit, ProjectPermissionSub.AuditLogs], diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 9b64900a1..a8f984df4 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -698,11 +698,46 @@ export const SECRET_IMPORTS = { } } as const; +export const DASHBOARD = { + SECRET_OVERVIEW_LIST: { + projectId: "The ID of the project to list secrets/folders from.", + environments: + "The slugs of the environments to list secrets/folders from (comma separated, ie 'environments=dev,staging,prod').", + secretPath: "The secret path to list secrets/folders from.", + offset: "The offset to start from. If you enter 10, it will start from the 10th secret/folder.", + limit: "The number of secrets/folders to return.", + orderBy: "The column to order secrets/folders by.", + orderDirection: "The direction to order secrets/folders in.", + search: "The text string to filter secret keys and folder names by.", + includeSecrets: "Whether to include project secrets in the response.", + includeFolders: "Whether to include project folders in the response.", + includeDynamicSecrets: "Whether to include dynamic project secrets in the response." + }, + SECRET_DETAILS_LIST: { + projectId: "The ID of the project to list secrets/folders from.", + environment: "The slug of the environment to list secrets/folders from.", + secretPath: "The secret path to list secrets/folders from.", + offset: "The offset to start from. If you enter 10, it will start from the 10th secret/folder.", + limit: "The number of secrets/folders to return.", + orderBy: "The column to order secrets/folders by.", + orderDirection: "The direction to order secrets/folders in.", + search: "The text string to filter secret keys and folder names by.", + tags: "The tags to filter secrets by (comma separated, ie 'tags=billing,engineering').", + includeSecrets: "Whether to include project secrets in the response.", + includeFolders: "Whether to include project folders in the response.", + includeImports: "Whether to include project secret imports in the response.", + includeDynamicSecrets: "Whether to include dynamic project secrets in the response." + } +} as const; + export const AUDIT_LOGS = { EXPORT: { - workspaceId: "The ID of the project to export audit logs from.", + projectId: + "Optionally filter logs by project ID. If not provided, logs from the entire organization will be returned.", eventType: "The type of the event to export.", userAgentType: "Choose which consuming application to export audit logs for.", + eventMetadata: + "Filter by event metadata key-value pairs. Formatted as `key1=value1,key2=value2`, with comma-separation.", startDate: "The date to start the export from.", endDate: "The date to end the export at.", offset: "The offset to start from. If you enter 10, it will start from the 10th audit log.", diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 73a246433..06b60f27e 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -147,8 +147,8 @@ const envSchema = z PLAIN_WISH_LABEL_IDS: zpStr(z.string().optional()), DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false"), SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert"), - WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string()).optional(), - WORKFLOW_SLACK_CLIENT_SECRET: zpStr(z.string()).optional() + WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string().optional()), + WORKFLOW_SLACK_CLIENT_SECRET: zpStr(z.string().optional()) }) .transform((data) => ({ ...data, diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index cbf5db3b0..36d81ae34 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -51,11 +51,17 @@ export type TFindReturn; -export type TFindOpt = { +export type TFindOpt< + R extends object = object, + TCount extends boolean = boolean, + TCountDistinct extends keyof R | undefined = undefined +> = { limit?: number; offset?: number; sort?: Array<[keyof R, "asc" | "desc"] | [keyof R, "asc" | "desc", "first" | "last"]>; + groupBy?: keyof R; count?: TCount; + countDistinct?: TCountDistinct; tx?: Knex; }; @@ -86,13 +92,18 @@ export const ormify = (db: Kne throw new DatabaseError({ error, name: "Find one" }); } }, - find: async ( + find: async < + TCount extends boolean = false, + TCountDistinct extends keyof Tables[Tname]["base"] | undefined = undefined + >( filter: TFindFilter, - { offset, limit, sort, count, tx }: TFindOpt = {} + { offset, limit, sort, count, tx, countDistinct }: TFindOpt = {} ) => { try { const query = (tx || db.replicaNode())(tableName).where(buildFindFilter(filter)); - if (count) { + if (countDistinct) { + void query.countDistinct(countDistinct); + } else if (count) { void query.select(db.raw("COUNT(*) OVER() AS count")); void query.select("*"); } @@ -101,7 +112,8 @@ export const ormify = (db: Kne if (sort) { void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); } - const res = (await query) as TFindReturn; + + const res = (await query) as TFindReturn; return res; } catch (error) { throw new DatabaseError({ error, name: "Find one" }); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index bf036ba84..0606f9dba 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -7,7 +7,11 @@ import { TScanFullRepoEventPayload, TScanPushEventPayload } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; -import { TSyncSecretsDTO } from "@app/services/secret/secret-types"; +import { + TFailedIntegrationSyncEmailsPayload, + TIntegrationSyncPayload, + TSyncSecretsDTO +} from "@app/services/secret/secret-types"; export enum QueueName { SecretRotation = "secret-rotation", @@ -42,6 +46,7 @@ export enum QueueJobs { SecWebhook = "secret-webhook-trigger", TelemetryInstanceStats = "telemetry-self-hosted-stats", IntegrationSync = "secret-integration-pull", + SendFailedIntegrationSyncEmails = "send-failed-integration-sync-emails", SecretScan = "secret-scan", UpgradeProjectToGhost = "upgrade-project-to-ghost-job", DynamicSecretRevocation = "dynamic-secret-revocation", @@ -88,16 +93,26 @@ export type TQueueJobTypes = { name: QueueJobs.SecWebhook; payload: { projectId: string; environment: string; secretPath: string; depth?: number }; }; - [QueueName.IntegrationSync]: { - name: QueueJobs.IntegrationSync; - payload: { - projectId: string; - environment: string; - secretPath: string; - depth?: number; - deDupeQueue?: Record; - }; - }; + + [QueueName.AccessTokenStatusUpdate]: + | { + name: QueueJobs.IdentityAccessTokenStatusUpdate; + payload: { identityAccessTokenId: string; numberOfUses: number }; + } + | { + name: QueueJobs.ServiceTokenStatusUpdate; + payload: { serviceTokenId: string }; + }; + + [QueueName.IntegrationSync]: + | { + name: QueueJobs.IntegrationSync; + payload: TIntegrationSyncPayload; + } + | { + name: QueueJobs.SendFailedIntegrationSyncEmails; + payload: TFailedIntegrationSyncEmailsPayload; + }; [QueueName.SecretFullRepoScan]: { name: QueueJobs.SecretScan; payload: TScanFullRepoEventPayload; @@ -151,15 +166,6 @@ export type TQueueJobTypes = { name: QueueJobs.ProjectV3Migration; payload: { projectId: string }; }; - [QueueName.AccessTokenStatusUpdate]: - | { - name: QueueJobs.IdentityAccessTokenStatusUpdate; - payload: { identityAccessTokenId: string; numberOfUses: number }; - } - | { - name: QueueJobs.ServiceTokenStatusUpdate; - payload: { serviceTokenId: string }; - }; }; export type TQueueServiceFactory = ReturnType; diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 3320c7d87..1aa2c0a44 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; import fastifyPlugin from "fastify-plugin"; +import jwt from "jsonwebtoken"; import { ZodError } from "zod"; import { @@ -11,6 +12,12 @@ import { UnauthorizedError } from "@app/lib/errors"; +enum JWTErrors { + JwtExpired = "jwt expired", + JwtMalformed = "jwt malformed", + InvalidAlgorithm = "invalid algorithm" +} + export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => { server.setErrorHandler((error, req, res) => { req.log.error(error); @@ -36,6 +43,27 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider status: error.status, detail: error.detail }); + // Handle JWT errors and make them more human-readable for the end-user. + } else if (error instanceof jwt.JsonWebTokenError) { + const message = (() => { + if (error.message === JWTErrors.JwtExpired) { + return "Your token has expired. Please re-authenticate."; + } + if (error.message === JWTErrors.JwtMalformed) { + return "The provided access token is malformed. Please use a valid token or generate a new one and try again."; + } + if (error.message === JWTErrors.InvalidAlgorithm) { + return "The access token is signed with an invalid algorithm. Please provide a valid token and try again."; + } + + return error.message; + })(); + + void res.status(401).send({ + statusCode: 401, + error: "TokenError", + message + }); } else { void res.send(error); } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f0675dff5..3eb6b0031 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -493,7 +493,6 @@ export const registerRoutes = async ( orgRoleDAL, permissionService, orgDAL, - userGroupMembershipDAL, projectBotDAL, incidentContactDAL, tokenService, @@ -811,6 +810,8 @@ export const registerRoutes = async ( projectEnvDAL, webhookDAL, orgDAL, + auditLogService, + userDAL, projectMembershipDAL, smtpService, projectDAL, diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 6526dd940..f08bb7e3b 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -4,7 +4,7 @@ import { IntegrationsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { INTEGRATION } from "@app/lib/api-docs"; import { removeTrailingSlash, shake } from "@app/lib/fn"; -import { writeLimit } from "@app/server/config/rateLimiter"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -154,6 +154,48 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:integrationId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get an integration by integration id", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + integrationId: z.string().trim().describe(INTEGRATION.UPDATE.integrationId) + }), + response: { + 200: z.object({ + integration: IntegrationsSchema.extend({ + environment: z.object({ + slug: z.string().trim(), + name: z.string().trim(), + id: z.string().trim() + }) + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const integration = await server.services.integration.getIntegration({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.integrationId + }); + + return { integration }; + } + }); + server.route({ method: "DELETE", url: "/:integrationId", diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 68a1dba45..8170fe04a 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -14,7 +14,7 @@ import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO } from "@app/lib/fn"; 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"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ @@ -74,8 +74,36 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { schema: { description: "Get all audit logs for an organization", querystring: z.object({ - eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), + projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId), + actorType: z.nativeEnum(ActorType).optional(), + // eventType is split with , for multiple values, we need to transform it to array + eventType: z + .string() + .optional() + .transform((val) => (val ? val.split(",") : undefined)), userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), + eventMetadata: z + .string() + .optional() + .transform((val) => { + if (!val) { + return undefined; + } + + const pairs = val.split(","); + + return pairs.reduce( + (acc, pair) => { + const [key, value] = pair.split("="); + if (key && value) { + acc[key] = value; + } + return acc; + }, + {} as Record + ); + }) + .describe(AUDIT_LOGS.EXPORT.eventMetadata), startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), @@ -93,10 +121,12 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }) .merge( z.object({ - project: z.object({ - name: z.string(), - slug: z.string() - }), + project: z + .object({ + name: z.string(), + slug: z.string() + }) + .optional(), event: z.object({ type: z.string(), metadata: z.any() @@ -114,13 +144,19 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { const auditLogs = await server.services.auditLog.listAuditLogs({ + filter: { + ...req.query, + endDate: req.query.endDate, + projectId: req.query.projectId, + startDate: req.query.startDate || getLastMidnightDateISO(), + auditLogActorId: req.query.actor, + actorType: req.query.actorType, + eventType: req.query.eventType as EventType[] | undefined + }, + actorId: req.permission.id, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, - ...req.query, - endDate: req.query.endDate, - startDate: req.query.startDate || getLastMidnightDateISO(), - auditLogActor: req.query.actor, actor: req.permission.type }); return { auditLogs }; diff --git a/backend/src/server/routes/v3/dashboard-router.ts b/backend/src/server/routes/v3/dashboard-router.ts new file mode 100644 index 000000000..7d06b1ce7 --- /dev/null +++ b/backend/src/server/routes/v3/dashboard-router.ts @@ -0,0 +1,612 @@ +import { z } from "zod"; + +import { SecretFoldersSchema, SecretImportsSchema, SecretTagsSchema } from "@app/db/schemas"; +import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; +import { DASHBOARD } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { OrderByDirection } from "@app/lib/types"; +import { secretsLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; +import { getUserAgentType } from "@app/server/plugins/audit-log"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { SanitizedDynamicSecretSchema, secretRawSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; + +export const registerDashboardRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/secrets-overview", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "List project secrets overview", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + projectId: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.projectId), + environments: z + .string() + .trim() + .transform(decodeURIComponent) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.environments), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.secretPath), + offset: z.coerce.number().min(0).optional().default(0).describe(DASHBOARD.SECRET_OVERVIEW_LIST.offset), + limit: z.coerce.number().min(1).max(100).optional().default(100).describe(DASHBOARD.SECRET_OVERVIEW_LIST.limit), + orderBy: z + .nativeEnum(SecretsOrderBy) + .default(SecretsOrderBy.Name) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.orderBy) + .optional(), + orderDirection: z + .nativeEnum(OrderByDirection) + .default(OrderByDirection.ASC) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.orderDirection) + .optional(), + search: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.search).optional(), + includeSecrets: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets), + includeFolders: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders), + includeDynamicSecrets: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets) + }), + response: { + 200: z.object({ + folders: SecretFoldersSchema.extend({ environment: z.string() }).array().optional(), + dynamicSecrets: SanitizedDynamicSecretSchema.extend({ environment: z.string() }).array().optional(), + secrets: secretRawSchema + .extend({ + secretPath: z.string().optional(), + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + color: true + }) + .extend({ name: z.string() }) + .array() + .optional() + }) + .array() + .optional(), + totalFolderCount: z.number().optional(), + totalDynamicSecretCount: z.number().optional(), + totalSecretCount: z.number().optional(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + secretPath, + projectId, + limit, + offset, + search, + orderBy, + orderDirection, + includeFolders, + includeSecrets, + includeDynamicSecrets + } = req.query; + + const environments = req.query.environments.split(","); + + if (!projectId || environments.length === 0) + throw new BadRequestError({ message: "Missing workspace id or environment(s)" }); + + const { shouldUseSecretV2Bridge } = await server.services.projectBot.getBotKey(projectId); + + // prevent older projects from accessing endpoint + if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" }); + + let remainingLimit = limit; + let adjustedOffset = offset; + + let folders: Awaited> | undefined; + let secrets: Awaited> | undefined; + let dynamicSecrets: + | Awaited> + | undefined; + + let totalFolderCount: number | undefined; + let totalDynamicSecretCount: number | undefined; + let totalSecretCount: number | undefined; + + if (includeFolders) { + // this is the unique count, ie duplicate folders across envs only count as 1 + totalFolderCount = await server.services.folder.getProjectFolderCount({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.query.projectId, + path: secretPath, + environments, + search + }); + + if (remainingLimit > 0 && totalFolderCount > adjustedOffset) { + folders = await server.services.folder.getFoldersMultiEnv({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + environments, + path: secretPath, + orderBy, + orderDirection, + search, + limit: remainingLimit, + offset: adjustedOffset + }); + + // get the count of unique folder names to properly adjust remaining limit + const uniqueFolderCount = new Set(folders.map((folder) => folder.name)).size; + + remainingLimit -= uniqueFolderCount; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalFolderCount); + } + } + + if (includeDynamicSecrets) { + // this is the unique count, ie duplicate secrets across envs only count as 1 + totalDynamicSecretCount = await server.services.dynamicSecret.getCountMultiEnv({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + search, + environmentSlugs: environments, + path: secretPath + }); + + if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) { + dynamicSecrets = await server.services.dynamicSecret.listDynamicSecretsByFolderIds({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + search, + orderBy, + orderDirection, + environmentSlugs: environments, + path: secretPath, + limit: remainingLimit, + offset: adjustedOffset + }); + + // get the count of unique dynamic secret names to properly adjust remaining limit + const uniqueDynamicSecretsCount = new Set(dynamicSecrets.map((dynamicSecret) => dynamicSecret.name)).size; + + remainingLimit -= uniqueDynamicSecretsCount; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount); + } + } + + if (includeSecrets) { + // this is the unique count, ie duplicate secrets across envs only count as 1 + totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + environments, + actorAuthMethod: req.permission.authMethod, + projectId, + path: secretPath, + search + }); + + if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { + secrets = await server.services.secret.getSecretsRawMultiEnv({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + environments, + actorAuthMethod: req.permission.authMethod, + projectId, + path: secretPath, + orderBy, + orderDirection, + search, + limit: remainingLimit, + offset: adjustedOffset + }); + + for await (const environment of environments) { + const secretCountFromEnv = secrets.filter((secret) => secret.environment === environment).length; + + if (secretCountFromEnv) { + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRETS, + metadata: { + environment, + secretPath, + numberOfSecrets: secretCountFromEnv + } + } + }); + + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secretCountFromEnv, + workspaceId: projectId, + environment, + secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } + } + } + } + } + + return { + folders, + dynamicSecrets, + secrets, + totalFolderCount, + totalDynamicSecretCount, + totalSecretCount, + totalCount: (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + }; + } + }); + + server.route({ + method: "GET", + url: "/secrets-details", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "List project secrets details", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + projectId: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.projectId), + environment: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(DASHBOARD.SECRET_DETAILS_LIST.secretPath), + offset: z.coerce.number().min(0).optional().default(0).describe(DASHBOARD.SECRET_DETAILS_LIST.offset), + limit: z.coerce.number().min(1).max(100).optional().default(100).describe(DASHBOARD.SECRET_DETAILS_LIST.limit), + orderBy: z + .nativeEnum(SecretsOrderBy) + .default(SecretsOrderBy.Name) + .describe(DASHBOARD.SECRET_DETAILS_LIST.orderBy) + .optional(), + orderDirection: z + .nativeEnum(OrderByDirection) + .default(OrderByDirection.ASC) + .describe(DASHBOARD.SECRET_DETAILS_LIST.orderDirection) + .optional(), + search: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.search).optional(), + tags: z.string().trim().transform(decodeURIComponent).describe(DASHBOARD.SECRET_DETAILS_LIST.tags).optional(), + includeSecrets: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecrets), + includeFolders: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_DETAILS_LIST.includeFolders), + includeDynamicSecrets: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets), + includeImports: z.coerce + .boolean() + .optional() + .default(true) + .describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports) + }), + response: { + 200: z.object({ + imports: SecretImportsSchema.omit({ importEnv: true }) + .extend({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) + }) + .array() + .optional(), + folders: SecretFoldersSchema.array().optional(), + dynamicSecrets: SanitizedDynamicSecretSchema.array().optional(), + secrets: secretRawSchema + .extend({ + secretPath: z.string().optional(), + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + color: true + }) + .extend({ name: z.string() }) + .array() + .optional() + }) + .array() + .optional(), + totalImportCount: z.number().optional(), + totalFolderCount: z.number().optional(), + totalDynamicSecretCount: z.number().optional(), + totalSecretCount: z.number().optional(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + secretPath, + environment, + projectId, + limit, + offset, + search, + orderBy, + orderDirection, + includeFolders, + includeSecrets, + includeDynamicSecrets, + includeImports + } = req.query; + + if (!projectId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" }); + + const { shouldUseSecretV2Bridge } = await server.services.projectBot.getBotKey(projectId); + + // prevent older projects from accessing endpoint + if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" }); + + const tags = req.query.tags?.split(",") ?? []; + + let remainingLimit = limit; + let adjustedOffset = offset; + + let imports: Awaited> | undefined; + let folders: Awaited> | undefined; + let secrets: Awaited>["secrets"] | undefined; + let dynamicSecrets: Awaited> | undefined; + + let totalImportCount: number | undefined; + let totalFolderCount: number | undefined; + let totalDynamicSecretCount: number | undefined; + let totalSecretCount: number | undefined; + + if (includeImports) { + totalImportCount = await server.services.secretImport.getProjectImportCount({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + environment, + path: secretPath, + search + }); + + if (remainingLimit > 0 && totalImportCount > adjustedOffset) { + imports = await server.services.secretImport.getImports({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + environment, + path: secretPath, + search, + limit: remainingLimit, + offset: adjustedOffset + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.GET_SECRET_IMPORTS, + metadata: { + environment, + folderId: imports?.[0]?.folderId, + numberOfImports: imports.length + } + } + }); + + remainingLimit -= imports.length; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalImportCount); + } + } + + if (includeFolders) { + totalFolderCount = await server.services.folder.getProjectFolderCount({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + path: secretPath, + environments: [environment], + search + }); + + if (remainingLimit > 0 && totalFolderCount > adjustedOffset) { + folders = await server.services.folder.getFolders({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + environment, + path: secretPath, + orderBy, + orderDirection, + search, + limit: remainingLimit, + offset: adjustedOffset + }); + + remainingLimit -= folders.length; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalFolderCount); + } + } + + if (includeDynamicSecrets) { + totalDynamicSecretCount = await server.services.dynamicSecret.getDynamicSecretCount({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + search, + environmentSlug: environment, + path: secretPath + }); + + if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) { + dynamicSecrets = await server.services.dynamicSecret.listDynamicSecretsByEnv({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + search, + orderBy, + orderDirection, + environmentSlug: environment, + path: secretPath, + limit: remainingLimit, + offset: adjustedOffset + }); + + remainingLimit -= dynamicSecrets.length; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount); + } + } + + if (includeSecrets) { + totalSecretCount = await server.services.secret.getSecretsCount({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + environment, + actorAuthMethod: req.permission.authMethod, + projectId, + path: secretPath, + search, + tagSlugs: tags + }); + + if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { + const secretsRaw = await server.services.secret.getSecretsRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + environment, + actorAuthMethod: req.permission.authMethod, + projectId, + path: secretPath, + orderBy, + orderDirection, + search, + limit: remainingLimit, + offset: adjustedOffset, + tagSlugs: tags + }); + + secrets = secretsRaw.secrets; + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRETS, + metadata: { + environment, + secretPath, + numberOfSecrets: secrets.length + } + } + }); + + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secrets.length, + workspaceId: projectId, + environment, + secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } + } + } + + return { + imports, + folders, + dynamicSecrets, + secrets, + totalImportCount, + totalFolderCount, + totalDynamicSecretCount, + totalSecretCount, + totalCount: + (totalImportCount ?? 0) + (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + }; + } + }); +}; diff --git a/backend/src/server/routes/v3/index.ts b/backend/src/server/routes/v3/index.ts index 10f3c9330..6a9bbf664 100644 --- a/backend/src/server/routes/v3/index.ts +++ b/backend/src/server/routes/v3/index.ts @@ -1,3 +1,4 @@ +import { registerDashboardRouter } from "./dashboard-router"; import { registerGroupProjectRouter } from "./group-project-router"; import { registerLoginRouter } from "./login-router"; import { registerSecretBlindIndexRouter } from "./secret-blind-index-router"; @@ -17,4 +18,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => { }, { prefix: "/workspaces" } ); + await server.register(registerDashboardRouter, { prefix: "/dashboard" }); }; diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 9210093ab..87522a803 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -34,6 +34,7 @@ export enum AuthMode { } export enum ActorType { // would extend to AWS, Azure, ... + PLATFORM = "platform", // Useful for when we want to perform logging on automated actions such as integration syncs. USER = "user", // userIdentity SERVICE = "service", IDENTITY = "identity", diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts index bbcadb96e..dbe43c30a 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -157,7 +157,7 @@ export const groupProjectDALFactory = (db: TDbClient) => { `${TableName.ProjectRoles}.id` ) .select( - db.ref("id").withSchema(TableName.GroupProjectMembership), + db.ref("id").withSchema(TableName.UserGroupMembership), db.ref("isGhost").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users), diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index 4c687e86c..a0feb824c 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -18,7 +18,7 @@ import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; -import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -68,12 +68,12 @@ export const identityOidcAuthServiceFactory = ({ identityId: identityOidcAuth.identityId }); if (!identityMembershipOrg) { - throw new BadRequestError({ message: "Failed to find identity" }); + throw new NotFoundError({ message: "Failed to find identity in organization" }); } const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }); if (!orgBot) { - throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + throw new NotFoundError({ message: "Org bot not found", name: "OrgBotNotFound" }); } const key = infisicalSymmetricDecrypt({ @@ -106,7 +106,7 @@ export const identityOidcAuthServiceFactory = ({ const decodedToken = jwt.decode(oidcJwt, { complete: true }); if (!decodedToken) { - throw new BadRequestError({ + throw new UnauthorizedError({ message: "Invalid JWT" }); } @@ -119,13 +119,24 @@ export const identityOidcAuthServiceFactory = ({ const { kid } = decodedToken.header; const oidcSigningKey = await client.getSigningKey(kid); - const tokenData = jwt.verify(oidcJwt, oidcSigningKey.getPublicKey(), { - issuer: identityOidcAuth.boundIssuer - }) as Record; + let tokenData: Record; + try { + tokenData = jwt.verify(oidcJwt, oidcSigningKey.getPublicKey(), { + issuer: identityOidcAuth.boundIssuer + }) as Record; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + throw new UnauthorizedError({ + message: `Access denied: ${error.message}` + }); + } + + throw error; + } if (identityOidcAuth.boundSubject) { if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) { - throw new ForbiddenRequestError({ + throw new UnauthorizedError({ message: "Access denied: OIDC subject not allowed." }); } @@ -137,7 +148,7 @@ export const identityOidcAuthServiceFactory = ({ .split(", ") .some((policyValue) => doesFieldValueMatchOidcPolicy(tokenData.aud, policyValue)) ) { - throw new ForbiddenRequestError({ + throw new UnauthorizedError({ message: "Access denied: OIDC audience not allowed." }); } @@ -150,7 +161,7 @@ export const identityOidcAuthServiceFactory = ({ if ( !claimValue.split(", ").some((claimEntry) => doesFieldValueMatchOidcPolicy(tokenData[claimKey], claimEntry)) ) { - throw new ForbiddenRequestError({ + throw new UnauthorizedError({ message: "Access denied: OIDC claim not allowed." }); } diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 0416d1851..933a2b17a 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -58,7 +58,8 @@ export const identityServiceFactory = ({ if (!hasRequiredPriviledges) throw new BadRequestError({ message: "Failed to create a more privileged identity" }); const plan = await licenseService.getPlan(orgId); - if (plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { + + if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { // limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed throw new BadRequestError({ message: "Failed to create identity due to identity limit reached. Upgrade plan to create more identities." diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index d6930c594..95dac6800 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -242,37 +242,12 @@ const getAppsGithub = async ({ accessToken }: { accessToken: string }) => { }; } - const octokit = new Octokit({ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const repos = (await new Octokit({ auth: accessToken - }); - - const getAllRepos = async () => { - let repos: GitHubApp[] = []; - let page = 1; - const perPage = 100; - let hasMore = true; - - while (hasMore) { - const response = await octokit.request( - "GET /user/repos{?visibility,affiliation,type,sort,direction,per_page,page,since,before}", - { - per_page: perPage, - page - } - ); - - if ((response.data as GitHubApp[]).length > 0) { - repos = repos.concat(response.data as GitHubApp[]); - page += 1; - } else { - hasMore = false; - } - } - - return repos; - }; - - const repos = await getAllRepos(); + }).paginate("GET /user/repos{?visibility,affiliation,type,sort,direction,per_page,page,since,before}", { + per_page: 100 + })) as GitHubApp[]; const apps = repos .filter((a: GitHubApp) => a.permissions.admin === true) diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 02e520c6e..029825baa 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TProjectPermission } from "@app/lib/types"; import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal"; @@ -19,6 +19,7 @@ import { TIntegrationDALFactory } from "./integration-dal"; import { TCreateIntegrationDTO, TDeleteIntegrationDTO, + TGetIntegrationDTO, TSyncIntegrationDTO, TUpdateIntegrationDTO } from "./integration-types"; @@ -180,6 +181,27 @@ export const integrationServiceFactory = ({ return updatedIntegration; }; + const getIntegration = async ({ id, actor, actorAuthMethod, actorId, actorOrgId }: TGetIntegrationDTO) => { + const integration = await integrationDAL.findById(id); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integration?.projectId || "", + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + + if (!integration) { + throw new NotFoundError({ + message: "Integration not found" + }); + } + + return { ...integration, envId: integration.environment.id }; + }; + const deleteIntegration = async ({ actorId, id, @@ -276,6 +298,8 @@ export const integrationServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); await secretQueueService.syncIntegrations({ + isManual: true, + actorId, environment: integration.environment.slug, secretPath: integration.secretPath, projectId: integration.projectId @@ -289,6 +313,7 @@ export const integrationServiceFactory = ({ updateIntegration, deleteIntegration, listIntegrationByProject, + getIntegration, syncIntegration }; }; diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 0df8edc4a..5c76159de 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -39,6 +39,10 @@ export type TCreateIntegrationDTO = { }; } & Omit; +export type TGetIntegrationDTO = { + id: string; +} & Omit; + export type TUpdateIntegrationDTO = { id: string; app?: string; diff --git a/backend/src/services/org/org-role-service.ts b/backend/src/services/org/org-role-service.ts index 26cfd67eb..d734d7818 100644 --- a/backend/src/services/org/org-role-service.ts +++ b/backend/src/services/org/org-role-service.ts @@ -60,7 +60,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol name: "Admin", slug: "admin", description: "Complete administration access over the organization", - permissions: packRules(orgAdminPermissions.rules), + permissions: packRules(orgAdminPermissions), createdAt: new Date(), updatedAt: new Date() }; @@ -72,7 +72,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol name: "Member", slug: "member", description: "Non-administrative role in an organization", - permissions: packRules(orgMemberPermissions.rules), + permissions: packRules(orgMemberPermissions), createdAt: new Date(), updatedAt: new Date() }; @@ -84,7 +84,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol name: "No Access", slug: "no-access", description: "No access to any resources in the organization", - permissions: packRules(orgNoAccessPermissions.rules), + permissions: packRules(orgNoAccessPermissions), createdAt: new Date(), updatedAt: new Date() }; @@ -151,7 +151,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol name: "Admin", slug: "admin", description: "Complete administration access over the organization", - permissions: packRules(orgAdminPermissions.rules), + permissions: packRules(orgAdminPermissions), createdAt: new Date(), updatedAt: new Date() }, @@ -161,7 +161,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol name: "Member", slug: "member", description: "Non-administrative role in an organization", - permissions: packRules(orgMemberPermissions.rules), + permissions: packRules(orgMemberPermissions), createdAt: new Date(), updatedAt: new Date() }, @@ -171,7 +171,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol name: "No Access", slug: "no-access", description: "No access to any resources in the organization", - permissions: packRules(orgNoAccessPermissions.rules), + permissions: packRules(orgNoAccessPermissions), createdAt: new Date(), updatedAt: new Date() }, diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index c0a640112..8417f2444 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -17,7 +17,6 @@ import { } from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; -import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -90,7 +89,6 @@ type TOrgServiceFactoryDep = { >; projectUserAdditionalPrivilegeDAL: Pick; projectRoleDAL: Pick; - userGroupMembershipDAL: Pick; projectBotDAL: Pick; projectUserMembershipRoleDAL: Pick; }; @@ -116,7 +114,6 @@ export const orgServiceFactory = ({ licenseService, projectRoleDAL, samlConfigDAL, - userGroupMembershipDAL, projectBotDAL, projectUserMembershipRoleDAL }: TOrgServiceFactoryDep) => { @@ -461,12 +458,6 @@ export const orgServiceFactory = ({ const org = await orgDAL.findOrgById(orgId); - if (org?.authEnforced) { - throw new BadRequestError({ - message: "Failed to invite user due to org-level auth enforced for organization" - }); - } - const isEmailInvalid = await isDisposableEmail(inviteeEmails); if (isEmailInvalid) { throw new BadRequestError({ @@ -475,19 +466,6 @@ export const orgServiceFactory = ({ }); } const plan = await licenseService.getPlan(orgId); - if (plan?.memberLimit && plan.membersUsed >= plan.memberLimit) { - // limit imposed on number of members allowed / number of members used exceeds the number of members allowed - throw new BadRequestError({ - message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members." - }); - } - - if (plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { - // limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed - throw new BadRequestError({ - message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members." - }); - } const isCustomOrgRole = !Object.values(OrgMembershipRole).includes(organizationRoleSlug as OrgMembershipRole); if (isCustomOrgRole) { if (!plan?.rbac) @@ -572,7 +550,7 @@ export const orgServiceFactory = ({ ); } - const [inviteeMembership] = await orgDAL.findMembership( + const [inviteeOrgMembership] = await orgDAL.findMembership( { [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUserId @@ -581,7 +559,27 @@ export const orgServiceFactory = ({ ); // if there exist no org membership we set is as given by the request - if (!inviteeMembership) { + if (!inviteeOrgMembership) { + if (plan?.slug !== "enterprise" && plan?.memberLimit && plan.membersUsed >= plan.memberLimit) { + // limit imposed on number of members allowed / number of members used exceeds the number of members allowed + throw new BadRequestError({ + message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members." + }); + } + + if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { + // limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed + throw new BadRequestError({ + message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members." + }); + } + + if (org?.authEnforced) { + throw new BadRequestError({ + message: "Failed to invite user due to org-level auth enforced for organization" + }); + } + // as its used by project invite also ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); let roleId; @@ -617,7 +615,6 @@ export const orgServiceFactory = ({ } const userIds = users.map(({ id }) => id); - const usernames = users.map((el) => el.username); const userEncryptionKeys = await userDAL.findUserEncKeyByUserIdsBatch({ userIds }, tx); // we don't need to spam with email. Thus org invitation doesn't need project invitation again const userIdsWithOrgInvitation = new Set(mailsForOrgInvitation.map((el) => el.userId)); @@ -644,12 +641,10 @@ export const orgServiceFactory = ({ { tx } ); const existingMembersGroupByUserId = groupBy(existingMembers, (i) => i.userId); - const userIdsToExcludeAsPartOfGroup = new Set( - await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernames, projectId, tx) - ); const userWithEncryptionKeyInvitedToProject = userEncryptionKeys.filter( - (user) => !existingMembersGroupByUserId?.[user.userId] && !userIdsToExcludeAsPartOfGroup.has(user.userId) + (user) => !existingMembersGroupByUserId?.[user.userId] ); + // eslint-disable-next-line no-continue if (!userWithEncryptionKeyInvitedToProject.length) continue; diff --git a/backend/src/services/project-bot/project-bot-fns.ts b/backend/src/services/project-bot/project-bot-fns.ts index 9cdb52cff..4efcbb34b 100644 --- a/backend/src/services/project-bot/project-bot-fns.ts +++ b/backend/src/services/project-bot/project-bot-fns.ts @@ -26,7 +26,10 @@ export const getBotKeyFnFactory = ( ) => { const getBotKeyFn = async (projectId: string) => { const project = await projectDAL.findById(projectId); - if (!project) throw new BadRequestError({ message: "Project not found during bot lookup." }); + if (!project) + throw new BadRequestError({ + message: "Project not found during bot lookup. Are you sure you are using the correct project ID?" + }); if (project.version === 3) { return { project, shouldUseSecretV2Bridge: true }; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index e2ed4647d..1086a36ea 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -90,15 +90,20 @@ export const projectMembershipServiceFactory = ({ // projectMembers[0].project if (includeGroupMembers) { const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId); - const allMembers = [ ...projectMembers.map((m) => ({ ...m, isGroupMember: false })), ...groupMembers.map((m) => ({ ...m, isGroupMember: true })) ]; // Ensure the userId is unique - const membersIds = new Set(allMembers.map((entity) => entity.user.id)); - const uniqueMembers = allMembers.filter((entity) => membersIds.has(entity.user.id)); + const uniqueMembers: typeof allMembers = []; + const addedUserIds = new Set(); + allMembers.forEach((member) => { + if (!addedUserIds.has(member.user.id)) { + uniqueMembers.push(member); + addedUserIds.add(member.user.id); + } + }); return uniqueMembers; } diff --git a/backend/src/services/secret-folder/secret-folder-dal.ts b/backend/src/services/secret-folder/secret-folder-dal.ts index a2a01be68..6365bd824 100644 --- a/backend/src/services/secret-folder/secret-folder-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-dal.ts @@ -5,6 +5,8 @@ import { TableName, TProjectEnvironments, TSecretFolders, TSecretFoldersUpdate } import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { groupBy, removeTrailingSlash } from "@app/lib/fn"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { OrderByDirection } from "@app/lib/types"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; export const validateFolderName = (folderName: string) => { const validNameRegex = /^[a-zA-Z0-9-_]+$/; @@ -83,7 +85,7 @@ const sqlFindMultipleFolderByEnvPathQuery = (db: Knex, query: Array<{ envId: str .from("parent"); }; -const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: string, secretPath: string) => { +const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: string[], secretPath: string) => { // this is removing an trailing slash like /folder1/folder2/ -> /folder1/folder2 const formatedPath = secretPath.at(-1) === "/" && secretPath.length > 1 ? secretPath.slice(0, -1) : secretPath; // next goal to sanitize saw the raw sql query is safe @@ -111,7 +113,7 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: stri projectId, parentId: null }) - .where(`${TableName.Environment}.slug`, environment) + .whereIn(`${TableName.Environment}.slug`, environments) .select(selectAllTableCols(TableName.SecretFolder)) .union( (qb) => @@ -139,14 +141,14 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: stri .from("parent") .leftJoin(TableName.Environment, `${TableName.Environment}.id`, "parent.envId") .select< - TSecretFolders & { + (TSecretFolders & { depth: number; path: string; envId: string; envSlug: string; envName: string; projectId: string; - } + })[] >( selectAllTableCols("parent" as TableName.SecretFolder), db.ref("id").withSchema(TableName.Environment).as("envId"), @@ -214,7 +216,7 @@ export const secretFolderDALFactory = (db: TDbClient) => { const folder = await sqlFindFolderByPathQuery( tx || db.replicaNode(), projectId, - environment, + [environment], removeTrailingSlash(path) ) .orderBy("depth", "desc") @@ -230,6 +232,35 @@ export const secretFolderDALFactory = (db: TDbClient) => { } }; + // finds folders by path for multiple envs + const findBySecretPathMultiEnv = async (projectId: string, environments: string[], path: string, tx?: Knex) => { + try { + const pathDepth = removeTrailingSlash(path).split("/").filter(Boolean).length + 1; + + const folders = await sqlFindFolderByPathQuery( + tx || db.replicaNode(), + projectId, + environments, + removeTrailingSlash(path) + ) + .orderBy("depth", "desc") + .where("depth", pathDepth); + + const firstFolder = folders[0]; + + if (firstFolder && firstFolder.path !== removeTrailingSlash(path)) { + return []; + } + + return folders.map((folder) => { + const { envId: id, envName: name, envSlug: slug, ...el } = folder; + return { ...el, envId: id, environment: { id, name, slug } }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find folders by secret path multi env" }); + } + }; + // used in folder creation // even if its the original given /path1/path2 // it will stop automatically at /path2 @@ -238,7 +269,7 @@ export const secretFolderDALFactory = (db: TDbClient) => { const folder = await sqlFindFolderByPathQuery( tx || db.replicaNode(), projectId, - environment, + [environment], removeTrailingSlash(path) ) .orderBy("depth", "desc") @@ -352,14 +383,77 @@ export const secretFolderDALFactory = (db: TDbClient) => { } }; + // find project folders for multiple envs + const findByMultiEnv = async ( + { + environmentIds, + parentIds, + search, + limit, + offset = 0, + orderBy = SecretsOrderBy.Name, + orderDirection = OrderByDirection.ASC + }: { + environmentIds: string[]; + parentIds: string[]; + search?: string; + limit?: number; + offset?: number; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + }, + tx?: Knex + ) => { + try { + const query = (tx || db.replicaNode())(TableName.SecretFolder) + .whereIn("parentId", parentIds) + .whereIn("envId", environmentIds) + .where("isReserved", false) + .where((bd) => { + if (search) { + void bd.whereILike(`${TableName.SecretFolder}.name`, `%${search}%`); + } + }) + .leftJoin(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) + .select( + selectAllTableCols(TableName.SecretFolder), + db.raw( + `DENSE_RANK() OVER (ORDER BY ${TableName.SecretFolder}."name" ${ + orderDirection ?? OrderByDirection.ASC + }) as rank` + ), + db.ref("slug").withSchema(TableName.Environment).as("environment") + ) + .orderBy(`${TableName.SecretFolder}.${orderBy}`, orderDirection); + + if (limit) { + const rankOffset = offset + 1; // ranks start from 1 + return await (tx || db) + .with("w", query) + .select("*") + .from[number]>("w") + .where("w.rank", ">=", rankOffset) + .andWhere("w.rank", "<", rankOffset + limit); + } + + const folders = await query; + + return folders; + } catch (error) { + throw new DatabaseError({ error, name: "Find folders multi env" }); + } + }; + return { ...secretFolderOrm, update, findBySecretPath, + findBySecretPathMultiEnv, findById, findByManySecretPath, findSecretPathByFolderIds, findClosestFolder, - findByProjectId + findByProjectId, + findByMultiEnv }; }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 45b5205b2..d63518a80 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -7,6 +7,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { OrderByDirection } from "@app/lib/types"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; @@ -26,7 +27,7 @@ type TSecretFolderServiceFactoryDep = { permissionService: Pick; snapshotService: Pick; folderDAL: TSecretFolderDALFactory; - projectEnvDAL: Pick; + projectEnvDAL: Pick; folderVersionDAL: TSecretFolderVersionDALFactory; projectDAL: Pick; }; @@ -396,7 +397,12 @@ export const secretFolderServiceFactory = ({ actorOrgId, actorAuthMethod, environment, - path: secretPath + path: secretPath, + search, + orderBy, + orderDirection, + limit, + offset }: TGetFolderDTO) => { // folder list is allowed to be read by anyone // permission to check does user has access @@ -408,11 +414,92 @@ export const secretFolderServiceFactory = ({ const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!parentFolder) return []; - const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id, isReserved: false }); + const folders = await folderDAL.find( + { + envId: env.id, + parentId: parentFolder.id, + isReserved: false, + $search: search ? { name: `%${search}%` } : undefined + }, + { + sort: orderBy ? [[orderBy, orderDirection ?? OrderByDirection.ASC]] : undefined, + limit, + offset + } + ); + return folders; + }; + + // get folders for multiple envs + const getFoldersMultiEnv = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environments, + path: secretPath, + ...params + }: Omit & { environments: string[] }) => { + // folder list is allowed to be read by anyone + // permission to check does user has access + await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId); + + const envs = await projectEnvDAL.findBySlugs(projectId, environments); + + if (!envs.length) + throw new BadRequestError({ message: "Environment(s) not found", name: "get project folder count" }); + + const parentFolders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath); + if (!parentFolders.length) return []; + + const folders = await folderDAL.findByMultiEnv({ + environmentIds: envs.map((env) => env.id), + parentIds: parentFolders.map((folder) => folder.id), + ...params + }); return folders; }; + // get the unique count of folders within a project path + const getProjectFolderCount = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environments, + path: secretPath, + search + }: Omit & { environments: string[] }) => { + // folder list is allowed to be read by anyone + // permission to check does user has access + await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId); + + const envs = await projectEnvDAL.findBySlugs(projectId, environments); + + if (!envs.length) + throw new BadRequestError({ message: "Environment(s) not found", name: "get project folder count" }); + + const parentFolders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath); + if (!parentFolders.length) return 0; + + const folders = await folderDAL.find( + { + $in: { + envId: envs.map((env) => env.id), + parentId: parentFolders.map((folder) => folder.id) + }, + isReserved: false, + $search: search ? { name: `%${search}%` } : undefined + }, + { countDistinct: "name" } + ); + + return Number(folders[0]?.count ?? 0); + }; + const getFolderById = async ({ actor, actorId, actorOrgId, actorAuthMethod, id }: TGetFolderByIdDTO) => { const folder = await folderDAL.findById(id); if (!folder) throw new NotFoundError({ message: "folder not found" }); @@ -429,6 +516,8 @@ export const secretFolderServiceFactory = ({ updateManyFolders, deleteFolder, getFolders, - getFolderById + getFolderById, + getProjectFolderCount, + getFoldersMultiEnv }; }; diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index 5c80b9582..a6ddf3688 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -1,4 +1,5 @@ -import { TProjectPermission } from "@app/lib/types"; +import { OrderByDirection, TProjectPermission } from "@app/lib/types"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; export enum ReservedFolders { SecretReplication = "__reserve_replication_" @@ -36,6 +37,11 @@ export type TDeleteFolderDTO = { export type TGetFolderDTO = { environment: string; path: string; + search?: string; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + limit?: number; + offset?: number; } & TProjectPermission; export type TGetFolderByIdDTO = { diff --git a/backend/src/services/secret-import/secret-import-dal.ts b/backend/src/services/secret-import/secret-import-dal.ts index 9a7c7e4dc..893a6d6b4 100644 --- a/backend/src/services/secret-import/secret-import-dal.ts +++ b/backend/src/services/secret-import/secret-import-dal.ts @@ -49,10 +49,30 @@ export const secretImportDALFactory = (db: TDbClient) => { } }; - const find = async (filter: Partial, tx?: Knex) => { + const find = async ( + { + search, + limit, + offset, + ...filter + }: Partial< + TSecretImports & { + projectId: string; + search?: string; + limit?: number; + offset?: number; + } + >, + tx?: Knex + ) => { try { - const docs = await (tx || db.replicaNode())(TableName.SecretImport) + const query = (tx || db.replicaNode())(TableName.SecretImport) .where(filter) + .where((bd) => { + if (search) { + void bd.whereILike("importPath", `%${search}%`); + } + }) .join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`) .select( db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports, @@ -61,6 +81,13 @@ export const secretImportDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.Environment).as("envId") ) .orderBy("position", "asc"); + + if (limit) { + void query.limit(limit).offset(offset ?? 0); + } + + const docs = await query; + return docs.map(({ envId, slug, name, ...el }) => ({ ...el, importEnv: { id: envId, slug, name } @@ -70,6 +97,28 @@ export const secretImportDALFactory = (db: TDbClient) => { } }; + const getProjectImportCount = async ( + { search, ...filter }: Partial, + tx?: Knex + ) => { + try { + const docs = await (tx || db.replicaNode())(TableName.SecretImport) + .where(filter) + .where("isReplication", false) + .where((bd) => { + if (search) { + void bd.whereILike("importPath", `%${search}%`); + } + }) + .join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`) + .count(); + + return Number(docs[0]?.count ?? 0); + } catch (error) { + throw new DatabaseError({ error, name: "get secret imports count" }); + } + }; + const findByFolderIds = async (folderIds: string[], tx?: Knex) => { try { const docs = await (tx || db.replicaNode())(TableName.SecretImport) @@ -97,6 +146,7 @@ export const secretImportDALFactory = (db: TDbClient) => { find, findByFolderIds, findLastImportPosition, - updateAllPosition + updateAllPosition, + getProjectImportCount }; }; diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index 981a1bf62..b21a6c3ce 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -220,7 +220,7 @@ export const fnSecretsV2FromImports = async ({ const secretsFromdeeperImportGroupedByFolderId = groupBy(secretsFromDeeperImports, (i) => i.importFolderId); const processedImports = allowedImports.map(({ importPath, importEnv, id, folderId }, i) => { - const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`][0]; + const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`]?.[0]; const folderDeeperImportSecrets = secretsFromdeeperImportGroupedByFolderId?.[sourceImportFolder?.id || ""]?.[0]?.secrets || []; const secretsWithDuplicate = (importedSecretsGroupByFolderId?.[importedFolders?.[i]?.id as string] || []) diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index c3d1a6791..f4dd03bf3 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -7,7 +7,7 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getReplicationFolderName } from "@app/ee/services/secret-replication/secret-replication-service"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; @@ -394,6 +394,36 @@ export const secretImportServiceFactory = ({ return { message: "replication started" }; }; + const getProjectImportCount = async ({ + path: secretPath, + environment, + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + search + }: TGetSecretImportsDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) throw new NotFoundError({ message: "Folder not found", name: "Get imports" }); + + const count = await secretImportDAL.getProjectImportCount({ folderId: folder.id, search }); + + return count; + }; + const getImports = async ({ path: secretPath, environment, @@ -401,7 +431,10 @@ export const secretImportServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + search, + limit, + offset }: TGetSecretImportsDTO) => { const { permission } = await permissionService.getProjectPermission( actor, @@ -418,7 +451,7 @@ export const secretImportServiceFactory = ({ const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" }); - const secImports = await secretImportDAL.find({ folderId: folder.id }); + const secImports = await secretImportDAL.find({ folderId: folder.id, search, limit, offset }); return secImports; }; @@ -512,7 +545,11 @@ export const secretImportServiceFactory = ({ return importedSecrets; } - if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + if (!botKey) + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); const importedSecrets = await fnSecretsFromImports({ allowedImports, folderDAL, secretDAL, secretImportDAL }); return importedSecrets.map((el) => ({ @@ -531,6 +568,7 @@ export const secretImportServiceFactory = ({ getSecretsFromImports, getRawSecretsFromImports, resyncSecretImportReplication, + getProjectImportCount, fnSecretsFromImports }; }; diff --git a/backend/src/services/secret-import/secret-import-types.ts b/backend/src/services/secret-import/secret-import-types.ts index 01847738b..0a72c4da2 100644 --- a/backend/src/services/secret-import/secret-import-types.ts +++ b/backend/src/services/secret-import/secret-import-types.ts @@ -32,6 +32,9 @@ export type TDeleteSecretImportDTO = { export type TGetSecretImportsDTO = { environment: string; path: string; + search?: string; + limit?: number; + offset?: number; } & TProjectPermission; export type TGetSecretsFromImportDTO = { diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index fe4d89bac..b04915abe 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -5,6 +5,8 @@ import { TDbClient } from "@app/db"; import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { OrderByDirection } from "@app/lib/types"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; export type TSecretV2BridgeDALFactory = ReturnType; @@ -181,7 +183,16 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } }; - const findByFolderIds = async (folderIds: string[], userId?: string, tx?: Knex) => { + // get unique secret count by folder IDs + const countByFolderIds = async ( + folderIds: string[], + userId?: string, + tx?: Knex, + filters?: { + search?: string; + tagSlugs?: string[]; + } + ) => { try { // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) if (userId && !uuidValidate(userId)) { @@ -189,8 +200,70 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { userId = undefined; } - const secs = await (tx || db.replicaNode())(TableName.SecretV2) + const query = (tx || db.replicaNode())(TableName.SecretV2) .whereIn("folderId", folderIds) + .where((bd) => { + if (filters?.search) { + void bd.whereILike("key", `%${filters?.search}%`); + } + }) + .where((bd) => { + void bd.whereNull("userId").orWhere({ userId: userId || null }); + }) + .countDistinct("key"); + + // only need to join tags if filtering by tag slugs + const slugs = filters?.tagSlugs?.filter(Boolean); + if (slugs && slugs.length > 0) { + void query + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .whereIn("slug", slugs); + } + + const secrets = await query; + + return Number(secrets[0]?.count ?? 0); + } catch (error) { + throw new DatabaseError({ error, name: "get folder secret count" }); + } + }; + + const findByFolderIds = async ( + folderIds: string[], + userId?: string, + tx?: Knex, + filters?: { + limit?: number; + offset?: number; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + search?: string; + tagSlugs?: string[]; + } + ) => { + try { + // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) + if (userId && !uuidValidate(userId)) { + // eslint-disable-next-line no-param-reassign + userId = undefined; + } + + const query = (tx || db.replicaNode())(TableName.SecretV2) + .whereIn("folderId", folderIds) + .where((bd) => { + if (filters?.search) { + void bd.whereILike("key", `%${filters?.search}%`); + } + }) .where((bd) => { void bd.whereNull("userId").orWhere({ userId: userId || null }); }) @@ -204,11 +277,37 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) - .select(selectAllTableCols(TableName.SecretV2)) + .select( + selectAllTableCols(TableName.SecretV2), + db.raw(`DENSE_RANK() OVER (ORDER BY "key" ${filters?.orderDirection ?? OrderByDirection.ASC}) as rank`) + ) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) - .orderBy("id", "asc"); + .where((bd) => { + const slugs = filters?.tagSlugs?.filter(Boolean); + if (slugs && slugs.length > 0) { + void bd.whereIn("slug", slugs); + } + }) + .orderBy( + filters?.orderBy === SecretsOrderBy.Name ? "key" : "id", + filters?.orderDirection ?? OrderByDirection.ASC + ); + + let secs: Awaited; + + if (filters?.limit) { + const rankOffset = (filters?.offset ?? 0) + 1; // ranks start at 1 + secs = await (tx || db) + .with("w", query) + .select("*") + .from[number]>("w") + .where("w.rank", ">=", rankOffset) + .andWhere("w.rank", "<", rankOffset + filters.limit); + } else { + secs = await query; + } const data = sqlNestRelationships({ data: secs, @@ -384,6 +483,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { findBySecretKeys, upsertSecretReferences, findReferencedSecretReferences, - findAllProjectSecretValues + findAllProjectSecretValues, + countByFolderIds }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index ed9cd3a7d..d7f895084 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -59,7 +59,7 @@ type TSecretV2BridgeServiceFactoryDep = { projectEnvDAL: Pick; folderDAL: Pick< TSecretFolderDALFactory, - "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" + "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findBySecretPathMultiEnv" >; secretImportDAL: Pick; secretQueueService: Pick; @@ -431,6 +431,165 @@ export const secretV2BridgeServiceFactory = ({ }); }; + // get unique secrets count for multiple envs + const getSecretsCountMultiEnv = async ({ + actorId, + path, + + projectId, + actor, + actorOrgId, + actorAuthMethod, + environments, + ...params + }: Pick & { + environments: string[]; + }) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + // verify user has access to all environments + environments.forEach((environment) => + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ) + ); + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path); + if (!folders.length) return 0; + + const count = await secretDAL.countByFolderIds( + folders.map((folder) => folder.id), + actorId, + undefined, + params + ); + + return count; + }; + + // get secret count for individual env + const getSecretsCount = async ({ + actorId, + path, + environment, + projectId, + actor, + actorOrgId, + actorAuthMethod, + ...params + }: Pick< + TGetSecretsDTO, + | "actorId" + | "actor" + | "path" + | "projectId" + | "actorOrgId" + | "actorAuthMethod" + | "tagSlugs" + | "environment" + | "search" + >) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) return 0; + + const count = await secretDAL.countByFolderIds([folder.id], actorId, undefined, params); + + return count; + }; + + // get secrets for multiple envs + const getSecretsMultiEnv = async ({ + actorId, + path, + environments, + projectId, + actor, + actorOrgId, + actorAuthMethod, + ...params + }: Pick & { + environments: string[]; + }) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + let paths: { folderId: string; path: string; environment: string }[] = []; + + // verify user has access to all environments + environments.forEach((environment) => + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ) + ); + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path); + + if (!folders.length) { + return []; + } + + paths = folders.map((folder) => ({ folderId: folder.id, path, environment: folder.environment.slug })); + + const groupedPaths = groupBy(paths, (p) => p.folderId); + + const secrets = await secretDAL.findByFolderIds( + paths.map((p) => p.folderId), + actorId, + undefined, + params + ); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedSecrets = secrets.map((secret) => + reshapeBridgeSecret( + projectId, + groupedPaths[secret.folderId][0].environment, + groupedPaths[secret.folderId][0].path, + { + ...secret, + value: secret.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() + : "", + comment: secret.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() + : "" + } + ) + ); + + return decryptedSecrets; + }; + const getSecrets = async ({ actorId, path, @@ -441,8 +600,8 @@ export const secretV2BridgeServiceFactory = ({ actorAuthMethod, includeImports, recursive, - tagSlugs = [], - expandSecretReferences: shouldExpandSecretReferences + expandSecretReferences: shouldExpandSecretReferences, + ...params }: TGetSecretsDTO) => { const { permission } = await permissionService.getProjectPermission( actor, @@ -490,7 +649,9 @@ export const secretV2BridgeServiceFactory = ({ const secrets = await secretDAL.findByFolderIds( paths.map((p) => p.folderId), - actorId + actorId, + undefined, + params ); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ @@ -509,9 +670,7 @@ export const secretV2BridgeServiceFactory = ({ : "" }) ); - const filteredSecrets = tagSlugs.length - ? decryptedSecrets.filter((secret) => Boolean(secret.tags?.find((el) => tagSlugs.includes(el.slug)))) - : decryptedSecrets; + const expandSecretReferences = expandSecretReferencesFactory({ projectId, folderDAL, @@ -520,7 +679,7 @@ export const secretV2BridgeServiceFactory = ({ }); if (shouldExpandSecretReferences) { - const secretsGroupByPath = groupBy(filteredSecrets, (i) => i.secretPath); + const secretsGroupByPath = groupBy(decryptedSecrets, (i) => i.secretPath); await Promise.allSettled( Object.keys(secretsGroupByPath).map((groupedPath) => Promise.allSettled( @@ -541,7 +700,7 @@ export const secretV2BridgeServiceFactory = ({ if (!includeImports) { return { - secrets: filteredSecrets + secrets: decryptedSecrets }; } @@ -569,7 +728,7 @@ export const secretV2BridgeServiceFactory = ({ }); return { - secrets: filteredSecrets, + secrets: decryptedSecrets, imports: importedSecrets }; }; @@ -1416,6 +1575,9 @@ export const secretV2BridgeServiceFactory = ({ getSecrets, getSecretVersions, backfillSecretReferences, - moveSecrets + moveSecrets, + getSecretsCount, + getSecretsCountMultiEnv, + getSecretsMultiEnv }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 8c7a32a7f..a76c57561 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -1,8 +1,9 @@ import { Knex } from "knex"; import { SecretType, TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas"; -import { TProjectPermission } from "@app/lib/types"; +import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; @@ -21,6 +22,11 @@ export type TGetSecretsDTO = { includeImports?: boolean; recursive?: boolean; tagSlugs?: string[]; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + offset?: number; + limit?: number; + search?: string; } & TProjectPermission; export type TGetASecretDTO = { diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 7837b716b..e77972d37 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -832,7 +832,11 @@ export const createManySecretsRawFnFactory = ({ secretDAL }); - if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + if (!botKey) + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); const inputSecrets = secrets.map((secret) => { const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey); const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey); @@ -993,7 +997,11 @@ export const updateManySecretsRawFnFactory = ({ return updatedSecrets; } - if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + if (!botKey) + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); if (!blindIndexCfg) throw new BadRequestError({ message: "Blind index not found", name: "Update secret" }); diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 0c63eb147..4005c8f7a 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -1,7 +1,15 @@ /* eslint-disable no-await-in-loop */ import { AxiosError } from "axios"; -import { ProjectUpgradeStatus, ProjectVersion, TSecretSnapshotSecretsV2, TSecretVersionsV2 } from "@app/db/schemas"; +import { + ProjectMembershipRole, + ProjectUpgradeStatus, + ProjectVersion, + TSecretSnapshotSecretsV2, + TSecretVersionsV2 +} from "@app/db/schemas"; +import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { Actor, EventType } from "@app/ee/services/audit-log/audit-log-types"; import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; @@ -21,6 +29,7 @@ import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { ActorType } from "../auth/auth-type"; import { TIntegrationDALFactory } from "../integration/integration-dal"; import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal"; import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-auth-service"; @@ -40,13 +49,16 @@ import { expandSecretReferencesFactory, getAllNestedSecretReferences } from "../ import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; +import { TUserDALFactory } from "../user/user-dal"; import { TWebhookDALFactory } from "../webhook/webhook-dal"; import { fnTriggerWebhook } from "../webhook/webhook-fns"; import { TSecretDALFactory } from "./secret-dal"; import { interpolateSecrets } from "./secret-fns"; import { TCreateSecretReminderDTO, + TFailedIntegrationSyncEmailsPayload, THandleReminderDTO, + TIntegrationSyncPayload, TRemoveSecretReminderDTO, TSyncSecretsDTO } from "./secret-types"; @@ -71,6 +83,7 @@ type TSecretQueueFactoryDep = { secretVersionDAL: TSecretVersionDALFactory; secretBlindIndexDAL: TSecretBlindIndexDALFactory; secretTagDAL: TSecretTagDALFactory; + userDAL: Pick; secretVersionTagDAL: TSecretVersionTagDALFactory; kmsService: Pick; secretV2BridgeDAL: TSecretV2BridgeDALFactory; @@ -81,6 +94,7 @@ type TSecretQueueFactoryDep = { snapshotDAL: Pick; snapshotSecretV2BridgeDAL: Pick; keyStore: Pick; + auditLogService: Pick; }; export type TGetSecrets = { @@ -106,6 +120,7 @@ export const secretQueueFactory = ({ secretDAL, secretImportDAL, folderDAL, + userDAL, webhookDAL, projectEnvDAL, orgDAL, @@ -125,7 +140,8 @@ export const secretQueueFactory = ({ snapshotDAL, snapshotSecretV2BridgeDAL, secretApprovalRequestDAL, - keyStore + keyStore, + auditLogService }: TSecretQueueFactoryDep) => { const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { const appCfg = getConfig(); @@ -430,7 +446,9 @@ export const secretQueueFactory = ({ return content; }; - const syncIntegrations = async (dto: TGetSecrets & { deDupeQueue?: Record }) => { + const syncIntegrations = async ( + dto: TGetSecrets & { isManual?: boolean; actorId?: string; deDupeQueue?: Record } + ) => { await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, { attempts: 3, delay: 1000, @@ -499,6 +517,19 @@ export const secretQueueFactory = ({ ); }; + const sendFailedIntegrationSyncEmails = async (payload: TFailedIntegrationSyncEmailsPayload) => { + const appCfg = getConfig(); + if (!appCfg.isSmtpConfigured) return; + + await queueService.queue(QueueName.IntegrationSync, QueueJobs.SendFailedIntegrationSyncEmails, payload, { + jobId: `send-failed-integration-sync-emails-${payload.projectId}-${payload.secretPath}-${payload.environmentSlug}`, + delay: 1_000 * 60, // 1 minute + + removeOnFail: true, + removeOnComplete: true + }); + }; + queueService.start(QueueName.SecretSync, async (job) => { const { _deDupeQueue: deDupeQueue, @@ -528,7 +559,7 @@ export const secretQueueFactory = ({ } } ); - await syncIntegrations({ secretPath, projectId, environment, deDupeQueue }); + await syncIntegrations({ secretPath, projectId, environment, deDupeQueue, isManual: false }); if (!excludeReplication) { await replicateSecrets({ _deDupeReplicationQueue: deDupeReplicationQueue, @@ -544,274 +575,396 @@ export const secretQueueFactory = ({ }); queueService.start(QueueName.IntegrationSync, async (job) => { - const { environment, projectId, secretPath, depth = 1, deDupeQueue = {} } = job.data; - if (depth > MAX_SYNC_SECRET_DEPTH) return; + if (job.name === QueueJobs.SendFailedIntegrationSyncEmails) { + const appCfg = getConfig(); - const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); - if (!folder) { - throw new Error("Secret path not found"); + const jobPayload = job.data as TFailedIntegrationSyncEmailsPayload; + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(jobPayload.projectId); + const project = await projectDAL.findById(jobPayload.projectId); + + // Only send emails to admins, and if its a manual trigger, only send it to the person who triggered it (if actor is admin as well) + const filteredProjectMembers = projectMembers + .filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin)) + .filter((member) => + jobPayload.manuallyTriggeredByUserId ? member.userId === jobPayload.manuallyTriggeredByUserId : true + ); + + await smtpService.sendMail({ + recipients: filteredProjectMembers.map((member) => member.user.email!), + template: SmtpTemplates.IntegrationSyncFailed, + subjectLine: `Integration Sync Failed`, + substitutions: { + syncMessage: jobPayload.count === 1 ? jobPayload.syncMessage : undefined, // We are only displaying the sync message if its a singular integration, so we can just grab the first one in the array. + secretPath: jobPayload.secretPath, + environment: jobPayload.environmentName, + count: jobPayload.count, + projectName: project.name, + integrationUrl: `${appCfg.SITE_URL}/integrations/${project.id}` + } + }); } - // find all imports made with the given environment and secret path - const linkSourceDto = { - projectId, - importEnv: folder.environment.id, - importPath: secretPath, - isReplication: false - }; - const imports = await secretImportDAL.find(linkSourceDto); - - if (imports.length) { - // keep calling sync secret for all the imports made - const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId); - const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds); - const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string); - logger.info( - `getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` - ); - await Promise.all( - imports - .filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string)) - // filter out already synced ones - .filter( - ({ folderId }) => - !deDupeQueue[ - uniqueSecretQueueKey( - foldersGroupedById[folderId][0]?.environmentSlug as string, - foldersGroupedById[folderId][0]?.path as string - ) - ] - ) - .map(({ folderId }) => - syncSecrets({ - projectId, - secretPath: foldersGroupedById[folderId][0]?.path as string, - environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, - _deDupeQueue: deDupeQueue, - _depth: depth + 1, - excludeReplication: true - }) - ) - ); - } - const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId); - const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.SecretManager, - projectId - }); - let referencedFolderIds; - if (shouldUseSecretV2Bridge) { - const secretReferences = await secretV2BridgeDAL.findReferencedSecretReferences( + if (job.name === QueueJobs.IntegrationSync) { + const { + environment, + actorId, + isManual, projectId, - folder.environment.slug, - secretPath - ); - referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId); - } else { - const secretReferences = await secretDAL.findReferencedSecretReferences( - projectId, - folder.environment.slug, - secretPath - ); - referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId); - } - if (referencedFolderIds.length) { - const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds); - const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string); - logger.info( - `getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` - ); - await Promise.all( - referencedFolderIds - .filter((folderId) => Boolean(referencedFoldersGroupedById[folderId][0]?.path)) - // filter out already synced ones - .filter( - (folderId) => - !deDupeQueue[ - uniqueSecretQueueKey( - referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, - referencedFoldersGroupedById[folderId][0]?.path as string - ) - ] - ) - .map((folderId) => - syncSecrets({ - projectId, - secretPath: referencedFoldersGroupedById[folderId][0]?.path as string, - environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, - _deDupeQueue: deDupeQueue, - _depth: depth + 1, - excludeReplication: true - }) - ) - ); - } + secretPath, + depth = 1, + deDupeQueue = {} + } = job.data as TIntegrationSyncPayload; + if (depth > MAX_SYNC_SECRET_DEPTH) return; - const integrations = await integrationDAL.findByProjectIdV2(projectId, environment); // note: returns array of integrations + integration auths in this environment - const toBeSyncedIntegrations = integrations.filter( - // note: sync only the integrations sourced from secretPath - ({ secretPath: integrationSecPath, isActive }) => isActive && isSamePath(secretPath, integrationSecPath) - ); - - if (!integrations.length) return; - logger.info( - `getIntegrationSecrets: secret integration sync started [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${job.data.depth}]` - ); - - const lock = await keyStore.acquireLock( - [KeyStorePrefixes.SyncSecretIntegrationLock(projectId, environment, secretPath)], - 10000, - { - retryCount: 3, - retryDelay: 2000 + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) { + throw new Error("Secret path not found"); } - ); - const lockAcquiredTime = new Date(); - const lastRunSyncIntegrationTimestamp = await keyStore.getItem( - KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath) - ); + // find all imports made with the given environment and secret path + const linkSourceDto = { + projectId, + importEnv: folder.environment.id, + importPath: secretPath, + isReplication: false + }; + const imports = await secretImportDAL.find(linkSourceDto); - // check whether the integration should wait or not - if (lastRunSyncIntegrationTimestamp) { - const INTEGRATION_INTERVAL = 2000; - const isStaleSyncIntegration = new Date(job.timestamp) < new Date(lastRunSyncIntegrationTimestamp); - if (isStaleSyncIntegration) { + if (imports.length) { + // keep calling sync secret for all the imports made + const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId); + const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds); + const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string); logger.info( - `getIntegrationSecrets: secret integration sync stale [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${job.data.depth}]` + `getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` + ); + await Promise.all( + imports + .filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string)) + // filter out already synced ones + .filter( + ({ folderId }) => + !deDupeQueue[ + uniqueSecretQueueKey( + foldersGroupedById[folderId][0]?.environmentSlug as string, + foldersGroupedById[folderId][0]?.path as string + ) + ] + ) + .map(({ folderId }) => + syncSecrets({ + projectId, + secretPath: foldersGroupedById[folderId][0]?.path as string, + environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, + _deDupeQueue: deDupeQueue, + _depth: depth + 1, + excludeReplication: true + }) + ) + ); + } + const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId); + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + let referencedFolderIds; + if (shouldUseSecretV2Bridge) { + const secretReferences = await secretV2BridgeDAL.findReferencedSecretReferences( + projectId, + folder.environment.slug, + secretPath + ); + referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId); + } else { + const secretReferences = await secretDAL.findReferencedSecretReferences( + projectId, + folder.environment.slug, + secretPath + ); + referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId); + } + if (referencedFolderIds.length) { + const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds); + const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string); + logger.info( + `getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` + ); + await Promise.all( + referencedFolderIds + .filter((folderId) => Boolean(referencedFoldersGroupedById[folderId][0]?.path)) + // filter out already synced ones + .filter( + (folderId) => + !deDupeQueue[ + uniqueSecretQueueKey( + referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, + referencedFoldersGroupedById[folderId][0]?.path as string + ) + ] + ) + .map((folderId) => + syncSecrets({ + projectId, + secretPath: referencedFoldersGroupedById[folderId][0]?.path as string, + environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, + _deDupeQueue: deDupeQueue, + _depth: depth + 1, + excludeReplication: true + }) + ) ); - return; } - const timeDifferenceWithLastIntegration = getTimeDifferenceInSeconds( - lockAcquiredTime.toISOString(), - lastRunSyncIntegrationTimestamp + const integrations = await integrationDAL.findByProjectIdV2(projectId, environment); // note: returns array of integrations + integration auths in this environment + const toBeSyncedIntegrations = integrations.filter( + // note: sync only the integrations sourced from secretPath + ({ secretPath: integrationSecPath, isActive }) => isActive && isSamePath(secretPath, integrationSecPath) ); - if (timeDifferenceWithLastIntegration < INTEGRATION_INTERVAL && timeDifferenceWithLastIntegration > 0) - await new Promise((resolve) => { - setTimeout(resolve, 2000 - timeDifferenceWithLastIntegration * 1000); - }); - } - // akhilmhdh: this try catch is for lock release - try { - const secrets = shouldUseSecretV2Bridge - ? await getIntegrationSecretsV2({ - environment, - projectId, - folderId: folder.id, - depth: 1, - secretPath, - decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "") - }) - : await getIntegrationSecrets({ - environment, - projectId, - folderId: folder.id, - key: botKey as string, - depth: 1, - secretPath - }); + const integrationsFailedToSync: { integrationId: string; syncMessage?: string }[] = []; - for (const integration of toBeSyncedIntegrations) { - const integrationAuth = { - ...integration.integrationAuth, - createdAt: new Date(), - updatedAt: new Date(), - projectId: integration.projectId - }; + if (!integrations.length) return; + logger.info( + `getIntegrationSecrets: secret integration sync started [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` + ); - const { accessToken, accessId } = await integrationAuthService.getIntegrationAccessToken( - integrationAuth, - shouldUseSecretV2Bridge, - botKey - ); - let awsAssumeRoleArn = null; - if (shouldUseSecretV2Bridge) { - if (integrationAuth.encryptedAwsAssumeIamRoleArn) { - awsAssumeRoleArn = secretManagerDecryptor({ - cipherTextBlob: Buffer.from(integrationAuth.encryptedAwsAssumeIamRoleArn) - }).toString(); - } - } else if ( - integrationAuth.awsAssumeIamRoleArnTag && - integrationAuth.awsAssumeIamRoleArnIV && - integrationAuth.awsAssumeIamRoleArnCipherText - ) { - awsAssumeRoleArn = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: integrationAuth.awsAssumeIamRoleArnCipherText, - iv: integrationAuth.awsAssumeIamRoleArnIV, - tag: integrationAuth.awsAssumeIamRoleArnTag, - key: botKey as string - }); + const lock = await keyStore.acquireLock( + [KeyStorePrefixes.SyncSecretIntegrationLock(projectId, environment, secretPath)], + 10000, + { + retryCount: 3, + retryDelay: 2000 } + ); + const lockAcquiredTime = new Date(); - const suffixedSecrets: typeof secrets = {}; - const metadata = integration.metadata as Record; - if (metadata) { - Object.keys(secrets).forEach((key) => { - const prefix = metadata?.secretPrefix || ""; - const suffix = metadata?.secretSuffix || ""; - const newKey = prefix + key + suffix; - suffixedSecrets[newKey] = secrets[key]; - }); - } + const lastRunSyncIntegrationTimestamp = await keyStore.getItem( + KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath) + ); - // akhilmhdh: this try catch is for catching integration error and saving it in db - try { - // akhilmhdh: this needs to changed later to be more easier to use - // at present this is not at all extendable like to add a new parameter for just one integration need to modify multiple places - const response = await syncIntegrationSecrets({ - createManySecretsRawFn, - updateManySecretsRawFn, - integrationDAL, - integration, - integrationAuth, - secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets, - accessId: accessId as string, - awsAssumeRoleArn, - accessToken, - projectId, - appendices: { - prefix: metadata?.secretPrefix || "", - suffix: metadata?.secretSuffix || "" - } - }); - - await integrationDAL.updateById(integration.id, { - lastSyncJobId: job.id, - lastUsed: new Date(), - syncMessage: response?.syncMessage ?? "", - isSynced: response?.isSynced ?? true - }); - } catch (err) { - logger.error( - err, - `Secret integration sync error [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}]` + // check whether the integration should wait or not + if (lastRunSyncIntegrationTimestamp) { + const INTEGRATION_INTERVAL = 2000; + const isStaleSyncIntegration = new Date(job.timestamp) < new Date(lastRunSyncIntegrationTimestamp); + if (isStaleSyncIntegration) { + logger.info( + `getIntegrationSecrets: secret integration sync stale [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` ); + return; + } - const message = - (err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) || - "Unknown error occurred."; + const timeDifferenceWithLastIntegration = getTimeDifferenceInSeconds( + lockAcquiredTime.toISOString(), + lastRunSyncIntegrationTimestamp + ); + if (timeDifferenceWithLastIntegration < INTEGRATION_INTERVAL && timeDifferenceWithLastIntegration > 0) + await new Promise((resolve) => { + setTimeout(resolve, 2000 - timeDifferenceWithLastIntegration * 1000); + }); + } - await integrationDAL.updateById(integration.id, { - lastSyncJobId: job.id, - lastUsed: new Date(), - syncMessage: message, - isSynced: false + const generateActor = async (): Promise => { + if (isManual && actorId) { + const user = await userDAL.findById(actorId); + + if (!user) { + throw new Error("User not found"); + } + + return { + type: ActorType.USER, + metadata: { + email: user.email, + username: user.username, + userId: user.id + } + }; + } + + return { + type: ActorType.PLATFORM, + metadata: {} + }; + }; + + // akhilmhdh: this try catch is for lock release + try { + const secrets = shouldUseSecretV2Bridge + ? await getIntegrationSecretsV2({ + environment, + projectId, + folderId: folder.id, + depth: 1, + secretPath, + decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "") + }) + : await getIntegrationSecrets({ + environment, + projectId, + folderId: folder.id, + key: botKey as string, + depth: 1, + secretPath + }); + + for (const integration of toBeSyncedIntegrations) { + const integrationAuth = { + ...integration.integrationAuth, + createdAt: new Date(), + updatedAt: new Date(), + projectId: integration.projectId + }; + + const { accessToken, accessId } = await integrationAuthService.getIntegrationAccessToken( + integrationAuth, + shouldUseSecretV2Bridge, + botKey + ); + let awsAssumeRoleArn = null; + if (shouldUseSecretV2Bridge) { + if (integrationAuth.encryptedAwsAssumeIamRoleArn) { + awsAssumeRoleArn = secretManagerDecryptor({ + cipherTextBlob: Buffer.from(integrationAuth.encryptedAwsAssumeIamRoleArn) + }).toString(); + } + } else if ( + integrationAuth.awsAssumeIamRoleArnTag && + integrationAuth.awsAssumeIamRoleArnIV && + integrationAuth.awsAssumeIamRoleArnCipherText + ) { + awsAssumeRoleArn = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: integrationAuth.awsAssumeIamRoleArnCipherText, + iv: integrationAuth.awsAssumeIamRoleArnIV, + tag: integrationAuth.awsAssumeIamRoleArnTag, + key: botKey as string + }); + } + + const suffixedSecrets: typeof secrets = {}; + const metadata = integration.metadata as Record; + if (metadata) { + Object.keys(secrets).forEach((key) => { + const prefix = metadata?.secretPrefix || ""; + const suffix = metadata?.secretSuffix || ""; + const newKey = prefix + key + suffix; + suffixedSecrets[newKey] = secrets[key]; + }); + } + + // akhilmhdh: this try catch is for catching integration error and saving it in db + try { + // akhilmhdh: this needs to changed later to be more easier to use + // at present this is not at all extendable like to add a new parameter for just one integration need to modify multiple places + const response = await syncIntegrationSecrets({ + createManySecretsRawFn, + updateManySecretsRawFn, + integrationDAL, + integration, + integrationAuth, + secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets, + accessId: accessId as string, + awsAssumeRoleArn, + accessToken, + projectId, + appendices: { + prefix: metadata?.secretPrefix || "", + suffix: metadata?.secretSuffix || "" + } + }); + + await auditLogService.createAuditLog({ + projectId, + actor: await generateActor(), + event: { + type: EventType.INTEGRATION_SYNCED, + metadata: { + integrationId: integration.id, + isSynced: response?.isSynced ?? true, + lastSyncJobId: job?.id ?? "", + lastUsed: new Date(), + syncMessage: response?.syncMessage ?? "" + } + } + }); + + await integrationDAL.updateById(integration.id, { + lastSyncJobId: job.id, + lastUsed: new Date(), + syncMessage: response?.syncMessage ?? "", + isSynced: response?.isSynced ?? true + }); + + // May be undefined, if it's undefined we assume the sync was successful, hence the strict equality type check. + if (response?.isSynced === false) { + integrationsFailedToSync.push({ + integrationId: integration.id, + syncMessage: response.syncMessage + }); + } + } catch (err) { + logger.error( + err, + `Secret integration sync error [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}]` + ); + + const message = + (err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) || + "Unknown error occurred."; + + await auditLogService.createAuditLog({ + projectId, + actor: await generateActor(), + event: { + type: EventType.INTEGRATION_SYNCED, + metadata: { + integrationId: integration.id, + isSynced: false, + lastSyncJobId: job?.id ?? "", + lastUsed: new Date(), + syncMessage: message + } + } + }); + + await integrationDAL.updateById(integration.id, { + lastSyncJobId: job.id, + syncMessage: message, + isSynced: false + }); + + integrationsFailedToSync.push({ + integrationId: integration.id, + syncMessage: message + }); + } + } + } finally { + await lock.release(); + if (integrationsFailedToSync.length) { + await sendFailedIntegrationSyncEmails({ + count: integrationsFailedToSync.length, + environmentName: folder.environment.name, + environmentSlug: environment, + ...(isManual && + actorId && { + manuallyTriggeredByUserId: actorId + }), + projectId, + secretPath, + syncMessage: integrationsFailedToSync[0].syncMessage }); } } - } finally { - await lock.release(); - } - await keyStore.setItemWithExpiry( - KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath), - KeyStoreTtls.SetSyncSecretIntegrationLastRunTimestampInSeconds, - lockAcquiredTime.toISOString() - ); - logger.info("Secret integration sync ended: %s", job.id); + await keyStore.setItemWithExpiry( + KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath), + KeyStoreTtls.SetSyncSecretIntegrationLastRunTimestampInSeconds, + lockAcquiredTime.toISOString() + ); + logger.info("Secret integration sync ended: %s", job.id); + } }); queueService.start(QueueName.SecretReminder, async ({ data }) => { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index e502f577a..0bf1f1171 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -954,6 +954,120 @@ export const secretServiceFactory = ({ return secretsDeleted; }; + const getSecretsCount = async ({ + projectId, + path, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environment, + tagSlugs = [], + ...v2Params + }: Pick< + TGetSecretsRawDTO, + | "projectId" + | "path" + | "actor" + | "actorId" + | "actorOrgId" + | "actorAuthMethod" + | "environment" + | "tagSlugs" + | "search" + >) => { + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + + if (!shouldUseSecretV2Bridge) + throw new BadRequestError({ + message: "Project version does not support pagination", + name: "pagination_not_supported" + }); + + const count = await secretV2BridgeService.getSecretsCount({ + projectId, + actorId, + actor, + actorOrgId, + environment, + path, + actorAuthMethod, + tagSlugs, + ...v2Params + }); + + return count; + }; + + const getSecretsCountMultiEnv = async ({ + projectId, + path, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environments, + ...v2Params + }: Pick< + TGetSecretsRawDTO, + "projectId" | "path" | "actor" | "actorId" | "actorOrgId" | "actorAuthMethod" | "search" + > & { environments: string[] }) => { + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + + if (!shouldUseSecretV2Bridge) + throw new BadRequestError({ + message: "Project version does not support pagination", + name: "pagination_not_supported" + }); + + const count = await secretV2BridgeService.getSecretsCountMultiEnv({ + projectId, + actorId, + actor, + actorOrgId, + environments, + path, + actorAuthMethod, + ...v2Params + }); + + return count; + }; + + const getSecretsRawMultiEnv = async ({ + projectId, + path, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environments, + ...params + }: Omit & { + environments: string[]; + }) => { + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + + if (!shouldUseSecretV2Bridge) + throw new BadRequestError({ + message: "Project version does not support pagination", + name: "pagination_not_supported" + }); + + const secrets = await secretV2BridgeService.getSecretsMultiEnv({ + projectId, + actorId, + actor, + actorOrgId, + environments, + path, + actorAuthMethod, + ...params + }); + + return secrets; + }; + const getSecretsRaw = async ({ projectId, path, @@ -965,7 +1079,8 @@ export const secretServiceFactory = ({ includeImports, expandSecretReferences, recursive, - tagSlugs = [] + tagSlugs = [], + ...paramsV2 }: TGetSecretsRawDTO) => { const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); if (shouldUseSecretV2Bridge) { @@ -980,12 +1095,17 @@ export const secretServiceFactory = ({ recursive, actorAuthMethod, includeImports, - tagSlugs + tagSlugs, + ...paramsV2 }); return { secrets, imports }; } - if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + if (!botKey) + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); const { secrets, imports } = await getSecrets({ actorId, @@ -1146,7 +1266,10 @@ export const secretServiceFactory = ({ }); if (!botKey) - throw new BadRequestError({ message: "Please upgrade your project first", name: "bot_not_found_error" }); + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); const decryptedSecret = decryptSecretRaw(encryptedSecret, botKey); if (expandSecretReferences) { @@ -1238,7 +1361,11 @@ export const secretServiceFactory = ({ return { secret, type: SecretProtectionType.Direct as const }; } - if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + if (!botKey) + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secretName, botKey); const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secretValue || "", botKey); const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secretComment || "", botKey); @@ -1376,7 +1503,11 @@ export const secretServiceFactory = ({ return { type: SecretProtectionType.Direct as const, secret }; } - if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + if (!botKey) + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secretValue || "", botKey); const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secretComment || "", botKey); @@ -1498,7 +1629,11 @@ export const secretServiceFactory = ({ }); return { type: SecretProtectionType.Direct as const, secret }; } - if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + if (!botKey) + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); if (policy) { const approval = await secretApprovalRequestService.generateSecretApprovalRequest({ policy, @@ -1598,7 +1733,11 @@ export const secretServiceFactory = ({ return { secrets, type: SecretProtectionType.Direct as const }; } - if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + if (!botKey) + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); const sanitizedSecrets = inputSecrets.map( ({ secretComment, secretKey, metadata, tagIds, secretValue, skipMultilineEncoding }) => { const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secretKey, botKey); @@ -1720,7 +1859,11 @@ export const secretServiceFactory = ({ return { type: SecretProtectionType.Direct as const, secrets }; } - if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + if (!botKey) + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); const sanitizedSecrets = inputSecrets.map( ({ secretComment, @@ -1848,7 +1991,11 @@ export const secretServiceFactory = ({ return { type: SecretProtectionType.Direct as const, secrets }; } - if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + if (!botKey) + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); if (policy) { const approval = await secretApprovalRequestService.generateSecretApprovalRequest({ @@ -2182,7 +2329,10 @@ export const secretServiceFactory = ({ } if (!botKey) - throw new BadRequestError({ message: "Please upgrade your project first", name: "bot_not_found_error" }); + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); await secretDAL.transaction(async (tx) => { const secrets = await secretDAL.findAllProjectSecretValues(projectId, tx); @@ -2265,7 +2415,10 @@ export const secretServiceFactory = ({ const { botKey } = await projectBotService.getBotKey(project.id); if (!botKey) { - throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + throw new BadRequestError({ + message: "Project bot not found. Please upgrade your project.", + name: "bot_not_found_error" + }); } const sourceFolder = await folderDAL.findBySecretPath(project.id, sourceEnvironment, sourceSecretPath); @@ -2656,6 +2809,9 @@ export const secretServiceFactory = ({ getSecretVersions, backfillSecretReferences, moveSecrets, - startSecretV2Migration + startSecretV2Migration, + getSecretsCount, + getSecretsCountMultiEnv, + getSecretsRawMultiEnv }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 1686ee488..7c09c9349 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -1,7 +1,8 @@ import { Knex } from "knex"; +import { z } from "zod"; import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas"; -import { TProjectPermission } from "@app/lib/types"; +import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TSecretDALFactory } from "@app/services/secret/secret-dal"; @@ -21,6 +22,29 @@ type TPartialSecret = Pick; +export const FailedIntegrationSyncEmailsPayloadSchema = z.object({ + projectId: z.string(), + secretPath: z.string(), + environmentName: z.string(), + environmentSlug: z.string(), + + count: z.number(), + syncMessage: z.string().optional(), + manuallyTriggeredByUserId: z.string().optional() +}); + +export type TFailedIntegrationSyncEmailsPayload = z.infer; + +export type TIntegrationSyncPayload = { + isManual?: boolean; + actorId?: string; + projectId: string; + environment: string; + secretPath: string; + depth?: number; + deDupeQueue?: Record; +}; + export type TCreateSecretDTO = { secretName: string; path: string; @@ -81,6 +105,8 @@ export type TGetSecretsDTO = { environment: string; includeImports?: boolean; recursive?: boolean; + limit?: number; + offset?: number; } & TProjectPermission; export type TGetASecretDTO = { @@ -143,6 +169,10 @@ export type TDeleteBulkSecretDTO = { }>; } & TProjectPermission; +export enum SecretsOrderBy { + Name = "name" // "key" for secrets but using name for use across resources +} + export type TGetSecretsRawDTO = { expandSecretReferences?: boolean; path: string; @@ -150,6 +180,11 @@ export type TGetSecretsRawDTO = { includeImports?: boolean; recursive?: boolean; tagSlugs?: string[]; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + offset?: number; + limit?: number; + search?: string; } & TProjectPermission; export type TGetASecretRawDTO = { diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index d0e1e0774..823da4cca 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -33,7 +33,8 @@ export enum SmtpTemplates { SecretLeakIncident = "secretLeakIncident.handlebars", WorkspaceInvite = "workspaceInvitation.handlebars", ScimUserProvisioned = "scimUserProvisioned.handlebars", - PkiExpirationAlert = "pkiExpirationAlert.handlebars" + PkiExpirationAlert = "pkiExpirationAlert.handlebars", + IntegrationSyncFailed = "integrationSyncFailed.handlebars" } export enum SmtpHost { diff --git a/backend/src/services/smtp/templates/integrationSyncFailed.handlebars b/backend/src/services/smtp/templates/integrationSyncFailed.handlebars new file mode 100644 index 000000000..5c5d76693 --- /dev/null +++ b/backend/src/services/smtp/templates/integrationSyncFailed.handlebars @@ -0,0 +1,31 @@ + + + + + + Integration Sync Failed + + + +

Infisical

+ +
+

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

+ + View your project integrations. + +
+ +
+
+

Project: {{projectName}}

+

Environment: {{environment}}

+

Secret Path: {{secretPath}}

+
+ + {{#if syncMessage}} +

Reason: {{syncMessage}}

+ {{/if}} + + + \ No newline at end of file diff --git a/cli/packages/cmd/root.go b/cli/packages/cmd/root.go index 482c6f78a..e40c07022 100644 --- a/cli/packages/cmd/root.go +++ b/cli/packages/cmd/root.go @@ -4,6 +4,7 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( + "fmt" "os" "strings" @@ -43,14 +44,26 @@ func init() { rootCmd.PersistentFlags().Bool("silent", false, "Disable output of tip/info messages. Useful when running in scripts or CI/CD pipelines.") rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { silent, err := cmd.Flags().GetBool("silent") - config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL) if err != nil { util.HandleError(err) } + config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL) + if !util.IsRunningInDocker() && !silent { util.CheckForUpdate() } + + loggedInDetails, err := util.GetCurrentLoggedInUserDetails() + + if !silent && err == nil && loggedInDetails.IsUserLoggedIn && !loggedInDetails.LoginExpired { + token, err := util.GetInfisicalToken(cmd) + + if err == nil && token != nil { + util.PrintWarning(fmt.Sprintf("Your logged-in session is being overwritten by the token provided from the %s.", token.Source)) + } + } + } // if config.INFISICAL_URL is set to the default value, check if INFISICAL_URL is set in the environment diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index c9ac36852..e2987cc7d 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -160,19 +160,19 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - if (token == nil) { + if token == nil { util.RequireLocalWorkspaceFile() } environmentName, _ := cmd.Flags().GetString("env") if !cmd.Flags().Changed("env") { - environmentFromWorkspace := util.GetEnvFromWorkspaceFile() + environmentFromWorkspace := util.GetEnvFromWorkspaceFile() if environmentFromWorkspace != "" { environmentName = environmentFromWorkspace } } - projectId, err := cmd.Flags().GetString("projectId") + projectId, err := cmd.Flags().GetString("projectId") if err != nil { util.HandleError(err, "Unable to parse flag") } diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 1bad5e327..62ff07190 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -63,8 +63,9 @@ type DynamicSecretLease struct { } type TokenDetails struct { - Type string - Token string + Type string + Token string + Source string } type SingleFolder struct { diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index b758ebd9d..11a1e3e0a 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -87,11 +87,15 @@ func GetInfisicalToken(cmd *cobra.Command) (token *models.TokenDetails, err erro return nil, err } + var source = "--token flag" + if infisicalToken == "" { // If no flag is passed, we first check for the universal auth access token env variable. infisicalToken = os.Getenv(INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME) + source = fmt.Sprintf("%s environment variable", INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME) if infisicalToken == "" { // If it's still empty after the first env check, we check for the service token env variable. infisicalToken = os.Getenv(INFISICAL_TOKEN_NAME) + source = fmt.Sprintf("%s environment variable", INFISICAL_TOKEN_NAME) } } @@ -101,14 +105,16 @@ func GetInfisicalToken(cmd *cobra.Command) (token *models.TokenDetails, err erro if strings.HasPrefix(infisicalToken, "st.") { return &models.TokenDetails{ - Type: SERVICE_TOKEN_IDENTIFIER, - Token: infisicalToken, + Type: SERVICE_TOKEN_IDENTIFIER, + Token: infisicalToken, + Source: source, }, nil } return &models.TokenDetails{ - Type: UNIVERSAL_AUTH_TOKEN_IDENTIFIER, - Token: infisicalToken, + Type: UNIVERSAL_AUTH_TOKEN_IDENTIFIER, + Token: infisicalToken, + Source: source, }, nil } diff --git a/docs/api-reference/endpoints/audit-logs/export-audit-log.mdx b/docs/api-reference/endpoints/audit-logs/export-audit-log.mdx index aa5adb004..d39cbe3d1 100644 --- a/docs/api-reference/endpoints/audit-logs/export-audit-log.mdx +++ b/docs/api-reference/endpoints/audit-logs/export-audit-log.mdx @@ -1,4 +1,4 @@ --- title: "Export" -openapi: "GET /api/v1/workspace/{workspaceId}/audit-logs" +openapi: "GET /api/v1/organization/audit-logs" --- diff --git a/docs/documentation/platform/dynamic-secrets/azure-entra-id.mdx b/docs/documentation/platform/dynamic-secrets/azure-entra-id.mdx new file mode 100644 index 000000000..8a71772b1 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/azure-entra-id.mdx @@ -0,0 +1,164 @@ +--- +title: "Azure Entra Id" +description: "Learn how to dynamically generate Azure Entra Id user credentials." +--- + +The Infisical Azure Entra Id dynamic secret allows you to generate Azure Entra Id credentials on demand based on configured role. + +## Prerequisites + + + +Login to [Microsoft Entra ID](https://entra.microsoft.com/) + + + +Go to Overview, Copy and store `Tenant Id` +![Copy Tenant Id](../../../images/platform/dynamic-secrets/dynamic-secret-ad-tenant-id.png) + + + +Go to Applications > App registrations. Click on New Registration. +![Copy Tenant Id](../../../images/platform/dynamic-secrets/dynamic-secret-ad-new-registration.png) + + + +Enter an application name. Click Register. + + + +Copy and store `Application Id`. +![Copy Application Id](../../../images/platform/dynamic-secrets/dynamic-secret-ad-copy-app-id.png) + + + +Go to Clients and Secrets. Click on New Client Secret. + + + +Enter a description, select expiry and click Add. + + + +Copy and store `Client Secret` value. +![Copy client Secret](../../../images/platform/dynamic-secrets/dynamic-secret-ad-add-client-secret.png) + + + +Go to API Permissions. Click on Add a permission. +![Click add a permission](../../../images/platform/dynamic-secrets/dynamic-secret-ad-add-permission.png) + + + +Click on Microsoft Graph. +![Click Microsoft Graph](../../../images/platform/dynamic-secrets/dynamic-secret-ad-select-graph.png) + + + +Click on Application Permissions. Search and select `User.ReadWrite.All` and click Add permissions. +![Add User.Read.All](../../../images/platform/dynamic-secrets/dynamic-secret-ad-select-perms.png) + + + +Click on Grant admin consent for app. Click yes to confirm. +![Grant admin consent](../../../images/platform/dynamic-secrets/dynamic-secret-ad-admin-consent.png) + + + +Go to Dashboard. Click on show more. +![Show more](../../../images/platform/dynamic-secrets/dynamic-secret-ad-show-more.png) + + + +Click on Roles & admins. Search for User Administrator and click on it. +![User Administrator](../../../images/platform/dynamic-secrets/dynamic-secret-ad-user-admin.png) + + + +Click on Add assignments. Search for the application name you created and select it. Click on Add. +![Add assignments](../../../images/platform/dynamic-secrets/dynamic-secret-ad-add-assignments.png) + + + +## Set up Dynamic Secrets with Azure Entra ID + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-ad-modal.png) + + + + Prefix for the secrets to be created + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret. + + + + The Tenant ID of your Azure Entra ID account. + + + + The Application ID of the application you created in Azure Entra ID. + + + + The Client Secret of the application you created in Azure Entra ID. + + + + Multi select list of users to generate secrets for. + + + + + After submitting the form, you will see a dynamic secrets for each user created in the dashboard. + + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-ad-lease.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the expiration time of the lease or delete a lease before it's set time to live. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/documentation/platform/workflow-integrations/slack-integration.mdx b/docs/documentation/platform/workflow-integrations/slack-integration.mdx index 18172a2a5..92b3feeca 100644 --- a/docs/documentation/platform/workflow-integrations/slack-integration.mdx +++ b/docs/documentation/platform/workflow-integrations/slack-integration.mdx @@ -5,9 +5,11 @@ description: "Learn how to setup Slack integration" This guide will provide step by step instructions on how to configure Slack integration for your Infisical projects. +## Setting up Slack integration in your projects + - ## Create Slack workflow integration + ### Create Slack workflow integration In order to use Slack integration in your projects, you will first have to @@ -32,7 +34,7 @@ This guide will provide step by step instructions on how to configure Slack inte - ## Configure project to use Slack workflow integration + ### Configure project to use Slack workflow integration @@ -56,7 +58,7 @@ This guide will provide step by step instructions on how to configure Slack inte - ## Configure admin settings + ### Configure admin settings Note that this step only has to be done once for the entire instance. @@ -90,7 +92,7 @@ This guide will provide step by step instructions on how to configure Slack inte - ## Create Slack workflow integration + ### Create Slack workflow integration @@ -116,7 +118,7 @@ This guide will provide step by step instructions on how to configure Slack inte - ## Configure project to use Slack workflow integration + ### Configure project to use Slack workflow integration @@ -140,3 +142,23 @@ This guide will provide step by step instructions on how to configure Slack inte + +## Using the Slack integration in your private channels + + + + ![private slack setup + menu](/images/platform/workflow-integrations/slack-integration/private-slack-setup-menu.png) + + + ![private slack setup + add](/images/platform/workflow-integrations/slack-integration/private-slack-setup-add.png) + + + ![private slack setup + form](/images/platform/workflow-integrations/slack-integration/private-slack-setup-form.png) + You can now view the private channels in the Slack channel selection fields! + ![private slack setup + channels](/images/platform/workflow-integrations/slack-integration/private-slack-setup-channel-field.png) + + diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-assignments.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-assignments.png new file mode 100644 index 000000000..561639de0 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-assignments.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-client-secret.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-client-secret.png new file mode 100644 index 000000000..358fc53c1 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-client-secret.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-permission.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-permission.png new file mode 100644 index 000000000..20614f9dc Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-add-permission.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-admin-consent.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-admin-consent.png new file mode 100644 index 000000000..5c8102450 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-admin-consent.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-copy-app-id.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-copy-app-id.png new file mode 100644 index 000000000..aa36ee39d Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-copy-app-id.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-lease.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-lease.png new file mode 100644 index 000000000..4740062db Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-lease.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-modal.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-modal.png new file mode 100644 index 000000000..481c78923 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-new-registration.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-new-registration.png new file mode 100644 index 000000000..285df9d77 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-new-registration.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-select-graph.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-select-graph.png new file mode 100644 index 000000000..98f23c084 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-select-graph.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-select-perms.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-select-perms.png new file mode 100644 index 000000000..45abaa738 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-select-perms.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-show-more.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-show-more.png new file mode 100644 index 000000000..df4dc9567 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-show-more.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-tenant-id.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-tenant-id.png new file mode 100644 index 000000000..5b0cb4763 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-tenant-id.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ad-user-admin.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-user-admin.png new file mode 100644 index 000000000..dfc1deadd Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ad-user-admin.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-add.png b/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-add.png new file mode 100644 index 000000000..1945313e8 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-add.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-channel-field.png b/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-channel-field.png new file mode 100644 index 000000000..3f6bd0d1d Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-channel-field.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-form.png b/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-form.png new file mode 100644 index 000000000..7d5ac0560 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-form.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-menu.png b/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-menu.png new file mode 100644 index 000000000..087f31483 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-menu.png differ diff --git a/docs/mint.json b/docs/mint.json index 60f7bbbcb..2fbce096d 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -167,7 +167,8 @@ "documentation/platform/dynamic-secrets/rabbit-mq", "documentation/platform/dynamic-secrets/aws-iam", "documentation/platform/dynamic-secrets/mongo-atlas", - "documentation/platform/dynamic-secrets/mongo-db" + "documentation/platform/dynamic-secrets/mongo-db", + "documentation/platform/dynamic-secrets/azure-entra-id" ] }, { diff --git a/docs/sdks/languages/node.mdx b/docs/sdks/languages/node.mdx index 9a25dd43e..04f210db0 100644 --- a/docs/sdks/languages/node.mdx +++ b/docs/sdks/languages/node.mdx @@ -1,9 +1,11 @@ --- title: "Infisical Node.js SDK" sidebarTitle: "Node.js" +url: "https://github.com/Infisical/node-sdk-v2" icon: "node" --- +{/* If you're working with Node.js, the official [Infisical Node SDK](https://github.com/Infisical/sdk/tree/main/languages/node) package is the easiest way to fetch and work with secrets for your application. - [NPM Package](https://www.npmjs.com/package/@infisical/sdk) @@ -552,3 +554,5 @@ const decryptedString = await client.decryptSymmetric({ #### Returns (string) `plaintext` (string): The decrypted plaintext. + +*/} \ No newline at end of file diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index e502fdd4e..11d34bb38 100644 --- a/docs/sdks/overview.mdx +++ b/docs/sdks/overview.mdx @@ -10,7 +10,7 @@ From local development to production, Infisical SDKs provide the easiest way for - Fetch secrets on demand - + Manage secrets for your Node application on demand diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 95104cdf1..5227088b6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,5 +1,5 @@ { - "name": "frontend", + "name": "relock-npm-lock-v2-SvMQeF", "lockfileVersion": 3, "requires": true, "packages": { @@ -65,7 +65,7 @@ "i18next-http-backend": "^2.2.0", "infisical-node": "^1.0.37", "ip": "^2.0.1", - "jspdf": "^2.5.1", + "jspdf": "^2.5.2", "jsrp": "^0.2.4", "jwt-decode": "^3.1.2", "lottie-react": "^2.4.0", @@ -12729,9 +12729,10 @@ } }, "node_modules/dompurify": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.7.tgz", - "integrity": "sha512-kxxKlPEDa6Nc5WJi+qRgPbOAbgTpSULL+vI3NUXsZMlkJxTqYI9wg5ZTay2sFrdZRWHPWNi+EdAhcJf81WtoMQ==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.6.tgz", + "integrity": "sha512-zUTaUBO8pY4+iJMPE1B9XlO2tXVYIcEA4SNGtvDELzTSCQO7RzH+j7S180BmhmJId78lqGU2z19vgVx2Sxs/PQ==", + "license": "(MPL-2.0 OR Apache-2.0)", "optional": true }, "node_modules/domutils": { @@ -16873,22 +16874,29 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/jspdf": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.1.tgz", - "integrity": "sha512-hXObxz7ZqoyhxET78+XR34Xu2qFGrJJ2I2bE5w4SM8eFaFEkW2xcGRVUss360fYelwRSid/jT078kbNvmoW0QA==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", + "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.14.0", + "@babel/runtime": "^7.23.2", "atob": "^2.1.2", "btoa": "^1.2.1", - "fflate": "^0.4.8" + "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.6", "core-js": "^3.6.0", - "dompurify": "^2.2.0", + "dompurify": "^2.5.4", "html2canvas": "^1.0.0-rc.5" } }, + "node_modules/jspdf/node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index a538e7cf1..42467ced4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -73,7 +73,7 @@ "i18next-http-backend": "^2.2.0", "infisical-node": "^1.0.37", "ip": "^2.0.1", - "jspdf": "^2.5.1", + "jspdf": "^2.5.2", "jsrp": "^0.2.4", "jwt-decode": "^3.1.2", "lottie-react": "^2.4.0", diff --git a/frontend/src/components/features/FormLabelToolTip.tsx b/frontend/src/components/features/FormLabelToolTip.tsx new file mode 100644 index 000000000..584c47aae --- /dev/null +++ b/frontend/src/components/features/FormLabelToolTip.tsx @@ -0,0 +1,36 @@ +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { FormLabel, Tooltip } from "../v2"; + +// To give users example of possible values of TTL +export const FormLabelToolTip = ({ label, linkToMore, content }: { label: string, linkToMore: string, content: string }) => ( +
+ + {content}{" "} + + More + + + } + > + + + } + /> +
+); diff --git a/frontend/src/components/features/TtlFormLabel.tsx b/frontend/src/components/features/TtlFormLabel.tsx index 14382abb4..5278feec7 100644 --- a/frontend/src/components/features/TtlFormLabel.tsx +++ b/frontend/src/components/features/TtlFormLabel.tsx @@ -1,36 +1,12 @@ -import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { FormLabel, Tooltip } from "../v2"; +import { FormLabelToolTip } from "./FormLabelToolTip"; // To give users example of possible values of TTL export const TtlFormLabel = ({ label }: { label: string }) => (
- - 1m, 2h, 3d.{" "} - - More - - - } - > - - - } + content="1m, 2h, 3d. " + linkToMore="https://github.com/vercel/ms?tab=readme-ov-file#examples" />
); diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index cecae5287..715d8c51d 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -100,7 +100,7 @@ export default function NavHeader({ onValueChange={(value) => { if (value && onEnvChange) onEnvChange(value); }} - className="bg-transparent pl-0 text-sm font-medium text-primary/80 hover:text-primary" + className="border-none bg-transparent pl-0 text-sm font-medium text-primary/80 hover:text-primary" dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 drop-shadow-2xl" > {userAvailableEnvs?.map(({ name, slug }) => ( diff --git a/frontend/src/components/v2/Pagination/Pagination.tsx b/frontend/src/components/v2/Pagination/Pagination.tsx index 7b3178707..cc1f7df95 100644 --- a/frontend/src/components/v2/Pagination/Pagination.tsx +++ b/frontend/src/components/v2/Pagination/Pagination.tsx @@ -1,3 +1,4 @@ +import { ReactElement } from "react"; import { faCaretDown, faCheck, @@ -23,6 +24,7 @@ export type PaginationProps = { onChangePerPage: (newRows: number) => void; className?: string; perPageList?: number[]; + startAdornment?: ReactElement; }; export const Pagination = ({ @@ -32,7 +34,8 @@ export const Pagination = ({ onChangePage, onChangePerPage, perPageList = [10, 20, 50, 100], - className + className, + startAdornment }: PaginationProps) => { const prevPageNumber = Math.max(1, page - 1); const canGoPrev = page > 1; @@ -46,11 +49,12 @@ export const Pagination = ({ return (
-
+ {startAdornment} +
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 5b7ef0174..c950ec179 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -21,7 +21,8 @@ export enum OrgPermissionSubjects { SecretScanning = "secret-scanning", Identity = "identity", Kms = "kms", - AdminConsole = "organization-admin-console" + AdminConsole = "organization-admin-console", + AuditLogs = "audit-logs" } export enum OrgPermissionAdminConsoleAction { @@ -43,6 +44,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Identity] | [OrgPermissionActions, OrgPermissionSubjects.Kms] - | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; + | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] + | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/context/UserContext/UserContext.tsx b/frontend/src/context/UserContext/UserContext.tsx index 433fc3959..0e23ccb7c 100644 --- a/frontend/src/context/UserContext/UserContext.tsx +++ b/frontend/src/context/UserContext/UserContext.tsx @@ -25,6 +25,21 @@ export const UserProvider = ({ children }: Props): JSX.Element => { }; }, [data, isLoading]); + if (isLoading) { + return ( +
+ infisical loading indicator +
+ ); + } + return {children}; }; diff --git a/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx b/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx index 4cf8c585f..29ecacaa5 100644 --- a/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx +++ b/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx @@ -1,6 +1,7 @@ -import { createContext, ReactNode, useContext, useMemo } from "react"; +import { createContext, ReactNode, useContext, useEffect, useMemo } from "react"; import { useRouter } from "next/router"; +import { createNotification } from "@app/components/notifications"; import { useGetUserWorkspaces } from "@app/hooks/api"; import { Workspace } from "@app/hooks/api/workspace/types"; @@ -31,6 +32,34 @@ export const WorkspaceProvider = ({ children }: Props): JSX.Element => { }; }, [ws, workspaceId, isLoading]); + const shouldTriggerNoProjectAccess = + !value.isLoading && + !value.currentWorkspace && + router.pathname.startsWith("/project") && + workspaceId; + + // handle redirects for project-specific routes + useEffect(() => { + if (shouldTriggerNoProjectAccess) { + createNotification({ + text: "You are not a member of this project.", + type: "info" + }); + + setTimeout(() => { + router.push("/"); + }, 5000); + } + }, [shouldTriggerNoProjectAccess, router]); + + if (shouldTriggerNoProjectAccess) { + return ( +
+ You do not have sufficient access to this project. +
+ ); + } + return {children}; }; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index cc72cfa86..404592908 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -79,7 +79,8 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG]: "Update certificate template EST configuration", [EventType.UPDATE_PROJECT_SLACK_CONFIG]: "Update project slack configuration", - [EventType.GET_PROJECT_SLACK_CONFIG]: "Get project slack configuration" + [EventType.GET_PROJECT_SLACK_CONFIG]: "Get project slack configuration", + [EventType.INTEGRATION_SYNCED]: "Integration sync" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index b110e330b..1db55d739 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -1,4 +1,5 @@ export enum ActorType { + PLATFORM = "platform", USER = "user", SERVICE = "service", IDENTITY = "identity" @@ -91,5 +92,6 @@ export enum EventType { UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", - GET_PROJECT_SLACK_CONFIG = "get-project-slack-config" + GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", + INTEGRATION_SYNCED = "integration-synced" } diff --git a/frontend/src/hooks/api/auditLogs/queries.tsx b/frontend/src/hooks/api/auditLogs/queries.tsx index 1788b8f08..1c74a79ba 100644 --- a/frontend/src/hooks/api/auditLogs/queries.tsx +++ b/frontend/src/hooks/api/auditLogs/queries.tsx @@ -1,35 +1,58 @@ -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, UseInfiniteQueryOptions, useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { Actor, AuditLog, AuditLogFilters } from "./types"; +import { Actor, AuditLog, TGetAuditLogsFilter } from "./types"; export const auditLogKeys = { - getAuditLogs: (workspaceId: string | null, filters: AuditLogFilters) => + getAuditLogs: (workspaceId: string | null, filters: TGetAuditLogsFilter) => [{ workspaceId, filters }, "audit-logs"] as const, getAuditLogActorFilterOpts: (workspaceId: string) => [{ workspaceId }, "audit-log-actor-filters"] as const }; -export const useGetAuditLogs = (filters: AuditLogFilters, workspaceId: string | null) => { +export const useGetAuditLogs = ( + filters: TGetAuditLogsFilter, + projectId: string | null, + options: Omit< + UseInfiniteQueryOptions< + AuditLog[], + unknown, + AuditLog[], + AuditLog[], + ReturnType + >, + "queryFn" | "queryKey" | "getNextPageParam" + > = {} +) => { return useInfiniteQuery({ - queryKey: auditLogKeys.getAuditLogs(workspaceId, filters), + queryKey: auditLogKeys.getAuditLogs(projectId, filters), queryFn: async ({ pageParam }) => { - const auditLogEndpoint = workspaceId - ? `/api/v1/workspace/${workspaceId}/audit-logs` - : "/api/v1/organization/audit-logs"; - const { data } = await apiRequest.get<{ auditLogs: AuditLog[] }>(auditLogEndpoint, { - params: { - ...filters, - offset: pageParam, - startDate: filters?.startDate?.toISOString(), - endDate: filters?.endDate?.toISOString() + const { data } = await apiRequest.get<{ auditLogs: AuditLog[] }>( + "/api/v1/organization/audit-logs", + { + params: { + ...filters, + offset: pageParam, + startDate: filters?.startDate?.toISOString(), + endDate: filters?.endDate?.toISOString(), + ...(filters.eventMetadata && Object.keys(filters.eventMetadata).length + ? { + eventMetadata: Object.entries(filters.eventMetadata) + .map(([key, value]) => `${key}=${value}`) + .join(",") + } + : {}), + ...(filters.eventType?.length ? { eventType: filters.eventType.join(",") } : {}), + ...(projectId ? { projectId } : {}) + } } - }); + ); return data.auditLogs; }, getNextPageParam: (lastPage, pages) => - lastPage.length !== 0 ? pages.length * filters.limit : undefined + lastPage.length !== 0 ? pages.length * filters.limit : undefined, + ...options }); }; diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 890327e0e..764d0b2a5 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -3,6 +3,18 @@ import { IdentityTrustedIp } from "../identities/types"; import { PkiItemType } from "../pkiCollections/constants"; import { ActorType, EventType, UserAgentType } from "./enums"; +export type TGetAuditLogsFilter = { + eventType?: EventType[]; + userAgentType?: UserAgentType; + eventMetadata?: Record; + actorType?: ActorType; + projectId?: string; + actorId?: string; // user ID format + startDate?: Date; + endDate?: Date; + limit: number; +}; + interface UserActorMetadata { userId: string; email: string; @@ -33,7 +45,13 @@ export interface IdentityActor { metadata: IdentityActorMetadata; } -export type Actor = UserActor | ServiceActor | IdentityActor; +export interface PlatformActorMetadata {} +export interface PlatformActor { + type: ActorType.PLATFORM; + metadata: PlatformActorMetadata; +} + +export type Actor = UserActor | ServiceActor | IdentityActor | PlatformActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -761,6 +779,22 @@ interface GetProjectSlackConfig { }; } +export enum IntegrationSyncedEventTrigger { + MANUAL = "manual", + AUTO = "auto" +} + +interface IntegrationSyncedEvent { + type: EventType.INTEGRATION_SYNCED; + metadata: { + integrationId: string; + lastSyncJobId: string; + lastUsed: Date; + syncMessage: string; + isSynced: boolean; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -838,7 +872,8 @@ export type Event = | CreateCertificateTemplateEstConfig | GetCertificateTemplateEstConfig | UpdateProjectSlackConfig - | GetProjectSlackConfig; + | GetProjectSlackConfig + | IntegrationSyncedEvent; export type AuditLog = { id: string; @@ -851,17 +886,8 @@ export type AuditLog = { userAgentType: UserAgentType; createdAt: string; updatedAt: string; - project: { + project?: { name: string; slug: string; }; }; - -export type AuditLogFilters = { - eventType?: EventType; - userAgentType?: UserAgentType; - actor?: string; - limit: number; - startDate?: Date; - endDate?: Date; -}; diff --git a/frontend/src/hooks/api/dashboard/index.ts b/frontend/src/hooks/api/dashboard/index.ts new file mode 100644 index 000000000..b0fb9324a --- /dev/null +++ b/frontend/src/hooks/api/dashboard/index.ts @@ -0,0 +1 @@ +export { useGetProjectSecretsDetails } from "./queries"; diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx new file mode 100644 index 000000000..38c07b6cf --- /dev/null +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -0,0 +1,261 @@ +import { useCallback } from "react"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import axios from "axios"; + +import { createNotification } from "@app/components/notifications"; +import { apiRequest } from "@app/config/request"; +import { + DashboardProjectSecretsDetails, + DashboardProjectSecretsDetailsResponse, + DashboardProjectSecretsOverview, + DashboardProjectSecretsOverviewResponse, + DashboardSecretsOrderBy, + TGetDashboardProjectSecretsDetailsDTO, + TGetDashboardProjectSecretsOverviewDTO +} from "@app/hooks/api/dashboard/types"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries"; + +export const dashboardKeys = { + all: () => ["dashboard"] as const, + getDashboardSecrets: ({ + projectId, + secretPath + }: Pick) => + [...dashboardKeys.all(), { projectId, secretPath }] as const, + getProjectSecretsOverview: ({ + projectId, + secretPath, + ...params + }: TGetDashboardProjectSecretsOverviewDTO) => + [ + ...dashboardKeys.getDashboardSecrets({ projectId, secretPath }), + "secrets-overview", + params + ] as const, + getProjectSecretsDetails: ({ + projectId, + secretPath, + environment, + ...params + }: TGetDashboardProjectSecretsDetailsDTO) => + [ + ...dashboardKeys.getDashboardSecrets({ projectId, secretPath }), + environment, + "secrets-details", + params + ] as const +}; + +export const fetchProjectSecretsOverview = async ({ + includeFolders, + includeSecrets, + includeDynamicSecrets, + environments, + ...params +}: TGetDashboardProjectSecretsOverviewDTO) => { + const { data } = await apiRequest.get( + "/api/v3/dashboard/secrets-overview", + { + params: { + ...params, + environments: encodeURIComponent(environments.join(",")), + includeFolders: includeFolders ? "1" : "", + includeSecrets: includeSecrets ? "1" : "", + includeDynamicSecrets: includeDynamicSecrets ? "1" : "" + } + } + ); + + return data; +}; + +export const fetchProjectSecretsDetails = async ({ + includeFolders, + includeImports, + includeSecrets, + includeDynamicSecrets, + tags, + ...params +}: TGetDashboardProjectSecretsDetailsDTO) => { + const { data } = await apiRequest.get( + "/api/v3/dashboard/secrets-details", + { + params: { + ...params, + includeImports: includeImports ? "1" : "", + includeFolders: includeFolders ? "1" : "", + includeSecrets: includeSecrets ? "1" : "", + includeDynamicSecrets: includeDynamicSecrets ? "1" : "", + tags: encodeURIComponent( + Object.entries(tags) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([_, enabled]) => enabled) + .map(([tag]) => tag) + .join(",") + ) + } + } + ); + + return data; +}; + +export const useGetProjectSecretsOverview = ( + { + projectId, + secretPath, + offset = 0, + limit = 100, + orderBy = DashboardSecretsOrderBy.Name, + orderDirection = OrderByDirection.ASC, + search = "", + includeSecrets, + includeFolders, + includeDynamicSecrets, + environments + }: TGetDashboardProjectSecretsOverviewDTO, + options?: Omit< + UseQueryOptions< + DashboardProjectSecretsOverviewResponse, + unknown, + DashboardProjectSecretsOverview, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + ...options, + // wait for all values to be available + enabled: Boolean(projectId) && (options?.enabled ?? true) && Boolean(environments.length), + queryKey: dashboardKeys.getProjectSecretsOverview({ + secretPath, + search, + limit, + orderBy, + orderDirection, + offset, + projectId, + includeSecrets, + includeFolders, + includeDynamicSecrets, + environments + }), + queryFn: () => + fetchProjectSecretsOverview({ + secretPath, + search, + limit, + orderBy, + orderDirection, + offset, + projectId, + includeSecrets, + includeFolders, + includeDynamicSecrets, + environments + }), + onError: (error) => { + if (axios.isAxiosError(error)) { + const serverResponse = error.response?.data as { message: string }; + createNotification({ + title: "Error fetching secret details", + type: "error", + text: serverResponse.message + }); + } + }, + select: useCallback((data: Awaited>) => { + const { secrets, ...select } = data; + + return { + ...select, + secrets: secrets ? mergePersonalSecrets(secrets) : undefined + }; + }, []), + keepPreviousData: true + }); +}; + +export const useGetProjectSecretsDetails = ( + { + projectId, + secretPath, + environment, + offset = 0, + limit = 100, + orderBy = DashboardSecretsOrderBy.Name, + orderDirection = OrderByDirection.ASC, + search = "", + includeSecrets, + includeFolders, + includeImports, + includeDynamicSecrets, + tags + }: TGetDashboardProjectSecretsDetailsDTO, + options?: Omit< + UseQueryOptions< + DashboardProjectSecretsDetailsResponse, + unknown, + DashboardProjectSecretsDetails, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + ...options, + // wait for all values to be available + enabled: Boolean(projectId) && (options?.enabled ?? true), + queryKey: dashboardKeys.getProjectSecretsDetails({ + secretPath, + search, + limit, + orderBy, + orderDirection, + offset, + projectId, + environment, + includeSecrets, + includeFolders, + includeImports, + includeDynamicSecrets, + tags + }), + queryFn: () => + fetchProjectSecretsDetails({ + secretPath, + search, + limit, + orderBy, + orderDirection, + offset, + projectId, + environment, + includeSecrets, + includeFolders, + includeImports, + includeDynamicSecrets, + tags + }), + onError: (error) => { + if (axios.isAxiosError(error)) { + const serverResponse = error.response?.data as { message: string }; + createNotification({ + title: "Error fetching secret details", + type: "error", + text: serverResponse.message + }); + } + }, + select: useCallback( + (data: Awaited>) => ({ + ...data, + secrets: data.secrets ? mergePersonalSecrets(data.secrets) : undefined + }), + [] + ), + keepPreviousData: true + }); +}; diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts new file mode 100644 index 000000000..08e75ee3b --- /dev/null +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -0,0 +1,68 @@ +import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { TSecretFolder } from "@app/hooks/api/secretFolders/types"; +import { TSecretImport } from "@app/hooks/api/secretImports/types"; +import { SecretV3Raw, SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; + +export type DashboardProjectSecretsOverviewResponse = { + folders?: (TSecretFolder & { environment: string })[]; + dynamicSecrets?: (TDynamicSecret & { environment: string })[]; + secrets?: SecretV3Raw[]; + totalSecretCount?: number; + totalFolderCount?: number; + totalDynamicSecretCount?: number; + totalCount: number; +}; + +export type DashboardProjectSecretsDetailsResponse = { + imports?: TSecretImport[]; + folders?: TSecretFolder[]; + dynamicSecrets?: TDynamicSecret[]; + secrets?: SecretV3Raw[]; + totalImportCount?: number; + totalFolderCount?: number; + totalDynamicSecretCount?: number; + totalSecretCount?: number; + totalCount: number; +}; + +export type DashboardProjectSecretsOverview = Omit< + DashboardProjectSecretsOverviewResponse, + "secrets" +> & { + secrets?: SecretV3RawSanitized[]; +}; + +export type DashboardProjectSecretsDetails = Omit< + DashboardProjectSecretsDetailsResponse, + "secrets" +> & { + secrets?: SecretV3RawSanitized[]; +}; + +export enum DashboardSecretsOrderBy { + Name = "name" +} + +export type TGetDashboardProjectSecretsOverviewDTO = { + projectId: string; + secretPath: string; + offset?: number; + limit?: number; + orderBy?: DashboardSecretsOrderBy; + orderDirection?: OrderByDirection; + search?: string; + includeSecrets?: boolean; + includeFolders?: boolean; + includeDynamicSecrets?: boolean; + environments: string[]; +}; + +export type TGetDashboardProjectSecretsDetailsDTO = Omit< + TGetDashboardProjectSecretsOverviewDTO, + "environments" +> & { + environment: string; + includeImports?: boolean; + tags: Record; +}; diff --git a/frontend/src/hooks/api/dynamicSecret/mutation.ts b/frontend/src/hooks/api/dynamicSecret/mutation.ts index 5f41e38f5..f8fbb4d05 100644 --- a/frontend/src/hooks/api/dynamicSecret/mutation.ts +++ b/frontend/src/hooks/api/dynamicSecret/mutation.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { dynamicSecretKeys } from "./queries"; import { @@ -22,6 +23,8 @@ export const useCreateDynamicSecret = () => { return data.dynamicSecret; }, onSuccess: (_, { path, environmentSlug, projectSlug }) => { + // TODO: optimize but we currently don't pass projectId + queryClient.invalidateQueries(dashboardKeys.all()); queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug })); } }); @@ -39,6 +42,8 @@ export const useUpdateDynamicSecret = () => { return data.dynamicSecret; }, onSuccess: (_, { path, environmentSlug, projectSlug }) => { + // TODO: optimize but currently don't pass projectId + queryClient.invalidateQueries(dashboardKeys.all()); queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug })); } }); @@ -56,6 +61,8 @@ export const useDeleteDynamicSecret = () => { return data.dynamicSecret; }, onSuccess: (_, { path, environmentSlug, projectSlug }) => { + // TODO: optimize but currently don't pass projectId + queryClient.invalidateQueries(dashboardKeys.all()); queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug })); } }); diff --git a/frontend/src/hooks/api/dynamicSecret/queries.ts b/frontend/src/hooks/api/dynamicSecret/queries.ts index 481b431cc..f84fd0712 100644 --- a/frontend/src/hooks/api/dynamicSecret/queries.ts +++ b/frontend/src/hooks/api/dynamicSecret/queries.ts @@ -71,6 +71,34 @@ export const useGetDynamicSecretDetails = ({ }); }; +export const useGetDynamicSecretProviderData = ({ + tenantId, + applicationId, + clientSecret, + enabled +}: { + tenantId: string; + applicationId: string; + clientSecret: string; + enabled: boolean +}) => { + return useQuery({ + queryKey: ["users"], + queryFn: async () => { + const { data } = await apiRequest.post<{id:string, email: string, name:string}[]>( + "/api/v1/dynamic-secrets/entra-id/users", + { + tenantId, + applicationId, + clientSecret + } + ); + return data; + }, + enabled + }); +}; + export const useGetDynamicSecretsOfAllEnv = ({ path, projectSlug, diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 32c9b15d0..e97234bc4 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -24,7 +24,8 @@ export enum DynamicSecretProviders { MongoAtlas = "mongo-db-atlas", ElasticSearch = "elastic-search", MongoDB = "mongo-db", - RabbitMq = "rabbit-mq" + RabbitMq = "rabbit-mq", + AzureEntraId = "azure-entra-id" } export enum SqlProviders { @@ -177,7 +178,17 @@ export type TDynamicSecretProvider = }; ca?: string; }; - }; + } + | { + type: DynamicSecretProviders.AzureEntraId; + inputs: { + tenantId: string; + userId: string; + email: string; + applicationId: string; + clientSecret: string; + }; + }; export type TCreateDynamicSecretDTO = { projectSlug: string; diff --git a/frontend/src/hooks/api/integrations/index.tsx b/frontend/src/hooks/api/integrations/index.tsx index f91d85644..9d43c33ad 100644 --- a/frontend/src/hooks/api/integrations/index.tsx +++ b/frontend/src/hooks/api/integrations/index.tsx @@ -1 +1,6 @@ -export { useCreateIntegration, useDeleteIntegration, useGetCloudIntegrations } from "./queries"; +export { + useCreateIntegration, + useDeleteIntegration, + useGetCloudIntegrations, + useGetIntegration +} from "./queries"; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index f07e33e60..56131f641 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -1,13 +1,14 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient, UseQueryOptions } from "@tanstack/react-query"; import { createNotification } from "@app/components/notifications"; import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace"; -import { TCloudIntegration } from "./types"; +import { TCloudIntegration, TIntegrationWithEnv } from "./types"; export const integrationQueryKeys = { - getIntegrations: () => ["integrations"] as const + getIntegrations: () => ["integrations"] as const, + getIntegration: (id: string) => ["integration", id] as const }; const fetchIntegrations = async () => { @@ -18,6 +19,14 @@ const fetchIntegrations = async () => { return data.integrationOptions; }; +const fetchIntegration = async (id: string) => { + const { data } = await apiRequest.get<{ integration: TIntegrationWithEnv }>( + `/api/v1/integration/${id}` + ); + + return data.integration; +}; + export const useGetCloudIntegrations = () => useQuery({ queryKey: integrationQueryKeys.getIntegrations(), @@ -128,6 +137,26 @@ export const useDeleteIntegration = () => { }); }; +export const useGetIntegration = ( + integrationId: string, + options?: Omit< + UseQueryOptions< + TIntegrationWithEnv, + unknown, + TIntegrationWithEnv, + ReturnType + >, + "queryFn" | "queryKey" + > +) => { + return useQuery({ + ...options, + enabled: Boolean(integrationId && options?.enabled === undefined ? true : options?.enabled), + queryKey: integrationQueryKeys.getIntegration(integrationId), + queryFn: () => fetchIntegration(integrationId) + }); +}; + export const useSyncIntegration = () => { return useMutation<{}, {}, { id: string; workspaceId: string; lastUsed: string }>({ mutationFn: ({ id }) => apiRequest.post(`/api/v1/integration/${id}/sync`), diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index f8c7ce244..1a434b497 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -36,14 +36,34 @@ export type TIntegration = { metadata?: { githubVisibility?: string; githubVisibilityRepoIds?: string[]; + shouldAutoRedeploy?: boolean; + secretAWSTag?: { + key: string; + value: string; + }[]; + kmsKeyId?: string; secretSuffix?: string; + secretPrefix?: string; syncBehavior?: IntegrationSyncBehavior; mappingBehavior?: IntegrationMappingBehavior; scope: string; org: string; project: string; environment: string; + + shouldDisableDelete?: boolean; + shouldMaskSecrets?: boolean; + shouldProtectSecrets?: boolean; + shouldEnableDelete?: boolean; + }; +}; + +export type TIntegrationWithEnv = TIntegration & { + environment: { + id: string; + name: string; + slug: string; }; }; diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index 236a13a26..d85bc558a 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -8,6 +8,7 @@ import { } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { secretSnapshotKeys } from "../secretSnapshots/queries"; import { @@ -124,6 +125,12 @@ export const useCreateFolder = () => { return data; }, onSuccess: (_, { projectId, environment, path }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: path ?? "/" + }) + ); queryClient.invalidateQueries( folderQueryKeys.getSecretFolders({ projectId, environment, path }) ); @@ -151,6 +158,12 @@ export const useUpdateFolder = () => { return data; }, onSuccess: (_, { projectId, environment, path }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: path ?? "/" + }) + ); queryClient.invalidateQueries( folderQueryKeys.getSecretFolders({ projectId, environment, path }) ); @@ -179,6 +192,12 @@ export const useDeleteFolder = () => { return data; }, onSuccess: (_, { path = "/", projectId, environment }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: path + }) + ); queryClient.invalidateQueries( folderQueryKeys.getSecretFolders({ projectId, environment, path }) ); @@ -206,6 +225,12 @@ export const useUpdateFolderBatch = () => { }, onSuccess: (_, { projectId, folders }) => { folders.forEach((folder) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: folder.path ?? "/" + }) + ); queryClient.invalidateQueries( folderQueryKeys.getSecretFolders({ projectId, diff --git a/frontend/src/hooks/api/secretImports/mutation.tsx b/frontend/src/hooks/api/secretImports/mutation.tsx index 04f1f01e6..4bee1a4ed 100644 --- a/frontend/src/hooks/api/secretImports/mutation.tsx +++ b/frontend/src/hooks/api/secretImports/mutation.tsx @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { secretImportKeys } from "./queries"; import { @@ -31,6 +32,9 @@ export const useCreateSecretImport = () => { queryClient.invalidateQueries( secretImportKeys.getSecretImportSecrets({ projectId, environment, path }) ); + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" }) + ); } }); }; @@ -55,6 +59,9 @@ export const useUpdateSecretImport = () => { queryClient.invalidateQueries( secretImportKeys.getSecretImportSecrets({ environment, path, projectId }) ); + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" }) + ); } }); }; @@ -93,6 +100,9 @@ export const useDeleteSecretImport = () => { queryClient.invalidateQueries( secretImportKeys.getSecretImportSecrets({ projectId, environment, path }) ); + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" }) + ); } }); }; diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index 4f4e00922..54aa97b43 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -1,6 +1,7 @@ import { MutationOptions, useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries"; import { secretSnapshotKeys } from "../secretSnapshots/queries"; @@ -44,6 +45,9 @@ export const useCreateSecretV3 = ({ return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -96,6 +100,9 @@ export const useUpdateSecretV3 = ({ return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -139,6 +146,9 @@ export const useDeleteSecretV3 = ({ return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -172,6 +182,9 @@ export const useCreateSecretBatch = ({ return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -205,6 +218,9 @@ export const useUpdateSecretBatch = ({ return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -240,6 +256,9 @@ export const useDeleteSecretBatch = ({ return data; }, onSuccess: (_, { workspaceId, environment, secretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -295,6 +314,12 @@ export const useMoveSecrets = ({ return data; }, onSuccess: (_, { projectId, sourceEnvironment, sourceSecretPath }) => { + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: sourceSecretPath + }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId: projectId, diff --git a/frontend/src/hooks/utils/index.ts b/frontend/src/hooks/utils/index.ts new file mode 100644 index 000000000..db6645c41 --- /dev/null +++ b/frontend/src/hooks/utils/index.ts @@ -0,0 +1 @@ +export * from "./secrets-overview"; diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx new file mode 100644 index 000000000..b84fb3902 --- /dev/null +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -0,0 +1,86 @@ +import { useCallback, useMemo } from "react"; + +import { DashboardProjectSecretsOverview } from "@app/hooks/api/dashboard/types"; + +export const useFolderOverview = (folders: DashboardProjectSecretsOverview["folders"]) => { + const folderNames = useMemo(() => { + const names = new Set(); + folders?.forEach((folder) => { + names.add(folder.name); + }); + return [...names]; + }, [folders]); + + const isFolderPresentInEnv = useCallback( + (name: string, env: string) => { + return Boolean( + folders?.find( + ({ name: folderName, environment }) => folderName === name && environment === env + ) + ); + }, + [folders] + ); + + const getFolderByNameAndEnv = useCallback( + (name: string, env: string) => { + return folders?.find( + ({ name: folderName, environment }) => folderName === name && environment === env + ); + }, + [folders] + ); + + return { folderNames, isFolderPresentInEnv, getFolderByNameAndEnv }; +}; + +export const useDynamicSecretOverview = ( + dynamicSecrets: DashboardProjectSecretsOverview["dynamicSecrets"] +) => { + const dynamicSecretNames = useMemo(() => { + const names = new Set(); + dynamicSecrets?.forEach((dynamicSecret) => { + names.add(dynamicSecret.name); + }); + return [...names]; + }, [dynamicSecrets]); + + const isDynamicSecretPresentInEnv = useCallback( + (name: string, env: string) => { + return Boolean( + dynamicSecrets?.find( + ({ name: dynamicSecretName, environment }) => + dynamicSecretName === name && environment === env + ) + ); + }, + [dynamicSecrets] + ); + + return { dynamicSecretNames, isDynamicSecretPresentInEnv }; +}; + +export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secrets"]) => { + const secKeys = useMemo(() => { + const keys = new Set(); + secrets?.forEach((secret) => keys.add(secret.key)); + return [...keys]; + }, [secrets]); + + const getEnvSecretKeyCount = useCallback( + (env: string) => { + return secrets?.filter((secret) => secret.env === env).length ?? 0; + }, + [secrets] + ); + + const getSecretByKey = useCallback( + (env: string, key: string) => { + const sec = secrets?.find((s) => s.env === env && s.key === key); + return sec; + }, + [secrets] + ); + + return { secKeys, getSecretByKey, getEnvSecretKeyCount }; +}; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 4e3602686..9f5e9897f 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -675,18 +675,6 @@ export const AppLayout = ({ children }: LayoutProps) => { - - - - Audit Logs - - - { )} + + + + Audit Logs + + + { + const [orgId, setOrgId] = React.useState(null); + const router = useRouter(); + const currentUrl = router?.asPath?.split("?")?.[0]; + + // Workaround: Fixes localStorage not being available in the error boundary until the next render. + useEffect(() => { + const savedOrgId = localStorage.getItem("orgData.id"); + + if (savedOrgId) { + setOrgId(savedOrgId); + } + }, []); + + return ( +
+
+ +

+ Something went wrong. Please contact{" "} + + support@infisical.com + + , or{" "} + + + join our Slack community + + {" "} + if the issue persists. +

+ + {orgId && ( + + )} + + {error?.message && ( + <> +
+

+ + {currentUrl}, {error.message} + +

+ + )} +
+
+ ); +}; + +class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("Error caught by ErrorBoundary:", error, errorInfo); + } + + render(): ReactNode { + const { hasError, error } = this.state; + const { children } = this.props; + + if (hasError) { + return ; + } + return children; + } +} + +const ErrorBoundaryWrapper = ({ children }: ErrorBoundaryProps) => { + return {children}; +}; + +export default ErrorBoundaryWrapper; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 7de2e92ab..493e302e0 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -27,6 +27,7 @@ import { WorkspaceProvider } from "@app/context"; import { AppLayout } from "@app/layouts"; +import ErrorBoundaryWrapper from "@app/layouts/AppLayout/ErrorBoundary"; import { queryClient } from "@app/reactQuery"; import "nprogress/nprogress.css"; @@ -85,46 +86,50 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element => !Component.requireAuth ) { return ( - - - - - - - - - - + + + + + + + + + + + + ); } const Layout = Component?.layout || AppLayout; return ( - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/frontend/src/pages/integrations/aws-secret-manager/create.tsx b/frontend/src/pages/integrations/aws-secret-manager/create.tsx index 10bf190ed..d18379789 100644 --- a/frontend/src/pages/integrations/aws-secret-manager/create.tsx +++ b/frontend/src/pages/integrations/aws-secret-manager/create.tsx @@ -104,6 +104,7 @@ export default function AWSSecretManagerCreateIntegrationPage() { const [tagKey, setTagKey] = useState(""); const [tagValue, setTagValue] = useState(""); const [kmsKeyId, setKmsKeyId] = useState(""); + const [secretPrefix, setSecretPrefix] = useState(""); // const [path, setPath] = useState(''); // const [pathErrorText, setPathErrorText] = useState(''); @@ -165,6 +166,7 @@ export default function AWSSecretManagerCreateIntegrationPage() { ] } : {}), + ...(secretPrefix && { secretPrefix }), ...(kmsKeyId && { kmsKeyId }), mappingBehavior: selectedMappingBehavior } @@ -325,7 +327,7 @@ export default function AWSSecretManagerCreateIntegrationPage() {
{shouldTag && ( -
+
)} + + + setSecretPrefix(e.target.value)} + placeholder="INFISICAL_" + /> + + diff --git a/frontend/src/pages/integrations/details/[integrationId].tsx b/frontend/src/pages/integrations/details/[integrationId].tsx new file mode 100644 index 000000000..f0db57472 --- /dev/null +++ b/frontend/src/pages/integrations/details/[integrationId].tsx @@ -0,0 +1,23 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { IntegrationDetailsPage } from "@app/views/IntegrationsPage/IntegrationDetailsPage"; + +export default function IntegrationsDetailsPage() { + const { t } = useTranslation(); + + return ( + <> + + Integration Details | Infisical + + + + + + + + ); +} + +IntegrationsDetailsPage.requireAuth = true; diff --git a/frontend/src/pages/project/[id]/audit-logs/index.tsx b/frontend/src/pages/org/[id]/audit-logs/index.tsx similarity index 59% rename from frontend/src/pages/project/[id]/audit-logs/index.tsx rename to frontend/src/pages/org/[id]/audit-logs/index.tsx index 6d09255f4..346edf863 100644 --- a/frontend/src/pages/project/[id]/audit-logs/index.tsx +++ b/frontend/src/pages/org/[id]/audit-logs/index.tsx @@ -1,15 +1,12 @@ -import { useTranslation } from "react-i18next"; import Head from "next/head"; -import { AuditLogsPage } from "@app/views/Project/AuditLogsPage"; +import { AuditLogsPage } from "@app/views/Org/AuditLogsPage"; const Logs = () => { - const { t } = useTranslation(); - return (
- {t("common.head-title", { title: t("settings.project.title") })} + Infisical | Audit Logs diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/IntegrationDetailsPage.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/IntegrationDetailsPage.tsx new file mode 100644 index 000000000..6fe2a1562 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/IntegrationDetailsPage.tsx @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useRouter } from "next/router"; +import { faChevronLeft, faEllipsis, faRefresh, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { integrationSlugNameMapping } from "public/data/frequentConstants"; +import { twMerge } from "tailwind-merge"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useUser, + useWorkspace +} from "@app/context"; +import { useGetIntegration } from "@app/hooks/api"; +import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; + +import { IntegrationAuditLogsSection } from "./components/IntegrationAuditLogsSection"; +import { IntegrationConnectionSection } from "./components/IntegrationConnectionSection"; +import { IntegrationDetailsSection } from "./components/IntegrationDetailsSection"; +import { IntegrationSettingsSection } from "./components/IntegrationSettingsSection"; + +export const IntegrationDetailsPage = () => { + const router = useRouter(); + const integrationId = router.query.integrationId as string; + + const { data: integration } = useGetIntegration(integrationId, { + refetchInterval: 4000 + }); + + const projectId = useWorkspace().currentWorkspace?.id; + const { mutateAsync: syncIntegration } = useSyncIntegration(); + const { currentOrg } = useOrganization(); + + return integration ? ( +
+
+ +
+

+ {integrationSlugNameMapping[integration.integration]} Integration +

+ + +
+ + + +
+
+ + { + await syncIntegration({ + id: integration.id, + lastUsed: integration.lastUsed!, + workspaceId: projectId! + }); + }} + > +
+ + Manually Sync +
+
+ + {(isAllowed) => ( + {}} + disabled={!isAllowed} + > +
+ + Delete Integration +
+
+ )} +
+
+
+
+ +
+
+ + +
+
+ + +
+
+
+
+ ) : null; +}; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationAuditLogsSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationAuditLogsSection.tsx new file mode 100644 index 000000000..e7aa5b2f5 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationAuditLogsSection.tsx @@ -0,0 +1,78 @@ +import Link from "next/link"; + +import { EmptyState } from "@app/components/v2"; +import { useSubscription } from "@app/context"; +import { EventType } from "@app/hooks/api/auditLogs/enums"; +import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; +import { LogsSection } from "@app/views/Org/AuditLogsPage/components"; + +// Add more events if needed +const INTEGRATION_EVENTS = [EventType.INTEGRATION_SYNCED]; + +type Props = { + integration: TIntegrationWithEnv; + orgId: string; +}; + +export const IntegrationAuditLogsSection = ({ integration, orgId }: Props) => { + const { subscription, isLoading } = useSubscription(); + + const auditLogsRetentionDays = subscription?.auditLogsRetentionDays ?? 30; + + // eslint-disable-next-line no-nested-ternary + return subscription?.auditLogs ? ( +
+
+

Integration Logs

+

+ Displaying audit logs from the last {auditLogsRetentionDays} days +

+
+ +
+ ) : !isLoading ? ( +
+
+

Integration Logs

+
+ +

+ Please{" "} + + + upgrade your subscription + + {" "} + to view integration logs +

+
+ } + /> +
+ ) : null; +}; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx new file mode 100644 index 000000000..7aa862593 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx @@ -0,0 +1,194 @@ +import { integrationSlugNameMapping } from "public/data/frequentConstants"; + +import { FormLabel } from "@app/components/v2"; +import { IntegrationMappingBehavior, TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; + +type Props = { + integration: TIntegrationWithEnv; +}; + +export const IntegrationConnectionSection = ({ integration }: Props) => { + const specifcQoveryDetails = () => { + if (integration.integration !== "qovery") return null; + + return ( +
+
+ +
{integration?.owner || "-"}
+
+
+ +
{integration?.targetService || "-"}
+
+
+ +
{integration?.targetEnvironment || "-"}
+
+
+ ); + }; + + const isNotAwsManagerOneToOneDetails = () => { + const isAwsSecretManagerOneToOne = + integration.integration === "aws-secret-manager" && + integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE; + + if (isAwsSecretManagerOneToOne) { + return null; + } + + const formLabel = () => { + switch (integration.integration) { + case "qovery": + return integration.scope; + case "circleci": + case "terraform-cloud": + return "Project"; + case "aws-secret-manager": + return "Secret"; + case "aws-parameter-store": + case "rundeck": + return "Path"; + case "github": + if (["github-env", "github-repo"].includes(integration.scope!)) { + return "Repository"; + } + return "Organization"; + + default: + return "App"; + } + }; + + const contents = () => { + switch (integration.integration) { + case "hashicorp-vault": + return `${integration.app} - path: ${integration.path}`; + case "github": + if (integration.scope === "github-org") { + return `${integration.owner}`; + } + return `${integration.owner}/${integration.app}`; + + case "aws-parameter-store": + case "rundeck": + return `${integration.path}`; + + default: + return `${integration.app}`; + } + }; + + return ( +
+ +
{contents()}
+
+ ); + }; + + const targetEnvironmentDetails = () => { + if ( + ["vercel", "netlify", "railway", "gitlab", "teamcity", "bitbucket"].includes( + integration.integration + ) || + (integration.integration === "github" && integration.scope === "github-env") + ) { + return ( +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ ); + } + + return null; + }; + + const generalIntegrationSpecificDetails = () => { + if (integration.integration === "checkly" && integration.targetService) { + return ( +
+ +
{integration.targetService}
+
+ ); + } + + if (integration.integration === "circleci" && integration.owner) { + return ( +
+ +
{integration.owner}
+
+ ); + } + + if (integration.integration === "terraform-cloud" && integration.targetService) { + return ( +
+ +
{integration.targetService}
+
+ ); + } + + if (integration.integration === "checkly" || integration.integration === "github") { + return ( +
+ +
+ {integration?.metadata?.secretSuffix || "-"} +
+
+ ); + } + + return null; + }; + + return ( +
+
+

Connection

+
+ +
+ + +
+
+ +
{integration.environment.name}
+
+
+ +
{integration.secretPath}
+
+
+ + +
+ +
+ {integrationSlugNameMapping[integration.integration]} +
+ + {specifcQoveryDetails()} + {isNotAwsManagerOneToOneDetails()} + {targetEnvironmentDetails()} + {generalIntegrationSpecificDetails()} +
+
+
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationDetailsSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationDetailsSection.tsx new file mode 100644 index 000000000..d2ffeb906 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationDetailsSection.tsx @@ -0,0 +1,69 @@ +import { faCalendarCheck, faCheckCircle, faCircleXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { integrationSlugNameMapping } from "public/data/frequentConstants"; +import { twMerge } from "tailwind-merge"; + +import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; + +type Props = { + integration: TIntegrationWithEnv; +}; + +export const IntegrationDetailsSection = ({ integration }: Props) => { + return ( +
+
+
+

Integration Details

+
+
+
+
+

Name

+

+ {integrationSlugNameMapping[integration.integration]} +

+
+
+

Sync Status

+
+

+ {integration.isSynced ? "Synced" : "Not Synced"} +

+ +
+
+ {integration.lastUsed && ( +
+

Latest Successful Sync

+
+ {format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")} + +
+
+ )} + +
+ {!integration.isSynced && integration.syncMessage && ( + <> +

Latest Sync Error

+

{integration.syncMessage}

+ + )} +
+
+
+
+
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx new file mode 100644 index 000000000..50c638b66 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationSettingsSection.tsx @@ -0,0 +1,89 @@ +import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; + +type Props = { + integration: TIntegrationWithEnv; +}; + +type Metadata = NonNullable; +type MetadataKey = keyof Metadata; +type MetadataValue = Metadata[K]; + +const metadataMappings: Record, string> = { + githubVisibility: "Github Visibility", + githubVisibilityRepoIds: "Github Visibility Repo Ids", + shouldAutoRedeploy: "Auto Redeploy Target Application When Secrets Change", + secretAWSTag: "Tags For Secrets Stored In AWS", + kmsKeyId: "AWS KMS Key ID", + secretSuffix: "Secret Suffix", + secretPrefix: "Secret Prefix", + syncBehavior: "Secrets Sync behavior", + mappingBehavior: "Secrets Mapping Behavior", + scope: "Scope", + org: "Organization", + project: "Project", + environment: "Environment", + shouldDisableDelete: "AWS Secret Deletion Disabled", + shouldMaskSecrets: "GitLab Secrets Masking Enabled", + shouldProtectSecrets: "GitLab Secret Protection Enabled", + shouldEnableDelete: "GitHub Secret Deletion Enabled" +} as const; + +export const IntegrationSettingsSection = ({ integration }: Props) => { + const renderValue = (key: K, value: MetadataValue) => { + if (!value) return null; + + // If it's a boolean, we render a generic "Yes" or "No" response. + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + + // When the value is an object or array, or array of objects, we need to handle some special cases. + if (typeof value === "object") { + if (key === "secretAWSTag") { + return (value as MetadataValue<"secretAWSTag">)!.map(({ key: tagKey, value: tagValue }) => ( +

+ {tagKey}={tagValue} +

+ )); + } + + if (key === "githubVisibilityRepoIds") { + return value.join(", "); + } + } + + if (typeof value === "string") { + return value.length ? value : "N/A"; + } + + if (typeof value === "number") { + return value; + } + + return null; + }; + + if (!integration.metadata || Object.keys(integration.metadata).length === 0) { + return null; + } + + // eslint-disable-next-line no-nested-ternary + return ( +
+
+

Integration Settings

+
+
+ {integration.metadata && + Object.entries(integration.metadata).map(([key, value]) => ( +
+

+ {metadataMappings[key as keyof typeof metadataMappings]} +

+

{renderValue(key as MetadataKey, value)}

+
+ ))} +
+
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/index.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/index.tsx new file mode 100644 index 000000000..145a6e2e0 --- /dev/null +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/index.tsx @@ -0,0 +1 @@ +export { IntegrationDetailsPage } from "./IntegrationDetailsPage"; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx index 8558eb36a..29901e35a 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/ConfiguredIntegrationItem.tsx @@ -1,6 +1,10 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import { useRouter } from "next/router"; import { faArrowRight, faCalendarCheck, + faEllipsis, faRefresh, faWarning, faXmark @@ -10,7 +14,7 @@ import { format } from "date-fns"; import { integrationSlugNameMapping } from "public/data/frequentConstants"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { Button, FormLabel, IconButton, Tag, Tooltip } from "@app/components/v2"; +import { Badge, FormLabel, IconButton, Tooltip } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types"; import { TIntegration } from "@app/hooks/api/types"; @@ -28,9 +32,12 @@ export const ConfiguredIntegrationItem = ({ onRemoveIntegration, onManualSyncIntegration }: IProps) => { + const router = useRouter(); + return (
router.push(`/integrations/details/${integration.id}`)} key={`integration-${integration?.id.toString()}`} >
@@ -168,9 +175,9 @@ export const ConfiguredIntegrationItem = ({
)}
-
+
{integration.isSynced != null && integration.lastUsed != null && ( - +
-
Last sync
+
Last successful sync
{format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")} @@ -195,43 +202,62 @@ export const ConfiguredIntegrationItem = ({
} > -
+
{integration.isSynced ? "Synced" : "Not synced"}
{!integration.isSynced && }
- + )} -
+
- + + -
- - {(isAllowed: boolean) => ( -
+ + {(isAllowed: boolean) => ( onRemoveIntegration()} + onClick={(e) => { + e.stopPropagation(); + onRemoveIntegration(); + }} ariaLabel="delete" isDisabled={!isAllowed} colorSchema="danger" variant="star" + className="max-w-[2.5rem] border-none bg-mineshaft-500" > - + -
- )} -
+ )} + + + + + + + +
); diff --git a/frontend/src/views/Org/AuditLogsPage/AuditLogsPage.tsx b/frontend/src/views/Org/AuditLogsPage/AuditLogsPage.tsx new file mode 100644 index 000000000..2b6ec6744 --- /dev/null +++ b/frontend/src/views/Org/AuditLogsPage/AuditLogsPage.tsx @@ -0,0 +1,21 @@ +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { withPermission } from "@app/hoc"; + +import { LogsSection } from "./components"; + +export const AuditLogsPage = withPermission( + () => { + return ( +
+
+
+

Audit Logs

+
+
+ +
+
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.AuditLogs } +); diff --git a/frontend/src/views/Org/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/views/Org/AuditLogsPage/components/LogsFilter.tsx new file mode 100644 index 000000000..9ea92f9a9 --- /dev/null +++ b/frontend/src/views/Org/AuditLogsPage/components/LogsFilter.tsx @@ -0,0 +1,338 @@ +/* eslint-disable no-nested-ternary */ +import { useState } from "react"; +import { Control, Controller, UseFormReset, UseFormWatch } from "react-hook-form"; +import { + faCheckCircle, + faChevronDown, + faFilterCircleXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { + Button, + DatePicker, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + FormControl, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useGetAuditLogActorFilterOpts } from "@app/hooks/api"; +import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants"; +import { ActorType, EventType } from "@app/hooks/api/auditLogs/enums"; +import { Actor } from "@app/hooks/api/auditLogs/types"; + +import { AuditLogFilterFormData } from "./types"; + +const eventTypes = Object.entries(eventToNameMap).map(([value, label]) => ({ label, value })); +const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label]) => ({ + label, + value +})); + +type Props = { + presets?: { + actorId?: string; + eventType?: EventType[]; + }; + className?: string; + isOrgAuditLogs?: boolean; + control: Control; + reset: UseFormReset; + watch: UseFormWatch; +}; + +export const LogsFilter = ({ + presets, + isOrgAuditLogs, + className, + control, + reset, + watch +}: Props) => { + const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); + + const { currentWorkspace, workspaces } = useWorkspace(); + const { data, isLoading } = useGetAuditLogActorFilterOpts(currentWorkspace?.id ?? ""); + + const renderActorSelectItem = (actor: Actor) => { + switch (actor.type) { + case ActorType.USER: + return ( + + {actor.metadata.email} + + ); + case ActorType.SERVICE: + return ( + + {actor.metadata.name} + + ); + case ActorType.IDENTITY: + return ( + + {actor.metadata.name} + + ); + default: + return ( + + N/A + + ); + } + }; + + const selectedEventTypes = watch("eventType") as EventType[] | undefined; + + return ( +
+
+ ( + + + +
+ {selectedEventTypes?.length === 1 + ? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0]) + ?.label + : selectedEventTypes?.length === 0 + ? "All events" + : `${selectedEventTypes?.length} events selected`} + +
+
+ +
+ {eventTypes && eventTypes.length > 0 ? ( + eventTypes.map((eventType) => { + const isSelected = selectedEventTypes?.includes( + eventType.value as EventType + ); + + return ( + eventTypes.length > 1 && event.preventDefault()} + onClick={() => { + if (selectedEventTypes?.includes(eventType.value as EventType)) { + field.onChange( + selectedEventTypes?.filter((e: string) => e !== eventType.value) + ); + } else { + field.onChange([...(selectedEventTypes || []), eventType.value]); + } + }} + key={`event-type-${eventType.value}`} + icon={ + isSelected ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {eventType.label} + + ); + }) + ) : ( +
+ )} +
+ + + + )} + /> + + {!isLoading && data && data.length > 0 && !presets?.actorId && ( + ( + + + + )} + /> + )} + ( + + + + )} + /> + + {isOrgAuditLogs && workspaces.length > 0 && ( + ( + + + + )} + /> + )} + { + return ( + + { + onChange(date); + setIsStartDatePickerOpen(false); + }} + popUpProps={{ + open: isStartDatePickerOpen, + onOpenChange: setIsStartDatePickerOpen + }} + popUpContentProps={{}} + /> + + ); + }} + /> + { + return ( + + { + pickedDate?.setHours(23, 59, 59, 999); // we choose the end of today not the start of it (going off of aws cloud watch) + onChange(pickedDate); + setIsEndDatePickerOpen(false); + }} + popUpProps={{ + open: isEndDatePickerOpen, + onOpenChange: setIsEndDatePickerOpen + }} + popUpContentProps={{}} + /> + + ); + }} + /> +
+ +
+ ); +}; diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx b/frontend/src/views/Org/AuditLogsPage/components/LogsSection.tsx similarity index 57% rename from frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx rename to frontend/src/views/Org/AuditLogsPage/components/LogsSection.tsx index aeb12468d..a184b0f5c 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx +++ b/frontend/src/views/Org/AuditLogsPage/components/LogsSection.tsx @@ -5,24 +5,38 @@ import { yupResolver } from "@hookform/resolvers/yup"; import { UpgradePlanModal } from "@app/components/v2"; import { useSubscription } from "@app/context"; -import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; +import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; import { usePopUp } from "@app/hooks/usePopUp"; import { LogsFilter } from "./LogsFilter"; -import { LogsTable } from "./LogsTable"; +import { LogsTable, TAuditLogTableHeader } from "./LogsTable"; import { AuditLogFilterFormData, auditLogFilterFormSchema } from "./types"; type Props = { - presetActor?: string; + presets?: { + actorId?: string; + eventType?: EventType[]; + actorType?: ActorType; + startDate?: Date; + endDate?: Date; + eventMetadata?: Record; + }; + showFilters?: boolean; filterClassName?: string; isOrgAuditLogs?: boolean; + showActorColumn?: boolean; + remappedHeaders?: Partial>; + refetchInterval?: number; }; export const LogsSection = ({ - presetActor, + presets, filterClassName, + remappedHeaders, isOrgAuditLogs, + showActorColumn, + refetchInterval, showFilters }: Props) => { const { subscription } = useSubscription(); @@ -33,11 +47,13 @@ export const LogsSection = ({ const { control, reset, watch } = useForm({ resolver: yupResolver(auditLogFilterFormSchema), defaultValues: { - actor: presetActor, + projectId: undefined, + actor: presets?.actorId, + eventType: presets?.eventType || [], page: 1, perPage: 10, - startDate: new Date(new Date().setDate(new Date().getDate() - 1)), // day before today - endDate: new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today + startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)), // day before today + endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today } }); @@ -47,9 +63,10 @@ export const LogsSection = ({ } }, [subscription]); - const eventType = watch("eventType") as EventType | undefined; + const eventType = watch("eventType") as EventType[] | undefined; const userAgentType = watch("userAgentType") as UserAgentType | undefined; const actor = watch("actor"); + const projectId = watch("projectId"); const startDate = watch("startDate"); const endDate = watch("endDate"); @@ -58,20 +75,30 @@ export const LogsSection = ({
{showFilters && ( )} >; + refetchInterval?: number; }; const AUDIT_LOG_LIMIT = 15; +const TABLE_HEADERS = ["Timestamp", "Event", "Project", "Actor", "Source", "Metadata"] as const; +export type TAuditLogTableHeader = (typeof TABLE_HEADERS)[number]; + export const LogsTable = ({ - eventType, - userAgentType, showActorColumn, - actor, - startDate, - endDate, - isOrgAuditLogs + isOrgAuditLogs, + filter, + remappedHeaders, + refetchInterval }: Props) => { const { currentWorkspace } = useWorkspace(); + // Determine the project ID for filtering + const filterProjectId = + // Use the projectId from the filter if it exists + filter?.projectId ?? + // Otherwise, if we're not looking at org-wide audit logs + (!isOrgAuditLogs + ? // Use the current workspace ID (or an empty string if that's null) + currentWorkspace?.id ?? "" + : // For org-wide audit logs, use null (no specific project filter) + null); + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useGetAuditLogs( { - eventType, - userAgentType, - actor, - startDate, - endDate, + ...filter, limit: AUDIT_LOG_LIMIT }, - !isOrgAuditLogs ? currentWorkspace?.id ?? "" : null + filterProjectId, + { + refetchInterval + } ); const isEmpty = !isLoading && !data?.pages?.[0].length; @@ -62,18 +71,24 @@ export const LogsTable = ({ - - - {isOrgAuditLogs && } - {showActorColumn && } - - + {TABLE_HEADERS.map((header, idx) => { + if ( + (header === "Project" && !isOrgAuditLogs) || + (header === "Actor" && !showActorColumn) + ) { + return null; + } + + return ( + + ); + })} {!isLoading && data?.pages?.map((group, i) => ( - + {group.map((auditLog) => ( { + const metadataKeys = Object.keys(event.metadata); + switch (event.type) { case EventType.GET_SECRETS: return ( @@ -461,7 +463,62 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop

{`Secret Request Channels: ${event.metadata.secretRequestChannels}`}

); + + case EventType.INTEGRATION_SYNCED: + return ( +
+ ); + + case EventType.GET_WORKSPACE_KEY: + return ( + + ); + + case EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH: + case EventType.ADD_IDENTITY_UNIVERSAL_AUTH: + case EventType.UPDATE_IDENTITY_UNIVERSAL_AUTH: + case EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS: + return ( + + ); + + case EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET: + case EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET: + return ( + + ); + + // ? If for some reason non the above events are matched, we will display the first 3 metadata items in the metadata object. default: + if (metadataKeys.length) { + const maxMetadataLength = metadataKeys.length > 3 ? 3 : metadataKeys.length; + return ( + + ); + } return - - - {isOrgAuditLogs && } - {showActorColumn && renderActor(auditLog.actor)} + const renderSource = () => { + const { event, actor } = auditLog; + + if (event.type === EventType.INTEGRATION_SYNCED) { + if (actor.type === ActorType.USER) { + return ( + + ); + } + + // Platform / automatic syncs + return ( + + ); + } + + return ( + ); + }; + + return ( + + + + {isOrgAuditLogs && } + {showActorColumn && renderActor(auditLog.actor)} + {renderSource()} {renderMetadata(auditLog.event)} ); diff --git a/frontend/src/views/Project/AuditLogsPage/components/index.tsx b/frontend/src/views/Org/AuditLogsPage/components/index.tsx similarity index 100% rename from frontend/src/views/Project/AuditLogsPage/components/index.tsx rename to frontend/src/views/Org/AuditLogsPage/components/index.tsx diff --git a/frontend/src/views/Project/AuditLogsPage/components/types.tsx b/frontend/src/views/Org/AuditLogsPage/components/types.tsx similarity index 80% rename from frontend/src/views/Project/AuditLogsPage/components/types.tsx rename to frontend/src/views/Org/AuditLogsPage/components/types.tsx index 73d0aef47..12afd0779 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/types.tsx +++ b/frontend/src/views/Org/AuditLogsPage/components/types.tsx @@ -4,7 +4,9 @@ import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; export const auditLogFilterFormSchema = yup .object({ - eventType: yup.string().oneOf(Object.values(EventType), "Invalid event type"), + eventMetadata: yup.object({}).optional(), + projectId: yup.string().optional(), + eventType: yup.array(yup.string().oneOf(Object.values(EventType), "Invalid event type")), actor: yup.string(), userAgentType: yup.string().oneOf(Object.values(UserAgentType), "Invalid user agent type"), startDate: yup.date(), diff --git a/frontend/src/views/Project/AuditLogsPage/index.tsx b/frontend/src/views/Org/AuditLogsPage/index.tsx similarity index 100% rename from frontend/src/views/Project/AuditLogsPage/index.tsx rename to frontend/src/views/Org/AuditLogsPage/index.tsx diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx index d7134dfee..6531fdc0f 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx @@ -42,6 +42,8 @@ export const IdentitySection = withPermission( ? subscription.identitiesUsed < subscription.identityLimit : true; + const isEnterprise = subscription?.slug === "enterprise" + const onDeleteIdentitySubmit = async (identityId: string) => { try { await deleteMutateAsync({ @@ -93,7 +95,7 @@ export const IdentitySection = withPermission( type="submit" leftIcon={} onClick={() => { - if (!isMoreIdentitiesAllowed) { + if (!isMoreIdentitiesAllowed && !isEnterprise) { handlePopUpOpen("upgradePlan", { description: "You can add more identities if you upgrade your Infisical plan." }); diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index 8b55fab23..180bd5e40 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -50,7 +50,7 @@ type Props = { ) => void; }; -const INIT_PER_PAGE = 10; +const INIT_PER_PAGE = 20; export const IdentityTable = ({ handlePopUpOpen }: Props) => { const router = useRouter(); @@ -277,7 +277,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { })}
TimestampEventProjectActorSourceMetadata{remappedHeaders?.[header] || header}
+ + +

{event.metadata.isSynced ? "Successful" : "Failed"}

+
+
+
+

{`Key ID: ${event.metadata.keyId}`}

+
+

{`Identity ID: ${event.metadata.identityId}`}

+
+

{`Identity ID: ${event.metadata.identityId}`}

+

{`Client Secret ID: ${event.metadata.clientSecretId}`}

+
+ {Object.entries(event.metadata) + .slice(0, maxMetadataLength) + .map(([key, value]) => { + return

{`${key}: ${value}`}

; + })} +
; } }; @@ -484,16 +541,41 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop return formattedDate; }; - return ( -
{formatDate(auditLog.createdAt)}{`${eventToNameMap[auditLog.event.type]}`}{auditLog.project.name} +

Manually triggered by {actor.metadata.email}

+
+

Automatically synced by Infisical

+

{userAgentTTypeoNameMap[auditLog.userAgentType]}

{auditLog.ipAddress}

{formatDate(auditLog.createdAt)}{`${eventToNameMap[auditLog.event.type]}`}{auditLog?.project?.name ?? "N/A"}
- {!isLoading && data && data.totalCount > INIT_PER_PAGE && ( + {!isLoading && data && data.totalCount > 0 && ( { ? subscription.identitiesUsed < subscription.identityLimit : true; + const isEnterprise = subscription?.slug === "enterprise"; + const handleAddMemberModal = () => { if (currentOrg?.authEnforced) { createNotification({ @@ -60,7 +62,7 @@ export const OrgMembersSection = () => { return; } - if (!isMoreUsersAllowed || !isMoreIdentitiesAllowed) { + if ((!isMoreUsersAllowed || !isMoreIdentitiesAllowed) && !isEnterprise) { handlePopUpOpen("upgradePlan", { description: "You can add more members if you upgrade your Infisical plan." }); diff --git a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts index 13cf2316b..027ac1bfe 100644 --- a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts @@ -32,6 +32,8 @@ export const formSchema = z.object({ create: z.boolean().optional() }) .optional(), + + "audit-logs": generalPermissionSchema, member: generalPermissionSchema, groups: generalPermissionSchema, role: generalPermissionSchema, diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index f4b237cfe..54bb903c6 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -41,6 +41,10 @@ const SIMPLE_PERMISSION_OPTIONS = [ title: "Incident Contacts", formName: "incident-contact" }, + { + title: "Audit Logs", + formName: "audit-logs" + }, { title: "Organization Profile", formName: "settings" diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx index dcfd0553c..a77e5eb4f 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx @@ -7,7 +7,7 @@ import { EmptyState, IconButton, Tooltip } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; import { withPermission } from "@app/hoc"; import { OrgUser } from "@app/hooks/api/types"; -import { LogsSection } from "@app/views/Project/AuditLogsPage/components"; +import { LogsSection } from "@app/views/Org/AuditLogsPage/components"; type Props = { orgMembership: OrgUser; @@ -42,7 +42,9 @@ export const UserAuditLogsSection = withPermission(
diff --git a/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx b/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx deleted file mode 100644 index e9b8504a2..000000000 --- a/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; -import { withProjectPermission } from "@app/hoc"; - -import { LogsSection } from "./components"; - -export const AuditLogsPage = withProjectPermission( - () => { - return ( -
-
-
-

Audit Logs

-
-
- -
-
- ); - }, - { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs } -); diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx deleted file mode 100644 index a9f10875c..000000000 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useState } from "react"; -import { Control, Controller, UseFormReset } from "react-hook-form"; -import { faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { twMerge } from "tailwind-merge"; - -import { Button, DatePicker, FormControl, Select, SelectItem } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { useGetAuditLogActorFilterOpts } from "@app/hooks/api"; -import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants"; -import { ActorType } from "@app/hooks/api/auditLogs/enums"; -import { Actor } from "@app/hooks/api/auditLogs/types"; - -import { AuditLogFilterFormData } from "./types"; - -const eventTypes = Object.entries(eventToNameMap).map(([value, label]) => ({ label, value })); -const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label]) => ({ - label, - value -})); - -type Props = { - presetActor?: string; - className?: string; - control: Control; - reset: UseFormReset; -}; - -export const LogsFilter = ({ presetActor, className, control, reset }: Props) => { - const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); - const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); - - const { currentWorkspace } = useWorkspace(); - const { data, isLoading } = useGetAuditLogActorFilterOpts(currentWorkspace?.id ?? ""); - - const renderActorSelectItem = (actor: Actor) => { - switch (actor.type) { - case ActorType.USER: - return ( - - {actor.metadata.email} - - ); - case ActorType.SERVICE: - return ( - - {actor.metadata.name} - - ); - case ActorType.IDENTITY: - return ( - - {actor.metadata.name} - - ); - default: - return ( - - N/A - - ); - } - }; - - return ( -
-
- ( - - - - )} - /> - {!isLoading && data && data.length > 0 && !presetActor && ( - ( - - - - )} - /> - )} - ( - - - - )} - /> - { - return ( - - { - onChange(date); - setIsStartDatePickerOpen(false); - }} - popUpProps={{ - open: isStartDatePickerOpen, - onOpenChange: setIsStartDatePickerOpen - }} - popUpContentProps={{}} - /> - - ); - }} - /> - { - return ( - - { - pickedDate?.setHours(23, 59, 59, 999); // we choose the end of today not the start of it (going off of aws cloud watch) - onChange(pickedDate); - setIsEndDatePickerOpen(false); - }} - popUpProps={{ - open: isEndDatePickerOpen, - onOpenChange: setIsEndDatePickerOpen - }} - popUpContentProps={{}} - /> - - ); - }} - /> -
- -
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx index 500a1926a..1f8cc996d 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx @@ -56,7 +56,7 @@ import { IdentityModal } from "./components/IdentityModal"; import { IdentityRoleForm } from "./components/IdentityRoleForm"; const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; -const INIT_PER_PAGE = 10; +const INIT_PER_PAGE = 20; const formatRoleName = (role: string, customRoleName?: string) => { if (role === ProjectMembershipRole.Custom) return customRoleName; if (role === ProjectMembershipRole.Member) return "Developer"; @@ -369,7 +369,7 @@ export const IdentityTab = withProjectPermission( })} - {!isLoading && data && data.totalCount > INIT_PER_PAGE && ( + {!isLoading && data && data.totalCount > 0 && ( { const { data: identityMembershipOrgsData } = useGetIdentityMembershipOrgs({ organizationId, - limit: 20000 // TODO: this is temp to preserve functionality for bitcoindepot, will replace with combobox in separate PR + limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will replace with combobox in separate PR }); const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships; const { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({ workspaceId, - limit: 20000 // TODO: this is temp to preserve functionality for bitcoindepot, will optimize in PR referenced above + limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will optimize in PR referenced above }); const identityMemberships = identityMembershipsData?.identityMemberships; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts index e3b05a41f..8b4958ee0 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts @@ -47,7 +47,6 @@ export const formSchema = z.object({ settings: generalPermissionSchema, environments: generalPermissionSchema, tags: generalPermissionSchema, - "audit-logs": generalPermissionSchema, "ip-allowlist": generalPermissionSchema, "certificate-authorities": generalPermissionSchema, certificates: generalPermissionSchema, diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index 1de24ae71..814994f90 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -65,10 +65,6 @@ const SINGLE_PERMISSION_LIST = [ title: "Tags", formName: "tags" }, - { - title: "Audit Logs", - formName: "audit-logs" - }, { title: "IP Allowlist", formName: "ip-allowlist" diff --git a/frontend/src/views/SecretMainPage/SecretMainPage.tsx b/frontend/src/views/SecretMainPage/SecretMainPage.tsx index 9b3a04acc..aa6804511 100644 --- a/frontend/src/views/SecretMainPage/SecretMainPage.tsx +++ b/frontend/src/views/SecretMainPage/SecretMainPage.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useRouter } from "next/router"; import { subject } from "@casl/ability"; import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import NavHeader from "@app/components/navigation/NavHeader"; import { createNotification } from "@app/components/notifications"; @@ -17,29 +18,28 @@ import { } from "@app/context"; import { useDebounce, usePopUp } from "@app/hooks"; import { - useGetDynamicSecrets, useGetImportedSecretsSingleEnv, - useGetProjectFolders, - useGetProjectSecrets, useGetSecretApprovalPolicyOfABoard, - useGetSecretImports, useGetWorkspaceSnapshotList, useGetWsSnapshotCount, useGetWsTags } from "@app/hooks/api"; +import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { DynamicSecretListView } from "@app/views/SecretMainPage/components/DynamicSecretListView"; +import { FolderListView } from "@app/views/SecretMainPage/components/FolderListView"; +import { SecretImportListView } from "@app/views/SecretMainPage/components/SecretImportListView"; +import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount/SecretTableResourceCount"; import { SecretV2MigrationSection } from "../SecretOverviewPage/components/SecretV2MigrationSection"; import { ActionBar } from "./components/ActionBar"; import { CreateSecretForm } from "./components/CreateSecretForm"; -import { DynamicSecretListView } from "./components/DynamicSecretListView"; -import { FolderListView } from "./components/FolderListView"; import { PitDrawer } from "./components/PitDrawer"; import { SecretDropzone } from "./components/SecretDropzone"; -import { SecretImportListView } from "./components/SecretImportListView"; import { SecretListView } from "./components/SecretListView"; import { SnapshotView } from "./components/SnapshotView"; import { StoreProvider } from "./SecretMainPage.store"; -import { Filter, SortDir } from "./SecretMainPage.types"; +import { Filter, RowType } from "./SecretMainPage.types"; const LOADER_TEXT = [ "Retrieving your encrypted secrets...", @@ -47,7 +47,7 @@ const LOADER_TEXT = [ "Getting secret import links..." ]; -const INIT_PER_PAGE = 10; +const INIT_PER_PAGE = 20; export const SecretMainPage = () => { const { t } = useTranslation(); const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace(); @@ -55,12 +55,8 @@ export const SecretMainPage = () => { const { permission } = useProjectPermission(); const [isVisible, setIsVisible] = useState(false); - const [sortDir, setSortDir] = useState(SortDir.ASC); - const [filter, setFilter] = useState({ - tags: {}, - searchFilter: (router.query.searchFilter as string) || "" - }); - const debouncedSearchFilter = useDebounce(filter.searchFilter); + const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC); + const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(INIT_PER_PAGE); const paginationOffset = (page - 1) * perPage; @@ -83,6 +79,18 @@ export const SecretMainPage = () => { ProjectPermissionSub.SecretRollback ); + const [filter, setFilter] = useState({ + tags: {}, + searchFilter: (router.query.searchFilter as string) || "", + include: { + [RowType.Folder]: true, + [RowType.Import]: canReadSecret, + [RowType.DynamicSecret]: canReadSecret, + [RowType.Secret]: canReadSecret + } + }); + const debouncedSearchFilter = useDebounce(filter.searchFilter); + useEffect(() => { if ( !isWorkspaceLoading && @@ -91,43 +99,43 @@ export const SecretMainPage = () => { ) { router.push(`/project/${workspaceId}/secrets/overview`); createNotification({ - text: "No envronment found with given slug", + text: "No environment found with given slug", type: "error" }); } }, [isWorkspaceLoading, currentWorkspace, environment, router.isReady]); - // fetch secrets - const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ - environment, - workspaceId, - secretPath, - options: { - enabled: canReadSecret - } - }); - - // fetch folders - const { data: folders, isLoading: isFoldersLoading } = useGetProjectFolders({ - projectId: workspaceId, - environment, - path: secretPath - }); - - // fetch secret imports const { - data: secretImports, - isLoading: isSecretImportsLoading, - isFetching: isSecretImportsFetching - } = useGetSecretImports({ - projectId: workspaceId, + data, + isLoading: isDetailsLoading, + isFetching: isDetailsFetching + } = useGetProjectSecretsDetails({ environment, - path: secretPath, - options: { - enabled: canReadSecret - } + projectId: workspaceId, + secretPath, + offset: paginationOffset, + limit: perPage, + search: debouncedSearchFilter, + orderDirection, + includeImports: canReadSecret && filter.include.import, + includeFolders: filter.include.folder, + includeDynamicSecrets: canReadSecret && filter.include.dynamic, + includeSecrets: canReadSecret && filter.include.secret, + tags: filter.tags }); + const { + imports, + folders, + dynamicSecrets, + secrets, + totalImportCount = 0, + totalFolderCount = 0, + totalDynamicSecretCount = 0, + totalSecretCount = 0, + totalCount = 0 + } = data ?? {}; + // fetch imported secrets to show user the overriden ones const { data: importedSecrets } = useGetImportedSecretsSingleEnv({ projectId: workspaceId, @@ -138,13 +146,7 @@ export const SecretMainPage = () => { } }); - const { data: dynamicSecrets, isLoading: isDynamicSecretLoading } = useGetDynamicSecrets({ - projectSlug, - environmentSlug: environment, - path: secretPath - }); - - // fech tags + // fetch tags const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : ""); const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ @@ -174,12 +176,14 @@ export const SecretMainPage = () => { isPaused: !canDoReadRollback }); - const isNotEmtpy = Boolean( - secrets?.length || folders?.length || secretImports?.length || dynamicSecrets?.length + const isNotEmpty = Boolean( + secrets?.length || folders?.length || imports?.length || dynamicSecrets?.length ); const handleSortToggle = () => - setSortDir((state) => (state === SortDir.ASC ? SortDir.DESC : SortDir.ASC)); + setOrderDirection((state) => + state === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC + ); const handleEnvChange = (slug: string) => { const query: Record = { ...router.query, env: slug }; @@ -191,17 +195,31 @@ export const SecretMainPage = () => { }; const handleTagToggle = useCallback( - (tagId: string) => + (tagSlug: string) => setFilter((state) => { - const isTagPresent = Boolean(state.tags?.[tagId]); + const isTagPresent = Boolean(state.tags?.[tagSlug]); const newTagFilter = { ...state.tags }; - if (isTagPresent) delete newTagFilter[tagId]; - else newTagFilter[tagId] = true; + if (isTagPresent) delete newTagFilter[tagSlug]; + else newTagFilter[tagSlug] = true; return { ...state, tags: newTagFilter }; }), [] ); + const handleToggleRowType = useCallback( + (rowType: RowType) => + setFilter((state) => { + return { + ...state, + include: { + ...state.include, + [rowType]: !state.include[rowType] + } + }; + }), + [] + ); + const handleSearchChange = useCallback( (searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })), [] @@ -219,132 +237,32 @@ export const SecretMainPage = () => { handlePopUpClose("snapshots"); }, []); - // loading screen when u have permission - const loadingOnAccess = - canReadSecret && - (isSecretsLoading || isSecretImportsLoading || isFoldersLoading || isDynamicSecretLoading); - - const rows = useMemo(() => { - const filteredSecrets = - secrets - ?.filter(({ key, tags: secretTags, value }) => { - const isTagFilterActive = Boolean(Object.keys(filter.tags).length); - return ( - (!isTagFilterActive || secretTags?.some(({ id }) => filter.tags?.[id])) && - (key.toUpperCase().includes(debouncedSearchFilter.toUpperCase()) || - value?.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) - ); - }) - .sort((a, b) => - sortDir === SortDir.ASC ? a.key.localeCompare(b.key) : b.key.localeCompare(a.key) - ) ?? []; - const filteredFolders = - folders - ?.filter(({ name }) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) - .sort((a, b) => - sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name) - ) ?? []; - const filteredDynamicSecrets = - dynamicSecrets - ?.filter(({ name }) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) - .sort((a, b) => - sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name) - ) ?? []; - const filteredSecretImports = - secretImports - ?.filter(({ importPath }) => - importPath.toLowerCase().includes(debouncedSearchFilter.toLowerCase()) - ) - .sort((a, b) => - sortDir === "asc" - ? a.importPath.localeCompare(b.importPath) - : b.importPath.localeCompare(a.importPath) - ) ?? []; - - const totalRows = - filteredSecretImports.length + - filteredFolders.length + - filteredDynamicSecrets.length + - filteredSecrets.length; - - const paginatedImports = filteredSecretImports.slice( - paginationOffset, - paginationOffset + perPage - ); - - let remainingRows = perPage - paginatedImports.length; - const foldersStartIndex = Math.max(0, paginationOffset - filteredSecretImports.length); - const paginatedFolders = - remainingRows > 0 - ? filteredFolders.slice(foldersStartIndex, foldersStartIndex + remainingRows) - : []; - - remainingRows -= paginatedFolders.length; - const dynamicSecretStartIndex = Math.max( - 0, - paginationOffset - filteredSecretImports.length - filteredFolders.length - ); - const paginatiedDynamicSecrets = - remainingRows > 0 - ? filteredDynamicSecrets.slice( - dynamicSecretStartIndex, - dynamicSecretStartIndex + remainingRows - ) - : []; - - remainingRows -= paginatiedDynamicSecrets.length; - const secretStartIndex = Math.max( - 0, - paginationOffset - - filteredSecretImports.length - - filteredFolders.length - - filteredDynamicSecrets.length - ); - - const paginatiedSecrets = - remainingRows > 0 - ? filteredSecrets.slice(secretStartIndex, secretStartIndex + remainingRows) - : []; - - return { - imports: paginatedImports, - folders: paginatedFolders, - secrets: paginatiedSecrets, - dynamicSecrets: paginatiedDynamicSecrets, - totalRows - }; - }, [ - sortDir, - debouncedSearchFilter, - folders, - secrets, - dynamicSecrets, - paginationOffset, - perPage, - filter.tags, - importedSecrets - ]); - useEffect(() => { // reset page if no longer valid - if (rows.totalRows < paginationOffset) setPage(1); - }, [rows.totalRows]); + if (totalCount < paginationOffset) setPage(1); + }, [totalCount]); - // loading screen when you don't have permission but as folder's is viewable need to wait for that - const loadingOnDenied = !canReadSecret && isFoldersLoading; - if (loadingOnAccess || loadingOnDenied) { + if (isDetailsLoading) { return ; } return ( -
+
+ permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: slug, + secretPath + }) + ) + )} isFolderMode secretPath={secretPath} isProjectRelated @@ -364,17 +282,22 @@ export const SecretMainPage = () => { isVisible={isVisible} filter={filter} tags={tags} - onVisiblilityToggle={handleToggleVisibility} + onVisibilityToggle={handleToggleVisibility} onSearchChange={handleSearchChange} onToggleTagFilter={handleTagToggle} snapshotCount={snapshotCount || 0} isSnapshotCountLoading={isSnapshotCountLoading} + onToggleRowType={handleToggleRowType} onClickRollbackMode={() => handlePopUpToggle("snapshots", true)} /> -
+
- {isNotEmtpy && ( -
+ {isNotEmpty && ( +
{ > Key
Value
)} - {canReadSecret && ( + {canReadSecret && imports?.length && ( )} - - {canReadSecret && ( + {folders?.length && ( + + )} + {canReadSecret && dynamicSecrets?.length && ( )} - {canReadSecret && ( + {canReadSecret && secrets?.length && ( { /> )} {!canReadSecret && folders?.length === 0 && } - {!loadingOnAccess && rows.totalRows > INIT_PER_PAGE && ( - setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} - /> - )}
+ {!isDetailsLoading && totalCount > 0 && ( + + } + className="rounded-b-md border-t border-solid border-t-mineshaft-600" + count={totalCount} + page={page} + perPage={perPage} + onChangePage={(newPage) => setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} { environment={environment} workspaceId={workspaceId} secretPath={secretPath} - isSmaller={isNotEmtpy} + isSmaller={isNotEmpty} environments={currentWorkspace?.environments} isProtectedBranch={isProtectedBranch} /> diff --git a/frontend/src/views/SecretMainPage/SecretMainPage.types.ts b/frontend/src/views/SecretMainPage/SecretMainPage.types.ts index 489e19e5c..848e8b91a 100644 --- a/frontend/src/views/SecretMainPage/SecretMainPage.types.ts +++ b/frontend/src/views/SecretMainPage/SecretMainPage.types.ts @@ -1,15 +1,14 @@ export type Filter = { tags: Record; searchFilter: string; + include: { + [key in RowType]: boolean; + }; }; -export enum SortDir { - ASC = "asc", - DESC = "desc" -} - export enum RowType { Folder = "folder", + Import = "import", DynamicSecret = "dynamic", - Secret = "Secret" + Secret = "secret" } diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index 94ac029bd..1c00f6823 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -13,7 +13,9 @@ import { faFileImport, faFilter, faFingerprint, + faFolder, faFolderPlus, + faKey, faMagnifyingGlass, faMinusSquare, faPlus, @@ -62,7 +64,7 @@ import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store"; -import { Filter } from "../../SecretMainPage.types"; +import { Filter, RowType } from "../../SecretMainPage.types"; import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; import { CreateSecretImportForm } from "./CreateSecretImportForm"; import { FolderForm } from "./FolderForm"; @@ -83,7 +85,8 @@ type Props = { isSnapshotCountLoading?: boolean; onSearchChange: (term: string) => void; onToggleTagFilter: (tagId: string) => void; - onVisiblilityToggle: () => void; + onVisibilityToggle: () => void; + onToggleRowType: (rowType: RowType) => void; onClickRollbackMode: () => void; }; @@ -100,8 +103,9 @@ export const ActionBar = ({ isSnapshotCountLoading, onSearchChange, onToggleTagFilter, - onVisiblilityToggle, - onClickRollbackMode + onVisibilityToggle, + onClickRollbackMode, + onToggleRowType }: Props) => { const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addFolder", @@ -298,7 +302,9 @@ export const ActionBar = ({ ariaLabel="Download" className={twMerge( "transition-all", - Object.keys(filter.tags).length && "border-primary/50 text-primary" + (Object.keys(filter.tags).length || + Object.values(filter.include).filter((include) => !include).length) && + "border-primary/50 text-primary" )} > @@ -306,6 +312,60 @@ export const ActionBar = ({ Filter By + { + e.preventDefault(); + onToggleRowType(RowType.Import); + }} + icon={filter?.include[RowType.Import] && } + iconPos="right" + > +
+ + Imports +
+
+ { + e.preventDefault(); + onToggleRowType(RowType.Folder); + }} + icon={filter?.include[RowType.Folder] && } + iconPos="right" + > +
+ + Folders +
+
+ { + e.preventDefault(); + onToggleRowType(RowType.DynamicSecret); + }} + icon={ + filter?.include[RowType.DynamicSecret] && + } + iconPos="right" + > +
+ + Dynamic Secrets +
+
+ { + e.preventDefault(); + onToggleRowType(RowType.Secret); + }} + icon={filter?.include[RowType.Secret] && } + iconPos="right" + > +
+ + Secrets +
+
{ evt.preventDefault(); - onToggleTagFilter(id); + onToggleTagFilter(slug); }} key={id} - icon={filter?.tags[id] && } + icon={filter?.tags[slug] && } iconPos="right" >
@@ -346,7 +406,7 @@ export const ActionBar = ({
- +
diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AzureEntraIdInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AzureEntraIdInputForm.tsx new file mode 100644 index 000000000..2fb2250a0 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AzureEntraIdInputForm.tsx @@ -0,0 +1,355 @@ +import { Controller, useForm } from "react-hook-form"; +import Link from "next/link"; +import { faArrowUpRightFromSquare, faBookOpen, faCheckCircle, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input +} from "@app/components/v2"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/v2/Dropdown/Dropdown"; +import { Tooltip } from "@app/components/v2/Tooltip"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { useGetDynamicSecretProviderData } from "@app/hooks/api/dynamicSecret/queries"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + selectedUsers: z.array(z.object({ + id: z.string().min(1), + name: z.string().min(1), + email: z.string().min(1), + })), + provider: z.object({ + tenantId: z.string().min(1), + applicationId: z.string().min(1), + clientSecret: z.string().min(1) + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: z.string().min(1).refine((val) => val.toLowerCase() === val, "Must be lowercase") +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environment: string; +}; + +export const AzureEntraIdInputForm = ({ + onCompleted, + onCancel, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting }, + watch, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema) + }); + const tenantId = watch("provider.tenantId"); + const applicationId = watch("provider.applicationId"); + const clientSecret = watch("provider.clientSecret"); + + const configurationComplete = !!(tenantId && applicationId && clientSecret); + const { data, isLoading, isError, isFetching } = useGetDynamicSecretProviderData({ tenantId, applicationId, clientSecret, enabled: !!configurationComplete }); + const loading = configurationComplete && isFetching; + const errored = configurationComplete && !isFetching && isError; + const createDynamicSecret = useCreateDynamicSecret(); + + const handleCreateDynamicSecret = async ({ name, selectedUsers, provider, maxTTL, defaultTTL }: TForm) => { + // wait till previous request is finished + if (createDynamicSecret.isLoading) return; + try { + selectedUsers.map(async (user: { id: string, name: string, email: string }) => { + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.AzureEntraId, inputs: { userId: user.id, tenantId: provider.tenantId, email: user.email, applicationId: provider.applicationId, clientSecret: provider.clientSecret } }, + maxTTL, + name: `${name}-${user.name}`, + path: secretPath, + defaultTTL, + projectSlug, + environmentSlug: environment + }); + }); + onCompleted(); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration + + +
+ + Docs + +
+
+ +
+
+
+ ( + + + + )} + + /> +
+
+
+
+ ( + + + + )} + + /> +
+
+
+
+ ( + + + + )} + + /> +
+
+
+
+ +
+ Select Users +
+
+   We create a unique dynamic secret for each user in Entra Id. +
+
+
+ ( + + + +
+ } + > +
+ + +
+ + + + {data && data.map((user) => { + const ids = value?.map((selectedUser) => selectedUser.id) + const isChecked = ids?.includes(user.id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el) => el.id !== user.id) + : [...(value || []), user] + ); + }} + key={`create-policy-members-${user.id}`} + iconPos="right" + icon={isChecked && } + > + {user.name}
{`(${user.email})`} +
+ ); + })} +
+ + + )} + /> +
+
+
+
+
+ + +
+ +
+ ); +}; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx index 90eb74012..a28544a33 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { DiRedis } from "react-icons/di"; -import { SiApachecassandra, SiElasticsearch, SiMongodb, SiRabbitmq } from "react-icons/si"; +import { SiApachecassandra, SiElasticsearch, SiMicrosoftazure, SiMongodb, SiRabbitmq } from "react-icons/si"; import { faAws } from "@fortawesome/free-brands-svg-icons"; import { faDatabase } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -11,6 +11,7 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm"; import { AwsIamInputForm } from "./AwsIamInputForm"; +import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm"; import { CassandraInputForm } from "./CassandraInputForm"; import { ElasticSearchInputForm } from "./ElasticSearchInputForm"; import { MongoAtlasInputForm } from "./MongoAtlasInputForm"; @@ -77,6 +78,11 @@ const DYNAMIC_SECRET_LIST = [ icon: , provider: DynamicSecretProviders.RabbitMq, title: "RabbitMQ" + }, + { + icon: , + provider: DynamicSecretProviders.AzureEntraId, + title: "Azure Entra ID", } ]; @@ -300,6 +306,25 @@ export const CreateDynamicSecretForm = ({ /> )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.AzureEntraId && ( + + + + ) + } diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx index 85be20fc1..c99fdda16 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx @@ -176,6 +176,24 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => { ); } + if (provider === DynamicSecretProviders.AzureEntraId) { + const { email, password } = data as { + email: string; + password: string; + }; + + return ( +
+ + +
+ ); + } + return null; }; diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/DynamicSecretListView.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/DynamicSecretListView.tsx index 6b133d945..abef7b6c3 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/DynamicSecretListView.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/DynamicSecretListView.tsx @@ -27,7 +27,6 @@ import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; -import { SortDir } from "../../SecretMainPage.types"; import { CreateDynamicSecretLease } from "./CreateDynamicSecretLease"; import { DynamicSecretLease } from "./DynamicSecretLease"; import { EditDynamicSecretForm } from "./EditDynamicSecretForm"; @@ -38,19 +37,17 @@ const formatProviderName = (type: DynamicSecretProviders) => { }; type Props = { - dynamicSecrets: TDynamicSecret[]; + dynamicSecrets?: TDynamicSecret[]; environment: string; projectSlug: string; secretPath?: string; - sortDir: SortDir; }; export const DynamicSecretListView = ({ dynamicSecrets = [], environment, projectSlug, - secretPath = "/", - sortDir = SortDir.ASC + secretPath = "/" }: Props) => { const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ "dynamicSecretLeases", @@ -59,7 +56,6 @@ export const DynamicSecretListView = ({ "deleteDynamicSecret" ] as const); - const deleteDynamicSecret = useDeleteDynamicSecret(); const handleDynamicSecretDelete = async () => { @@ -90,158 +86,148 @@ export const DynamicSecretListView = ({ return ( <> - {dynamicSecrets - .sort((a, b) => - sortDir === SortDir.ASC - ? a.name.toLowerCase().localeCompare(b.name.toLowerCase()) - : b.name.toLowerCase().localeCompare(a.name.toLowerCase()) - ) - .map((secret) => { - const isRevocking = secret.status === DynamicSecretStatus.Deleting; - return ( - handlePopUpToggle("dynamicSecretLeases", state)} + {dynamicSecrets.map((secret) => { + const isRevoking = secret.status === DynamicSecretStatus.Deleting; + return ( + handlePopUpToggle("dynamicSecretLeases", state)} + > +
{ + if (evt.key === "Enter" && !isRevoking) + handlePopUpOpen("dynamicSecretLeases", secret.id); + }} + onClick={() => { + if (!isRevoking) { + handlePopUpOpen("dynamicSecretLeases", secret.id); + } + }} > -
{ - if (evt.key === "Enter" && !isRevocking) - handlePopUpOpen("dynamicSecretLeases", secret.id); - }} - onClick={() => { - if (!isRevocking) { - handlePopUpOpen("dynamicSecretLeases", secret.id); - } - }} - > -
- -
-
- {secret.name} - - {formatProviderName(secret.type)} - - {Boolean(secret.status) && ( - - - - )} -
-
- - {secret.status === DynamicSecretStatus.FailedDeletion && ( - - - - )} -
-
- - {(isAllowed) => ( - { - evt.stopPropagation(); - handlePopUpOpen("updateDynamicSecret", secret); - }} - isDisabled={!isAllowed || isRevocking} - > - - - )} - - - {(isAllowed) => ( - { - evt.stopPropagation(); - handlePopUpOpen("deleteDynamicSecret", secret); - }} - isDisabled={!isAllowed || isRevocking} - > - - - )} - -
+
+
- - handlePopUpOpen("createDynamicSecretLease", secret)} - onClose={() => handlePopUpClose("dynamicSecretLeases")} - projectSlug={projectSlug} - key={secret.id} - dynamicSecretName={secret.name} - secretPath={secretPath} - environment={environment} - /> - - - ); - })} +
+ {secret.name} + + {formatProviderName(secret.type)} + + {Boolean(secret.status) && ( + + + + )} +
+
+ + {secret.status === DynamicSecretStatus.FailedDeletion && ( + + + + )} +
+
+ + {(isAllowed) => ( + { + evt.stopPropagation(); + handlePopUpOpen("updateDynamicSecret", secret); + }} + isDisabled={!isAllowed || isRevoking} + > + + + )} + + + {(isAllowed) => ( + { + evt.stopPropagation(); + handlePopUpOpen("deleteDynamicSecret", secret); + }} + isDisabled={!isAllowed || isRevoking} + > + + + )} + +
+
+ + handlePopUpOpen("createDynamicSecretLease", secret)} + onClose={() => handlePopUpClose("dynamicSecretLeases")} + projectSlug={projectSlug} + key={secret.id} + dynamicSecretName={secret.name} + secretPath={secretPath} + environment={environment} + /> + + + ); + })} handlePopUpToggle("createDynamicSecretLease", state)} diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAzureEntraIdForm.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAzureEntraIdForm.tsx new file mode 100644 index 000000000..31ac7b5c2 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAzureEntraIdForm.tsx @@ -0,0 +1,283 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + SecretInput, +} from "@app/components/v2"; +import { useUpdateDynamicSecret } from "@app/hooks/api"; +import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + inputs: z.object({ + email: z.string(), + userId: z.string(), + tenantId: z.string(), + applicationId: z.string(), + clientSecret: z.string() + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + newName: z + .string() + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .optional() +}); +type TForm = z.infer; + +type Props = { + onClose: () => void; + dynamicSecret: TDynamicSecret & { inputs: unknown }; + secretPath: string; + environment: string; + projectSlug: string; +}; + +export const EditDynamicSecretAzureEntraIdForm = ({ + onClose, + dynamicSecret, + secretPath, + environment, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + values: { + defaultTTL: dynamicSecret.defaultTTL, + maxTTL: dynamicSecret.maxTTL, + newName: dynamicSecret.name, + inputs: { + ...(dynamicSecret.inputs as TForm["inputs"]) + } + } + }); + + const updateDynamicSecret = useUpdateDynamicSecret(); + + const handleUpdateDynamicSecret = async ({ maxTTL, defaultTTL, newName, inputs }: TForm) => { + // wait till previous request is finished + if (updateDynamicSecret.isLoading) return; + try { + await updateDynamicSecret.mutateAsync({ + name: dynamicSecret.name, + path: secretPath, + projectSlug, + environmentSlug: environment, + data: { + maxTTL: maxTTL || undefined, + defaultTTL, + newName: newName === dynamicSecret.name ? undefined : newName, + inputs + } + }); + onClose(); + createNotification({ + type: "success", + text: "Successfully updated dynamic secret" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to update dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+
+
+ ( + + + + )} + /> +
+
+
+ + +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx index 8f95bcc94..23cb45843 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx @@ -6,6 +6,7 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm"; import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm"; +import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm"; import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm"; import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm"; import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm"; @@ -202,6 +203,24 @@ export const EditDynamicSecretForm = ({ /> )} + + {dynamicSecretDetails?.type === DynamicSecretProviders.AzureEntraId && ( + + + + )} ); }; diff --git a/frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx b/frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx index dfc670fad..e4f74cedc 100644 --- a/frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx +++ b/frontend/src/views/SecretMainPage/components/FolderListView/FolderListView.tsx @@ -11,7 +11,6 @@ import { usePopUp } from "@app/hooks"; import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api"; import { TSecretFolder } from "@app/hooks/api/secretFolders/types"; -import { SortDir } from "../../SecretMainPage.types"; import { FolderForm } from "../ActionBar/FolderForm"; type Props = { @@ -19,17 +18,13 @@ type Props = { environment: string; workspaceId: string; secretPath?: string; - sortDir: SortDir; - searchTerm?: string; }; export const FolderListView = ({ folders = [], environment, workspaceId, - searchTerm, - secretPath = "/", - sortDir = SortDir.ASC + secretPath = "/" }: Props) => { const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ "updateFolder", @@ -104,84 +99,77 @@ export const FolderListView = ({ return ( <> - {folders - .filter(({ name }) => name.toUpperCase().includes(String(searchTerm?.toUpperCase()))) - .sort((a, b) => - sortDir === SortDir.ASC - ? a.name.toLowerCase().localeCompare(b.name.toLowerCase()) - : b.name.toLowerCase().localeCompare(a.name.toLowerCase()) - ) - .map(({ name, id }) => ( -
-
- -
-
{ - if (evt.key === "Enter") handleFolderClick(name); - }} - onClick={() => handleFolderClick(name)} - > - {name} -
-
- - {(isAllowed) => ( - handlePopUpOpen("updateFolder", { id, name })} - isDisabled={!isAllowed} - > - - - )} - - - {(isAllowed) => ( - handlePopUpOpen("deleteFolder", { id, name })} - isDisabled={!isAllowed} - > - - - )} - -
+ {folders.map(({ name, id }) => ( +
+
+
- ))} +
{ + if (evt.key === "Enter") handleFolderClick(name); + }} + onClick={() => handleFolderClick(name)} + > + {name} +
+
+ + {(isAllowed) => ( + handlePopUpOpen("updateFolder", { id, name })} + isDisabled={!isAllowed} + > + + + )} + + + {(isAllowed) => ( + handlePopUpOpen("deleteFolder", { id, name })} + isDisabled={!isAllowed} + > + + + )} + +
+
+ ))} handlePopUpToggle("updateFolder", isOpen)} diff --git a/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx index 09759fba7..88295b440 100644 --- a/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretDropzone/SecretDropzone.tsx @@ -14,9 +14,10 @@ import { Button, Modal, ModalContent } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretKeys } from "@app/hooks/api/secrets/queries"; -import { SecretType,SecretV3RawSanitized } from "@app/hooks/api/types"; +import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types"; import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store"; import { CopySecretsFromBoard } from "./CopySecretsFromBoard"; @@ -190,6 +191,9 @@ export const SecretDropzone = ({ queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId })); handlePopUpClose("overlapKeyWarning"); createNotification({ diff --git a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx index 9be3cf03b..e7c6b526f 100644 --- a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportItem.tsx @@ -291,7 +291,7 @@ export const SecretImportItem = ({ Key Value - Override + {/* Override */} @@ -304,7 +304,7 @@ export const SecretImportItem = ({ )} {importedSecrets .filter((secret) => secret.key.toUpperCase().includes(searchTerm.toUpperCase())) - .map(({ key, value, overriden }, index) => ( + .map(({ key, value }, index) => ( {key} @@ -312,9 +312,9 @@ export const SecretImportItem = ({ - + {/* - + */} ))} diff --git a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx index cef0d2916..e212d6da9 100644 --- a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx @@ -90,18 +90,18 @@ type Props = { secretPath?: string; secretImports?: TSecretImport[]; isFetching?: boolean; - secrets?: SecretV3RawSanitized[]; + // secrets?: SecretV3RawSanitized[]; importedSecrets?: TImportedSecrets; searchTerm: string; }; export const SecretImportListView = ({ - secretImports = [], + secretImports, environment, workspaceId, secretPath, importedSecrets, - secrets = [], + // secrets = [], isFetching, searchTerm }: Props) => { @@ -117,11 +117,11 @@ export const SecretImportListView = ({ useSensor(KeyboardSensor, {}) ); - const [items, setItems] = useState(secretImports); + const [items, setItems] = useState(secretImports ?? []); useEffect(() => { if (!isFetching) { - setItems(secretImports); + setItems(secretImports ?? []); } }, [isFetching, secretImports]); @@ -170,7 +170,7 @@ export const SecretImportListView = ({ }; const handleOpenReplicationSecrets = (replicationImportId: string) => { - const reservedImport = secretImports.find( + const reservedImport = secretImports?.find( ({ isReserved, importPath, importEnv }) => importEnv.slug === environment && isReserved && @@ -208,8 +208,8 @@ export const SecretImportListView = ({ importedSecrets={computeImportedSecretRows( item.importEnv.slug, item.importPath, - importedSecrets, - secrets + importedSecrets + // secrets scott - now that secrets are paginated we are not showing if they are overridden (yet?) )} secretPath={secretPath} environment={environment} diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx index b6377a35d..811a9e03b 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx @@ -7,6 +7,7 @@ import { CreateTagModal } from "@app/components/tags/CreateTagModal"; import { DeleteActionModal } from "@app/components/v2"; import { usePopUp } from "@app/hooks"; import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretKeys } from "@app/hooks/api/secrets/queries"; import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; @@ -213,6 +214,12 @@ export const SecretListView = ({ }); if (cb) cb(); } + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ + projectId: workspaceId, + secretPath + }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); @@ -246,6 +253,9 @@ export const SecretListView = ({ try { await handleSecretOperation("delete", SecretType.Shared, key, { secretId }); // wrap this in another function and then reuse + queryClient.invalidateQueries( + dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath }) + ); queryClient.invalidateQueries( secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) ); diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 44302467b..46e5f4ba7 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -1,20 +1,24 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; import { subject } from "@casl/ability"; -import { faCheckCircle, faCircle } from "@fortawesome/free-regular-svg-icons"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; import { faAngleDown, faArrowDown, faArrowUp, + faFingerprint, + faFolder, faFolderBlank, faFolderPlus, + faKey, faList, faMagnifyingGlass, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import NavHeader from "@app/components/navigation/NavHeader"; import { createNotification } from "@app/components/notifications"; @@ -55,22 +59,25 @@ import { useCreateFolder, useCreateSecretV3, useDeleteSecretV3, - useGetDynamicSecretsOfAllEnv, - useGetFoldersByEnv, useGetImportedSecretsAllEnvs, - useGetProjectSecretsAllEnv, useUpdateSecretV3 } from "@app/hooks/api"; +import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries"; +import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; import { SecretType, TSecretFolder } from "@app/hooks/api/types"; +import { ProjectVersion } from "@app/hooks/api/workspace/types"; +import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils"; +import { SecretOverviewDynamicSecretRow } from "@app/views/SecretOverviewPage/components/SecretOverviewDynamicSecretRow"; +import { SecretOverviewTableRow } from "@app/views/SecretOverviewPage/components/SecretOverviewTableRow"; +import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount"; import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm"; import { CreateSecretForm } from "./components/CreateSecretForm"; import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs"; -import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow"; import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; -import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow"; import { SecretV2MigrationSection } from "./components/SecretV2MigrationSection"; import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel"; @@ -82,10 +89,13 @@ export enum EntryType { enum RowType { Folder = "folder", DynamicSecret = "dynamic", - Secret = "Secret" + Secret = "secret" } -const INIT_PER_PAGE = 10; +type Filter = { + [key in RowType]: boolean; +}; +const INIT_PER_PAGE = 20; export const SecretOverviewPage = () => { const { t } = useTranslation(); @@ -96,7 +106,7 @@ export const SecretOverviewPage = () => { // coz when overflow the table goes to the right const parentTableRef = useRef(null); const [expandableTableWidth, setExpandableTableWidth] = useState(0); - const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC); const { permission } = useProjectPermission(); useEffect(() => { @@ -106,6 +116,7 @@ export const SecretOverviewPage = () => { }, [parentTableRef.current]); const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace(); + const isProjectV3 = currentWorkspace?.version === ProjectVersion.V3; const { currentOrg } = useOrganization(); const workspaceId = currentWorkspace?.id as string; const projectSlug = currentWorkspace?.slug as string; @@ -113,6 +124,12 @@ export const SecretOverviewPage = () => { const debouncedSearchFilter = useDebounce(searchFilter); const secretPath = (router.query?.secretPath as string) || "/"; + const [filter, setFilter] = useState({ + [RowType.Folder]: true, + [RowType.DynamicSecret]: true, + [RowType.Secret]: true + }); + const [selectedEntries, setSelectedEntries] = useState<{ [EntryType.FOLDER]: Record; [EntryType.SECRET]: Record; @@ -173,28 +190,22 @@ export const SecretOverviewPage = () => { }, [isWorkspaceLoading, workspaceId, router.isReady]); const userAvailableEnvs = currentWorkspace?.environments || []; - const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs); + + const readableEnvs = userAvailableEnvs?.filter(({ slug }) => + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: slug, + secretPath + }) + ) + ); + + const [visibleEnvs, setVisibleEnvs] = useState(readableEnvs); useEffect(() => { - setVisibleEnvs(userAvailableEnvs); - }, [userAvailableEnvs]); - - const { - data: secrets, - getSecretByKey, - secKeys, - getEnvSecretKeyCount - } = useGetProjectSecretsAllEnv({ - workspaceId, - envs: userAvailableEnvs.map(({ slug }) => slug), - secretPath - }); - - const { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv } = useGetFoldersByEnv({ - projectId: workspaceId, - path: secretPath, - environments: userAvailableEnvs.map(({ slug }) => slug) - }); + setVisibleEnvs(readableEnvs); + }, [userAvailableEnvs, secretPath]); const { isImportedSecretPresentInEnv, getImportedSecretByKey } = useGetImportedSecretsAllEnvs({ projectId: workspaceId, @@ -202,12 +213,41 @@ export const SecretOverviewPage = () => { environments: userAvailableEnvs.map(({ slug }) => slug) }); - const { dynamicSecretNames, dynamicSecrets, isDynamicSecretPresentInEnv } = - useGetDynamicSecretsOfAllEnv({ - projectSlug, - environmentSlugs: userAvailableEnvs.map(({ slug }) => slug), - path: secretPath - }); + const paginationOffset = (page - 1) * perPage; + + const { isLoading: isOverviewLoading, data: overview } = useGetProjectSecretsOverview( + { + projectId: workspaceId, + environments: visibleEnvs.map((env) => env.slug), + secretPath, + orderDirection, + orderBy: DashboardSecretsOrderBy.Name, + includeFolders: filter.folder, + includeDynamicSecrets: filter.dynamic, + includeSecrets: filter.secret, + search: debouncedSearchFilter, + limit: perPage, + offset: paginationOffset + }, + { enabled: isProjectV3 } + ); + + const { + secrets, + folders, + dynamicSecrets, + totalCount = 0, + totalFolderCount, + totalSecretCount, + totalDynamicSecretCount + } = overview ?? {}; + + const { folderNames, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders); + + const { dynamicSecretNames, isDynamicSecretPresentInEnv } = + useDynamicSecretOverview(dynamicSecrets); + + const { secKeys, getSecretByKey, getEnvSecretKeyCount } = useSecretOverview(secrets); const { mutateAsync: createSecretV3 } = useCreateSecretV3(); const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); @@ -452,40 +492,23 @@ export const SecretOverviewPage = () => { } }; - const rows = useMemo(() => { - const filteredSecretNames = - secKeys - ?.filter((name) => name.toUpperCase().includes(debouncedSearchFilter.toUpperCase())) - .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? []; - const filteredFolderNames = - folderNames - ?.filter((name) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) - .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? []; - const filteredDynamicSecrets = - dynamicSecretNames - ?.filter((name) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase())) - .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? []; - - return [ - ...filteredFolderNames.map((name) => ({ name, type: RowType.Folder })), - ...filteredDynamicSecrets.map((name) => ({ name, type: RowType.DynamicSecret })), - ...filteredSecretNames.map((name) => ({ name, type: RowType.Secret })) - ]; - }, [sortDir, debouncedSearchFilter, secKeys, folderNames, dynamicSecretNames]); - - const paginationOffset = (page - 1) * perPage; - useEffect(() => { // reset page if no longer valid - if (rows.length < paginationOffset) setPage(1); - }, [rows.length]); + if (totalCount < paginationOffset) setPage(1); + }, [totalCount]); - const isTableLoading = - folders?.some(({ isLoading }) => isLoading) || - secrets?.some(({ isLoading }) => isLoading) || - dynamicSecrets?.some(({ isLoading }) => isLoading); + const handleToggleRowType = useCallback( + (rowType: RowType) => + setFilter((state) => { + return { + ...state, + [rowType]: !state[rowType] + }; + }), + [] + ); - if (isWorkspaceLoading || isTableLoading) { + if (isWorkspaceLoading || (isProjectV3 && isOverviewLoading)) { return (
{ // This is needed to also show imports from other paths – right now those are missing. // const combinedKeys = [...secKeys, ...secretImports.map((impSecrets) => impSecrets?.data?.map((impSec) => impSec.secrets?.map((impSecKey) => impSecKey.key))).flat().flat()]; - const isTableEmpty = - !( - folders?.every(({ isLoading }) => isLoading) && - secrets?.every(({ isLoading }) => isLoading) && - dynamicSecrets?.every(({ isLoading }) => isLoading) - ) && rows.length === 0; + const isTableEmpty = totalCount === 0; + + const isTableFiltered = + Boolean(Object.values(filter).filter((enabled) => !enabled).length) || + visibleEnvs.length !== readableEnvs?.length; + + if (!isProjectV3) + return ( +
+ +
+ ); return ( <>
-
@@ -571,7 +599,10 @@ export const SecretOverviewPage = () => { ariaLabel="Environments" variant="plain" size="sm" - className="flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 hover:border-primary/60 hover:bg-primary/10" + className={twMerge( + "flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10", + isTableFiltered && "border-primary/50 text-primary" + )} > @@ -580,22 +611,20 @@ export const SecretOverviewPage = () => { Choose visible environments - {userAvailableEnvs.map((availableEnv) => { + {readableEnvs.map((availableEnv) => { const { id: envId, name } = availableEnv; const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); return ( handleEnvSelect(envId)} + onClick={(e) => { + e.preventDefault(); + handleEnvSelect(envId); + }} key={envId} - icon={ - isEnvSelected ? ( - - ) : ( - - ) - } - iconPos="left" + disabled={visibleEnvs?.length === 1} + icon={isEnvSelected && } + iconPos="right" >
{name}
@@ -613,6 +642,48 @@ export const SecretOverviewPage = () => { Create an environment */} + Filter project resources + { + e.preventDefault(); + handleToggleRowType(RowType.Folder); + }} + icon={filter[RowType.Folder] && } + iconPos="right" + > +
+ + Folders +
+
+ { + e.preventDefault(); + handleToggleRowType(RowType.DynamicSecret); + }} + icon={ + filter[RowType.DynamicSecret] && + } + iconPos="right" + > +
+ + Dynamic Secrets +
+
+ { + e.preventDefault(); + handleToggleRowType(RowType.Secret); + }} + icon={filter[RowType.Secret] && } + iconPos="right" + > +
+ + Secrets +
+
)} @@ -697,9 +768,17 @@ export const SecretOverviewPage = () => { variant="plain" className="ml-2" ariaLabel="sort" - onClick={() => setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))} + onClick={() => + setOrderDirection((prev) => + prev === OrderByDirection.ASC + ? OrderByDirection.DESC + : OrderByDirection.ASC + ) + } > - +
@@ -736,7 +815,7 @@ export const SecretOverviewPage = () => { - {canViewOverviewPage && isTableLoading && ( + {canViewOverviewPage && isOverviewLoading && ( { )} - {isTableEmpty && !isTableLoading && visibleEnvs.length > 0 && ( + {isTableEmpty && !isOverviewLoading && visibleEnvs.length > 0 && ( { )} - {!isTableLoading && - rows.slice(paginationOffset, paginationOffset + perPage).map((row, index) => { - switch (row.type) { - case RowType.Secret: - if (visibleEnvs?.length === 0) return null; - return ( - - toggleSelectedEntry(EntryType.SECRET, row.name) - } - secretPath={secretPath} - getImportedSecretByKey={getImportedSecretByKey} - isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} - onSecretCreate={handleSecretCreate} - onSecretDelete={handleSecretDelete} - onSecretUpdate={handleSecretUpdate} - key={`overview-${row.name}-${index + 1}`} - environments={visibleEnvs} - secretKey={row.name} - getSecretByKey={getSecretByKey} - expandableColWidth={expandableTableWidth} - /> - ); - case RowType.DynamicSecret: - return ( - - ); - case RowType.Folder: - return ( - - toggleSelectedEntry(EntryType.FOLDER, row.name) - } - environments={visibleEnvs} - key={`overview-${row.name}-${index + 1}`} - onClick={handleFolderClick} - onToggleFolderEdit={(name: string) => - handlePopUpOpen("updateFolder", { name }) - } - /> - ); - default: - return null; - } - })} + {!isOverviewLoading && visibleEnvs.length > 0 && ( + <> + {folderNames.map((folderName, index) => ( + + toggleSelectedEntry(EntryType.FOLDER, folderName) + } + environments={visibleEnvs} + key={`overview-${folderName}-${index + 1}`} + onClick={handleFolderClick} + onToggleFolderEdit={(name: string) => + handlePopUpOpen("updateFolder", { name }) + } + /> + ))} + {dynamicSecretNames.map((dynamicSecretName, index) => ( + + ))} + {secKeys.map((key, index) => ( + toggleSelectedEntry(EntryType.SECRET, key)} + secretPath={secretPath} + getImportedSecretByKey={getImportedSecretByKey} + isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} + onSecretCreate={handleSecretCreate} + onSecretDelete={handleSecretDelete} + onSecretUpdate={handleSecretUpdate} + key={`overview-${key}-${index + 1}`} + environments={visibleEnvs} + secretKey={key} + getSecretByKey={getSecretByKey} + expandableColWidth={expandableTableWidth} + /> + ))} + + )} @@ -883,10 +953,17 @@ export const SecretOverviewPage = () => { - {!isTableLoading && rows.length > INIT_PER_PAGE && ( + {!isOverviewLoading && totalCount > 0 && ( + } className="border-t border-solid border-t-mineshaft-600" - count={rows.length} + count={totalCount} page={page} perPage={perPage} onChangePage={(newPage) => setPage(newPage)} diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index 6c6dc9662..287269838 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -13,7 +13,7 @@ import { twMerge } from "tailwind-merge"; import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; import { useToggle } from "@app/hooks"; -import { SecretType,SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; +import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; import { WorkspaceEnv } from "@app/hooks/api/types"; import { SecretEditRow } from "./SecretEditRow"; @@ -53,6 +53,8 @@ export const SecretOverviewTableRow = ({ onSecretDelete, isImportedSecretPresentInEnv, getImportedSecretByKey, + // temporary until below todo is resolved + // eslint-disable-next-line @typescript-eslint/no-unused-vars expandableColWidth, onToggleSecretSelect, isSelected @@ -150,10 +152,11 @@ export const SecretOverviewTableRow = ({ }`} >
{ + return ( +
+ {importCount > 0 && ( +
+ + {importCount} +
+ )} + {folderCount > 0 && ( +
+ + {folderCount} +
+ )} + {dynamicSecretCount > 0 && ( +
+ + {dynamicSecretCount} +
+ )} + {secretCount > 0 && ( +
+ + {secretCount} +
+ )} +
+ ); +}; diff --git a/frontend/src/views/SecretOverviewPage/components/SecretTableResourceCount/index.tsx b/frontend/src/views/SecretOverviewPage/components/SecretTableResourceCount/index.tsx new file mode 100644 index 000000000..d2b5705fa --- /dev/null +++ b/frontend/src/views/SecretOverviewPage/components/SecretTableResourceCount/index.tsx @@ -0,0 +1 @@ +export { SecretTableResourceCount } from "./SecretTableResourceCount"; diff --git a/frontend/src/views/SecretOverviewPage/components/SecretV2MigrationSection/SecretV2MigrationSection.tsx b/frontend/src/views/SecretOverviewPage/components/SecretV2MigrationSection/SecretV2MigrationSection.tsx index 50ed175b4..e94189261 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretV2MigrationSection/SecretV2MigrationSection.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretV2MigrationSection/SecretV2MigrationSection.tsx @@ -1,15 +1,16 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; -import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, Checkbox, Modal, ModalContent, Spinner } from "@app/components/v2"; import { useProjectPermission, useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; -import { useGetWorkspaceById, useMigrateProjectToV3 } from "@app/hooks/api"; +import { useGetWorkspaceById, useMigrateProjectToV3, workspaceKeys } from "@app/hooks/api"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; @@ -28,6 +29,7 @@ const formSchema = z.object({ export const SecretV2MigrationSection = () => { const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["migrationInfo"] as const); const { currentWorkspace } = useWorkspace(); + const queryClient = useQueryClient(); const { data: workspaceDetails, refetch } = useGetWorkspaceById( // if v3 no need to fetch currentWorkspace?.version === ProjectVersion.V3 ? "" : currentWorkspace?.id || "", @@ -51,6 +53,7 @@ export const SecretV2MigrationSection = () => { if (isProjectUpgraded && migrateProjectToV3.data) { createNotification({ type: "success", text: "Project upgrade completed successfully" }); migrateProjectToV3.reset(); + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); } }, [isProjectUpgraded, Boolean(migrateProjectToV3.data)]); @@ -78,7 +81,7 @@ export const SecretV2MigrationSection = () => { const isAdmin = membership?.roles.includes(ProjectMembershipRole.Admin); return ( -
+
{isUpgrading && (
@@ -88,18 +91,29 @@ export const SecretV2MigrationSection = () => {
)} -

Action Required

-

- Infisical secrets engine is now 10x faster and allows you to encrypt secrets with your own - KMS. Upgrade your project to receive these improvements. +

+ +

+ Upgrade your project +

+
+

+ Your existing workflows to fetch secrets will continue to work. However, viewing secrets on the UI requires you to upgrade your project. +

+

+ Upgrading your project enables the use of Infisical's new secrets engine, which is 10x faster and + allows you to encrypt secrets with your own KMS provider. +

+

+ The upgrade takes only 1-2 minutes and will not cause any downtime.

{didProjectUpgradeFailed && (

diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/ManagePlansTable.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/ManagePlansTable.tsx index 2aa552f52..b37c22012 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/ManagePlansTable.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/ManagePlansTable.tsx @@ -66,12 +66,11 @@ export const ManagePlansTable = ({ billingCycle }: Props) => { {subscription && !isTableDataLoading && tableData && - tableData.rows.map(({ name, starter, team, pro, enterprise }) => { + tableData.rows.map(({ name, starter, pro, enterprise }) => { return ( {displayCell(name)} {displayCell(starter)} - {displayCell(team)} {displayCell(pro)} {displayCell(enterprise)} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/BackfillSecretReferenceSection/BackfillSecretReferenceSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/BackfillSecretReferenceSection/BackfillSecretReferenceSection.tsx index 91f71f5eb..6b1da60b3 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/BackfillSecretReferenceSection/BackfillSecretReferenceSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/BackfillSecretReferenceSection/BackfillSecretReferenceSection.tsx @@ -5,39 +5,40 @@ import { useBackfillSecretReference } from "@app/hooks/api"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; export const BackfillSecretReferenceSecretion = () => { - const { currentWorkspace } = useWorkspace(); - const { membership } = useProjectPermission(); - const backfillSecretReferences = useBackfillSecretReference(); + const { currentWorkspace } = useWorkspace(); + const { membership } = useProjectPermission(); + const backfillSecretReferences = useBackfillSecretReference(); - if (!currentWorkspace) return null; + if (!currentWorkspace) return null; - const handleBackfill = async () => { - if (backfillSecretReferences.isLoading) return; - try { - await backfillSecretReferences.mutateAsync({ projectId: currentWorkspace.id || "" }); - createNotification({ text: "Successfully re-indexed secret references", type: "success" }); - } catch { - createNotification({ text: "Failed to re-index secret references", type: "error" }); - } - }; + const handleBackfill = async () => { + if (backfillSecretReferences.isLoading) return; + try { + await backfillSecretReferences.mutateAsync({ projectId: currentWorkspace.id || "" }); + createNotification({ text: "Successfully re-indexed secret references", type: "success" }); + } catch { + createNotification({ text: "Failed to re-index secret references", type: "error" }); + } + }; - const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin); - return ( -

-
-

Index Secret References

-
-

- This will index all secret references, enabling integrations to be triggered when their values change going forward. -

- -
- ); + const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin); + return ( +
+
+

Index Secret References

+
+

+ This will index all secret references, enabling integrations to be triggered when their + values change going forward. This happens automatically when secrets are created or updated. +

+ +
+ ); }; diff --git a/frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx b/frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx index b07dde77e..9bb005ce4 100644 --- a/frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx +++ b/frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx @@ -5,6 +5,7 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; import { useGetAdminSlackConfig, useUpdateServerConfig } from "@app/hooks/api"; const slackFormSchema = z.object({ @@ -65,6 +66,8 @@ export const IntegrationPanel = () => { const { data: adminSlackConfig } = useGetAdminSlackConfig(); const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig(); + const [isSlackClientIdFocused, setIsSlackClientIdFocused] = useToggle(); + const [isSlackClientSecretFocused, setIsSlackClientSecretFocused] = useToggle(); useEffect(() => { if (adminSlackConfig) { @@ -120,6 +123,9 @@ export const IntegrationPanel = () => { setIsSlackClientIdFocused.on()} + onBlur={() => setIsSlackClientIdFocused.off()} onChange={(e) => field.onChange(e.target.value)} /> @@ -138,6 +144,9 @@ export const IntegrationPanel = () => { setIsSlackClientSecretFocused.on()} + onBlur={() => setIsSlackClientSecretFocused.off()} onChange={(e) => field.onChange(e.target.value)} />