diff --git a/backend/package-lock.json b/backend/package-lock.json index 8a8e9d13d..97eaf4ff8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@aws-sdk/client-iam": "^3.525.0", "@aws-sdk/client-secrets-manager": "^3.504.0", "@casl/ability": "^6.5.0", "@fastify/cookie": "^9.3.1", @@ -45,6 +46,7 @@ "knex": "^3.0.1", "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", + "ms": "^2.1.3", "mysql2": "^3.9.1", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", @@ -54,6 +56,7 @@ "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", "pg": "^8.11.3", + "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", "posthog-node": "^3.6.2", @@ -660,6 +663,496 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@aws-sdk/client-iam": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.525.0.tgz", + "integrity": "sha512-h705ebOYcgZWN9a4Pdkwd7DAPK4KTEGEFtXEj2FoaadBYBcR9r5yffm7umHZv9gOCHxFMaap8/NSZXirF2VHKg==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.525.0", + "@aws-sdk/core": "3.525.0", + "@aws-sdk/credential-provider-node": "3.525.0", + "@aws-sdk/middleware-host-header": "3.523.0", + "@aws-sdk/middleware-logger": "3.523.0", + "@aws-sdk/middleware-recursion-detection": "3.523.0", + "@aws-sdk/middleware-user-agent": "3.525.0", + "@aws-sdk/region-config-resolver": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@aws-sdk/util-endpoints": "3.525.0", + "@aws-sdk/util-user-agent-browser": "3.523.0", + "@aws-sdk/util-user-agent-node": "3.525.0", + "@smithy/config-resolver": "^2.1.4", + "@smithy/core": "^1.3.5", + "@smithy/fetch-http-handler": "^2.4.3", + "@smithy/hash-node": "^2.1.3", + "@smithy/invalid-dependency": "^2.1.3", + "@smithy/middleware-content-length": "^2.1.3", + "@smithy/middleware-endpoint": "^2.4.4", + "@smithy/middleware-retry": "^2.1.4", + "@smithy/middleware-serde": "^2.1.3", + "@smithy/middleware-stack": "^2.1.3", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/node-http-handler": "^2.4.1", + "@smithy/protocol-http": "^3.2.1", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "@smithy/url-parser": "^2.1.3", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.4", + "@smithy/util-defaults-mode-node": "^2.2.3", + "@smithy/util-endpoints": "^1.1.4", + "@smithy/util-middleware": "^2.1.3", + "@smithy/util-retry": "^2.1.3", + "@smithy/util-utf8": "^2.1.1", + "@smithy/util-waiter": "^2.1.3", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sso": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.525.0.tgz", + "integrity": "sha512-6KwGQWFoNLH1UupdWPFdKPfTgjSz1kN8/r8aCzuvvXBe4Pz+iDUZ6FEJzGWNc9AapjvZDNO1hs23slomM9rTaA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.525.0", + "@aws-sdk/middleware-host-header": "3.523.0", + "@aws-sdk/middleware-logger": "3.523.0", + "@aws-sdk/middleware-recursion-detection": "3.523.0", + "@aws-sdk/middleware-user-agent": "3.525.0", + "@aws-sdk/region-config-resolver": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@aws-sdk/util-endpoints": "3.525.0", + "@aws-sdk/util-user-agent-browser": "3.523.0", + "@aws-sdk/util-user-agent-node": "3.525.0", + "@smithy/config-resolver": "^2.1.4", + "@smithy/core": "^1.3.5", + "@smithy/fetch-http-handler": "^2.4.3", + "@smithy/hash-node": "^2.1.3", + "@smithy/invalid-dependency": "^2.1.3", + "@smithy/middleware-content-length": "^2.1.3", + "@smithy/middleware-endpoint": "^2.4.4", + "@smithy/middleware-retry": "^2.1.4", + "@smithy/middleware-serde": "^2.1.3", + "@smithy/middleware-stack": "^2.1.3", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/node-http-handler": "^2.4.1", + "@smithy/protocol-http": "^3.2.1", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "@smithy/url-parser": "^2.1.3", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.4", + "@smithy/util-defaults-mode-node": "^2.2.3", + "@smithy/util-endpoints": "^1.1.4", + "@smithy/util-middleware": "^2.1.3", + "@smithy/util-retry": "^2.1.3", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.525.0.tgz", + "integrity": "sha512-zz13k/6RkjPSLmReSeGxd8wzGiiZa4Odr2Tv3wTcxClM4wOjD+zOgGv4Fe32b9AMqaueiCdjbvdu7AKcYxFA4A==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.525.0", + "@aws-sdk/core": "3.525.0", + "@aws-sdk/middleware-host-header": "3.523.0", + "@aws-sdk/middleware-logger": "3.523.0", + "@aws-sdk/middleware-recursion-detection": "3.523.0", + "@aws-sdk/middleware-user-agent": "3.525.0", + "@aws-sdk/region-config-resolver": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@aws-sdk/util-endpoints": "3.525.0", + "@aws-sdk/util-user-agent-browser": "3.523.0", + "@aws-sdk/util-user-agent-node": "3.525.0", + "@smithy/config-resolver": "^2.1.4", + "@smithy/core": "^1.3.5", + "@smithy/fetch-http-handler": "^2.4.3", + "@smithy/hash-node": "^2.1.3", + "@smithy/invalid-dependency": "^2.1.3", + "@smithy/middleware-content-length": "^2.1.3", + "@smithy/middleware-endpoint": "^2.4.4", + "@smithy/middleware-retry": "^2.1.4", + "@smithy/middleware-serde": "^2.1.3", + "@smithy/middleware-stack": "^2.1.3", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/node-http-handler": "^2.4.1", + "@smithy/protocol-http": "^3.2.1", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "@smithy/url-parser": "^2.1.3", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.4", + "@smithy/util-defaults-mode-node": "^2.2.3", + "@smithy/util-endpoints": "^1.1.4", + "@smithy/util-middleware": "^2.1.3", + "@smithy/util-retry": "^2.1.3", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.525.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sts": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.525.0.tgz", + "integrity": "sha512-a8NUGRvO6rkfTZCbMaCsjDjLbERCwIUU9dIywFYcRgbFhkupJ7fSaZz3Het98U51M9ZbTEpaTa3fz0HaJv8VJw==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.525.0", + "@aws-sdk/middleware-host-header": "3.523.0", + "@aws-sdk/middleware-logger": "3.523.0", + "@aws-sdk/middleware-recursion-detection": "3.523.0", + "@aws-sdk/middleware-user-agent": "3.525.0", + "@aws-sdk/region-config-resolver": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@aws-sdk/util-endpoints": "3.525.0", + "@aws-sdk/util-user-agent-browser": "3.523.0", + "@aws-sdk/util-user-agent-node": "3.525.0", + "@smithy/config-resolver": "^2.1.4", + "@smithy/core": "^1.3.5", + "@smithy/fetch-http-handler": "^2.4.3", + "@smithy/hash-node": "^2.1.3", + "@smithy/invalid-dependency": "^2.1.3", + "@smithy/middleware-content-length": "^2.1.3", + "@smithy/middleware-endpoint": "^2.4.4", + "@smithy/middleware-retry": "^2.1.4", + "@smithy/middleware-serde": "^2.1.3", + "@smithy/middleware-stack": "^2.1.3", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/node-http-handler": "^2.4.1", + "@smithy/protocol-http": "^3.2.1", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "@smithy/url-parser": "^2.1.3", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.4", + "@smithy/util-defaults-mode-node": "^2.2.3", + "@smithy/util-endpoints": "^1.1.4", + "@smithy/util-middleware": "^2.1.3", + "@smithy/util-retry": "^2.1.3", + "@smithy/util-utf8": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.525.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/core": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.525.0.tgz", + "integrity": "sha512-E3LtEtMWCriQOFZpVKpLYzbdw/v2PAOEAMhn2VRRZ1g0/g1TXzQrfhEU2yd8l/vQEJaCJ82ooGGg7YECviBUxA==", + "dependencies": { + "@smithy/core": "^1.3.5", + "@smithy/protocol-http": "^3.2.1", + "@smithy/signature-v4": "^2.1.3", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.523.0.tgz", + "integrity": "sha512-Y6DWdH6/OuMDoNKVzZlNeBc6f1Yjk1lYMjANKpIhMbkRCvLJw/PYZKOZa8WpXbTYdgg9XLjKybnLIb3ww3uuzA==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/property-provider": "^2.1.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.525.0.tgz", + "integrity": "sha512-RNWQGuSBQZhl3iqklOslUEfQ4br1V3DCPboMpeqFtddUWJV3m2u2extFur9/4Uy+1EHVF120IwZUKtd8dF+ibw==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/fetch-http-handler": "^2.4.3", + "@smithy/node-http-handler": "^2.4.1", + "@smithy/property-provider": "^2.1.3", + "@smithy/protocol-http": "^3.2.1", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "@smithy/util-stream": "^2.1.3", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.525.0.tgz", + "integrity": "sha512-JDnccfK5JRb9jcgpc9lirL9PyCwGIqY0nKdw3LlX5WL5vTpTG4E1q7rLAlpNh7/tFD1n66Itarfv2tsyHMIqCw==", + "dependencies": { + "@aws-sdk/client-sts": "3.525.0", + "@aws-sdk/credential-provider-env": "3.523.0", + "@aws-sdk/credential-provider-process": "3.523.0", + "@aws-sdk/credential-provider-sso": "3.525.0", + "@aws-sdk/credential-provider-web-identity": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@smithy/credential-provider-imds": "^2.2.3", + "@smithy/property-provider": "^2.1.3", + "@smithy/shared-ini-file-loader": "^2.3.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.525.0.tgz", + "integrity": "sha512-RJXlO8goGXpnoHQAyrCcJ0QtWEOFa34LSbfdqBIjQX/fwnjUuEmiGdXTV3AZmwYQ7juk49tfBneHbtOP3AGqsQ==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.523.0", + "@aws-sdk/credential-provider-http": "3.525.0", + "@aws-sdk/credential-provider-ini": "3.525.0", + "@aws-sdk/credential-provider-process": "3.523.0", + "@aws-sdk/credential-provider-sso": "3.525.0", + "@aws-sdk/credential-provider-web-identity": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@smithy/credential-provider-imds": "^2.2.3", + "@smithy/property-provider": "^2.1.3", + "@smithy/shared-ini-file-loader": "^2.3.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.523.0.tgz", + "integrity": "sha512-f0LP9KlFmMvPWdKeUKYlZ6FkQAECUeZMmISsv6NKtvPCI9e4O4cLTeR09telwDK8P0HrgcRuZfXM7E30m8re0Q==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/property-provider": "^2.1.3", + "@smithy/shared-ini-file-loader": "^2.3.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.525.0.tgz", + "integrity": "sha512-7V7ybtufxdD3plxeIeB6aqHZeFIUlAyPphXIUgXrGY10iNcosL970rQPBeggsohe4gCM6UvY2TfMeEcr+ZE8FA==", + "dependencies": { + "@aws-sdk/client-sso": "3.525.0", + "@aws-sdk/token-providers": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@smithy/property-provider": "^2.1.3", + "@smithy/shared-ini-file-loader": "^2.3.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.525.0.tgz", + "integrity": "sha512-sAukOjR1oKb2JXG4nPpuBFpSwGUhrrY17PG/xbTy8NAoLLhrqRwnErcLfdTfmj6tH+3094k6ws/Sh8a35ae7fA==", + "dependencies": { + "@aws-sdk/client-sts": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@smithy/property-provider": "^2.1.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.523.0.tgz", + "integrity": "sha512-4g3q7Ta9sdD9TMUuohBAkbx/e3I/juTqfKi7TPgP+8jxcYX72MOsgemAMHuP6CX27eyj4dpvjH+w4SIVDiDSmg==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/protocol-http": "^3.2.1", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-logger": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.523.0.tgz", + "integrity": "sha512-PeDNJNhfiaZx54LBaLTXzUaJ9LXFwDFFIksipjqjvxMafnoVcQwKbkoPUWLe5ytT4nnL1LogD3s55mERFUsnwg==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.523.0.tgz", + "integrity": "sha512-nZ3Vt7ehfSDYnrcg/aAfjjvpdE+61B3Zk68i6/hSUIegT3IH9H1vSW67NDKVp+50hcEfzWwM2HMPXxlzuyFyrw==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/protocol-http": "^3.2.1", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.525.0.tgz", + "integrity": "sha512-4al/6uO+t/QIYXK2OgqzDKQzzLAYJza1vWFS+S0lJ3jLNGyLB5BMU5KqWjDzevYZ4eCnz2Nn7z0FveUTNz8YdQ==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@aws-sdk/util-endpoints": "3.525.0", + "@smithy/protocol-http": "^3.2.1", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.525.0.tgz", + "integrity": "sha512-8kFqXk6UyKgTMi7N7QlhA6qM4pGPWbiUXqEY2RgUWngtxqNFGeM9JTexZeuavQI+qLLe09VPShPNX71fEDcM6w==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/types": "^2.10.1", + "@smithy/util-config-provider": "^2.2.1", + "@smithy/util-middleware": "^2.1.3", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/token-providers": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.525.0.tgz", + "integrity": "sha512-puVjbxuK0Dq7PTQ2HdddHy2eQjOH8GZbump74yWJa6JVpRW84LlOcNmP+79x4Kscvz2ldWB8XDFw/pcCiSDe5A==", + "dependencies": { + "@aws-sdk/client-sso-oidc": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@smithy/property-provider": "^2.1.3", + "@smithy/shared-ini-file-loader": "^2.3.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/types": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.523.0.tgz", + "integrity": "sha512-AqGIu4u+SxPiUuNBp2acCVcq80KDUFjxe6e3cMTvKWTzCbrVk1AXv0dAaJnCmdkWIha6zJDWxpIk/aL4EGhZ9A==", + "dependencies": { + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-endpoints": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.525.0.tgz", + "integrity": "sha512-DIW7WWU5tIGkeeKX6NJUyrEIdWMiqjLQG3XBzaUj+ufIENwNjdAHhlD8l2vX7Yr3JZRT6yN/84wBCj7Tw1xd1g==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/types": "^2.10.1", + "@smithy/util-endpoints": "^1.1.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.523.0.tgz", + "integrity": "sha512-6ZRNdGHX6+HQFqTbIA5+i8RWzxFyxsZv8D3soRfpdyWIKkzhSz8IyRKXRciwKBJDaC7OX2jzGE90wxRQft27nA==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/types": "^2.10.1", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.525.0.tgz", + "integrity": "sha512-88Wjt4efyUSBGcyIuh1dvoMqY1k15jpJc5A/3yi67clBQEFsu9QCodQCQPqmRjV3VRcMtBOk+jeCTiUzTY5dRQ==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, "node_modules/@aws-sdk/client-secrets-manager": { "version": "3.504.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.504.0.tgz", @@ -3029,11 +3522,11 @@ } }, "node_modules/@smithy/abort-controller": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.1.1.tgz", - "integrity": "sha512-1+qdrUqLhaALYL0iOcN43EP6yAXXQ2wWZ6taf4S2pNGowmOc5gx+iMQv+E42JizNJjB0+gEadOXeV1Bf7JWL1Q==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.1.3.tgz", + "integrity": "sha512-c2aYH2Wu1RVE3rLlVgg2kQOBJGM0WbjReQi5DnPTm2Zb7F0gk7J2aeQeaX2u/lQZoHl6gv8Oac7mt9alU3+f4A==", "dependencies": { - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3041,14 +3534,14 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-2.1.1.tgz", - "integrity": "sha512-lxfLDpZm+AWAHPFZps5JfDoO9Ux1764fOgvRUBpHIO8HWHcSN1dkgsago1qLRVgm1BZ8RCm8cgv99QvtaOWIhw==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-2.1.4.tgz", + "integrity": "sha512-AW2WUZmBAzgO3V3ovKtsUbI3aBNMeQKFDumoqkNxaVDWF/xfnxAWqBKDr/NuG7c06N2Rm4xeZLPiJH/d+na0HA==", "dependencies": { - "@smithy/node-config-provider": "^2.2.1", - "@smithy/types": "^2.9.1", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/types": "^2.10.1", "@smithy/util-config-provider": "^2.2.1", - "@smithy/util-middleware": "^2.1.1", + "@smithy/util-middleware": "^2.1.3", "tslib": "^2.5.0" }, "engines": { @@ -3056,17 +3549,17 @@ } }, "node_modules/@smithy/core": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-1.3.1.tgz", - "integrity": "sha512-tf+NIu9FkOh312b6M9G4D68is4Xr7qptzaZGZUREELF8ysE1yLKphqt7nsomjKZVwW7WE5pDDex9idowNGRQ/Q==", + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-1.3.5.tgz", + "integrity": "sha512-Rrc+e2Jj6Gu7Xbn0jvrzZlSiP2CZocIOfZ9aNUA82+1sa6GBnxqL9+iZ9EKHeD9aqD1nU8EK4+oN2EiFpSv7Yw==", "dependencies": { - "@smithy/middleware-endpoint": "^2.4.1", - "@smithy/middleware-retry": "^2.1.1", - "@smithy/middleware-serde": "^2.1.1", - "@smithy/protocol-http": "^3.1.1", - "@smithy/smithy-client": "^2.3.1", - "@smithy/types": "^2.9.1", - "@smithy/util-middleware": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.4", + "@smithy/middleware-retry": "^2.1.4", + "@smithy/middleware-serde": "^2.1.3", + "@smithy/protocol-http": "^3.2.1", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "@smithy/util-middleware": "^2.1.3", "tslib": "^2.5.0" }, "engines": { @@ -3074,14 +3567,14 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-2.2.1.tgz", - "integrity": "sha512-7XHjZUxmZYnONheVQL7j5zvZXga+EWNgwEAP6OPZTi7l8J4JTeNh9aIOfE5fKHZ/ee2IeNOh54ZrSna+Vc6TFA==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-2.2.4.tgz", + "integrity": "sha512-DdatjmBZQnhGe1FhI8gO98f7NmvQFSDiZTwC3WMvLTCKQUY+Y1SVkhJqIuLu50Eb7pTheoXQmK+hKYUgpUWsNA==", "dependencies": { - "@smithy/node-config-provider": "^2.2.1", - "@smithy/property-provider": "^2.1.1", - "@smithy/types": "^2.9.1", - "@smithy/url-parser": "^2.1.1", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/property-provider": "^2.1.3", + "@smithy/types": "^2.10.1", + "@smithy/url-parser": "^2.1.3", "tslib": "^2.5.0" }, "engines": { @@ -3089,12 +3582,12 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-2.1.1.tgz", - "integrity": "sha512-E8KYBxBIuU4c+zrpR22VsVrOPoEDzk35bQR3E+xm4k6Pa6JqzkDOdMyf9Atac5GPNKHJBdVaQ4JtjdWX2rl/nw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-2.1.3.tgz", + "integrity": "sha512-rGlCVuwSDv6qfKH4/lRxFjcZQnIE0LZ3D4lkMHg7ZSltK9rA74r0VuGSvWVQ4N/d70VZPaniFhp4Z14QYZsa+A==", "dependencies": { "@aws-crypto/crc32": "3.0.0", - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "@smithy/util-hex-encoding": "^2.1.1", "tslib": "^2.5.0" } @@ -3151,23 +3644,23 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-2.4.1.tgz", - "integrity": "sha512-VYGLinPsFqH68lxfRhjQaSkjXM7JysUOJDTNjHBuN/ykyRb2f1gyavN9+VhhPTWCy32L4yZ2fdhpCs/nStEicg==", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-2.4.3.tgz", + "integrity": "sha512-Fn/KYJFo6L5I4YPG8WQb2hOmExgRmNpVH5IK2zU3JKrY5FKW7y9ar5e0BexiIC9DhSKqKX+HeWq/Y18fq7Dkpw==", "dependencies": { - "@smithy/protocol-http": "^3.1.1", - "@smithy/querystring-builder": "^2.1.1", - "@smithy/types": "^2.9.1", + "@smithy/protocol-http": "^3.2.1", + "@smithy/querystring-builder": "^2.1.3", + "@smithy/types": "^2.10.1", "@smithy/util-base64": "^2.1.1", "tslib": "^2.5.0" } }, "node_modules/@smithy/hash-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-2.1.1.tgz", - "integrity": "sha512-Qhoq0N8f2OtCnvUpCf+g1vSyhYQrZjhSwvJ9qvR8BUGOtTXiyv2x1OD2e6jVGmlpC4E4ax1USHoyGfV9JFsACg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-2.1.3.tgz", + "integrity": "sha512-FsAPCUj7VNJIdHbSxMd5uiZiF20G2zdSDgrgrDrHqIs/VMxK85Vqk5kMVNNDMCZmMezp6UKnac0B4nAyx7HJ9g==", "dependencies": { - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "@smithy/util-buffer-from": "^2.1.1", "@smithy/util-utf8": "^2.1.1", "tslib": "^2.5.0" @@ -3177,11 +3670,11 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-2.1.1.tgz", - "integrity": "sha512-7WTgnKw+VPg8fxu2v9AlNOQ5yaz6RA54zOVB4f6vQuR0xFKd+RzlCpt0WidYTsye7F+FYDIaS/RnJW4pxjNInw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-2.1.3.tgz", + "integrity": "sha512-wkra7d/G4CbngV4xsjYyAYOvdAhahQje/WymuQdVEnXFExJopEu7fbL5AEAlBPgWHXwu94VnCSG00gVzRfExyg==", "dependencies": { - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" } }, @@ -3197,12 +3690,12 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-2.1.1.tgz", - "integrity": "sha512-rSr9ezUl9qMgiJR0UVtVOGEZElMdGFyl8FzWEF5iEKTlcWxGr2wTqGfDwtH3LAB7h+FPkxqv4ZU4cpuCN9Kf/g==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-2.1.3.tgz", + "integrity": "sha512-aJduhkC+dcXxdnv5ZpM3uMmtGmVFKx412R1gbeykS5HXDmRU6oSsyy2SoHENCkfOGKAQOjVE2WVqDJibC0d21g==", "dependencies": { - "@smithy/protocol-http": "^3.1.1", - "@smithy/types": "^2.9.1", + "@smithy/protocol-http": "^3.2.1", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3210,16 +3703,16 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-2.4.1.tgz", - "integrity": "sha512-XPZTb1E2Oav60Ven3n2PFx+rX9EDsU/jSTA8VDamt7FXks67ekjPY/XrmmPDQaFJOTUHJNKjd8+kZxVO5Ael4Q==", + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-2.4.4.tgz", + "integrity": "sha512-4yjHyHK2Jul4JUDBo2sTsWY9UshYUnXeb/TAK/MTaPEb8XQvDmpwSFnfIRDU45RY1a6iC9LCnmJNg/yHyfxqkw==", "dependencies": { - "@smithy/middleware-serde": "^2.1.1", - "@smithy/node-config-provider": "^2.2.1", - "@smithy/shared-ini-file-loader": "^2.3.1", - "@smithy/types": "^2.9.1", - "@smithy/url-parser": "^2.1.1", - "@smithy/util-middleware": "^2.1.1", + "@smithy/middleware-serde": "^2.1.3", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/shared-ini-file-loader": "^2.3.4", + "@smithy/types": "^2.10.1", + "@smithy/url-parser": "^2.1.3", + "@smithy/util-middleware": "^2.1.3", "tslib": "^2.5.0" }, "engines": { @@ -3227,17 +3720,17 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-2.1.1.tgz", - "integrity": "sha512-eMIHOBTXro6JZ+WWzZWd/8fS8ht5nS5KDQjzhNMHNRcG5FkNTqcKpYhw7TETMYzbLfhO5FYghHy1vqDWM4FLDA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-2.1.4.tgz", + "integrity": "sha512-Cyolv9YckZTPli1EkkaS39UklonxMd08VskiuMhURDjC0HHa/AD6aK/YoD21CHv9s0QLg0WMLvk9YeLTKkXaFQ==", "dependencies": { - "@smithy/node-config-provider": "^2.2.1", - "@smithy/protocol-http": "^3.1.1", - "@smithy/service-error-classification": "^2.1.1", - "@smithy/smithy-client": "^2.3.1", - "@smithy/types": "^2.9.1", - "@smithy/util-middleware": "^2.1.1", - "@smithy/util-retry": "^2.1.1", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/protocol-http": "^3.2.1", + "@smithy/service-error-classification": "^2.1.3", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "@smithy/util-middleware": "^2.1.3", + "@smithy/util-retry": "^2.1.3", "tslib": "^2.5.0", "uuid": "^8.3.2" }, @@ -3254,11 +3747,11 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-2.1.1.tgz", - "integrity": "sha512-D8Gq0aQBeE1pxf3cjWVkRr2W54t+cdM2zx78tNrVhqrDykRA7asq8yVJij1u5NDtKzKqzBSPYh7iW0svUKg76g==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-2.1.3.tgz", + "integrity": "sha512-s76LId+TwASrHhUa9QS4k/zeXDUAuNuddKklQzRgumbzge5BftVXHXIqL4wQxKGLocPwfgAOXWx+HdWhQk9hTg==", "dependencies": { - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3266,11 +3759,11 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-2.1.1.tgz", - "integrity": "sha512-KPJhRlhsl8CjgGXK/DoDcrFGfAqoqvuwlbxy+uOO4g2Azn1dhH+GVfC3RAp+6PoL5PWPb+vt6Z23FP+Mr6qeCw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-2.1.3.tgz", + "integrity": "sha512-opMFufVQgvBSld/b7mD7OOEBxF6STyraVr1xel1j0abVILM8ALJvRoFbqSWHGmaDlRGIiV9Q5cGbWi0sdiEaLQ==", "dependencies": { - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3278,13 +3771,13 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-2.2.1.tgz", - "integrity": "sha512-epzK3x1xNxA9oJgHQ5nz+2j6DsJKdHfieb+YgJ7ATWxzNcB7Hc+Uya2TUck5MicOPhDV8HZImND7ZOecVr+OWg==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-2.2.4.tgz", + "integrity": "sha512-nqazHCp8r4KHSFhRQ+T0VEkeqvA0U+RhehBSr1gunUuNW3X7j0uDrWBxB2gE9eutzy6kE3Y7L+Dov/UXT871vg==", "dependencies": { - "@smithy/property-provider": "^2.1.1", - "@smithy/shared-ini-file-loader": "^2.3.1", - "@smithy/types": "^2.9.1", + "@smithy/property-provider": "^2.1.3", + "@smithy/shared-ini-file-loader": "^2.3.4", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3292,14 +3785,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-2.3.1.tgz", - "integrity": "sha512-gLA8qK2nL9J0Rk/WEZSvgin4AppvuCYRYg61dcUo/uKxvMZsMInL5I5ZdJTogOvdfVug3N2dgI5ffcUfS4S9PA==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-2.4.1.tgz", + "integrity": "sha512-HCkb94soYhJMxPCa61wGKgmeKpJ3Gftx1XD6bcWEB2wMV1L9/SkQu/6/ysKBnbOzWRE01FGzwrTxucHypZ8rdg==", "dependencies": { - "@smithy/abort-controller": "^2.1.1", - "@smithy/protocol-http": "^3.1.1", - "@smithy/querystring-builder": "^2.1.1", - "@smithy/types": "^2.9.1", + "@smithy/abort-controller": "^2.1.3", + "@smithy/protocol-http": "^3.2.1", + "@smithy/querystring-builder": "^2.1.3", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3307,11 +3800,11 @@ } }, "node_modules/@smithy/property-provider": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-2.1.1.tgz", - "integrity": "sha512-FX7JhhD/o5HwSwg6GLK9zxrMUrGnb3PzNBrcthqHKBc3dH0UfgEAU24xnJ8F0uow5mj17UeBEOI6o3CF2k7Mhw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-2.1.3.tgz", + "integrity": "sha512-bMz3se+ySKWNrgm7eIiQMa2HO/0fl2D0HvLAdg9pTMcpgp4SqOAh6bz7Ik6y7uQqSrk4rLjIKgbQ6yzYgGehCQ==", "dependencies": { - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3319,11 +3812,11 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-3.1.1.tgz", - "integrity": "sha512-6ZRTSsaXuSL9++qEwH851hJjUA0OgXdQFCs+VDw4tGH256jQ3TjYY/i34N4vd24RV3nrjNsgd1yhb57uMoKbzQ==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-3.2.1.tgz", + "integrity": "sha512-KLrQkEw4yJCeAmAH7hctE8g9KwA7+H2nSJwxgwIxchbp/L0B5exTdOQi9D5HinPLlothoervGmhpYKelZ6AxIA==", "dependencies": { - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3331,11 +3824,11 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-2.1.1.tgz", - "integrity": "sha512-C/ko/CeEa8jdYE4gt6nHO5XDrlSJ3vdCG0ZAc6nD5ZIE7LBp0jCx4qoqp7eoutBu7VrGMXERSRoPqwi1WjCPbg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-2.1.3.tgz", + "integrity": "sha512-kFD3PnNqKELe6m9GRHQw/ftFFSZpnSeQD4qvgDB6BQN6hREHELSosVFUMPN4M3MDKN2jAwk35vXHLoDrNfKu0A==", "dependencies": { - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "@smithy/util-uri-escape": "^2.1.1", "tslib": "^2.5.0" }, @@ -3344,11 +3837,11 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-2.1.1.tgz", - "integrity": "sha512-H4+6jKGVhG1W4CIxfBaSsbm98lOO88tpDWmZLgkJpt8Zkk/+uG0FmmqMuCAc3HNM2ZDV+JbErxr0l5BcuIf/XQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-2.1.3.tgz", + "integrity": "sha512-3+CWJoAqcBMR+yvz6D+Fc5VdoGFtfenW6wqSWATWajrRMGVwJGPT3Vy2eb2bnMktJc4HU4bpjeovFa566P3knQ==", "dependencies": { - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3356,22 +3849,22 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-2.1.1.tgz", - "integrity": "sha512-txEdZxPUgM1PwGvDvHzqhXisrc5LlRWYCf2yyHfvITWioAKat7srQvpjMAvgzf0t6t7j8yHrryXU9xt7RZqFpw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-2.1.3.tgz", + "integrity": "sha512-iUrpSsem97bbXHHT/v3s7vaq8IIeMo6P6cXdeYHrx0wOJpMeBGQF7CB0mbJSiTm3//iq3L55JiEm8rA7CTVI8A==", "dependencies": { - "@smithy/types": "^2.9.1" + "@smithy/types": "^2.10.1" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-2.3.1.tgz", - "integrity": "sha512-2E2kh24igmIznHLB6H05Na4OgIEilRu0oQpYXo3LCNRrawHAcfDKq9004zJs+sAMt2X5AbY87CUCJ7IpqpSgdw==", + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-2.3.4.tgz", + "integrity": "sha512-CiZmPg9GeDKbKmJGEFvJBsJcFnh0AQRzOtQAzj1XEa8N/0/uSN/v1LYzgO7ry8hhO8+9KB7+DhSW0weqBra4Aw==", "dependencies": { - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3379,15 +3872,15 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.1.1.tgz", - "integrity": "sha512-Hb7xub0NHuvvQD3YwDSdanBmYukoEkhqBjqoxo+bSdC0ryV9cTfgmNjuAQhTPYB6yeU7hTR+sPRiFMlxqv6kmg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.1.3.tgz", + "integrity": "sha512-Jq4iPPdCmJojZTsPePn4r1ULShh6ONkokLuxp1Lnk4Sq7r7rJp4HlA1LbPBq4bD64TIzQezIpr1X+eh5NYkNxw==", "dependencies": { - "@smithy/eventstream-codec": "^2.1.1", + "@smithy/eventstream-codec": "^2.1.3", "@smithy/is-array-buffer": "^2.1.1", - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "@smithy/util-hex-encoding": "^2.1.1", - "@smithy/util-middleware": "^2.1.1", + "@smithy/util-middleware": "^2.1.3", "@smithy/util-uri-escape": "^2.1.1", "@smithy/util-utf8": "^2.1.1", "tslib": "^2.5.0" @@ -3397,15 +3890,15 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-2.3.1.tgz", - "integrity": "sha512-YsTdU8xVD64r2pLEwmltrNvZV6XIAC50LN6ivDopdt+YiF/jGH6PY9zUOu0CXD/d8GMB8gbhnpPsdrjAXHS9QA==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-2.4.2.tgz", + "integrity": "sha512-ntAFYN51zu3N3mCd95YFcFi/8rmvm//uX+HnK24CRbI6k5Rjackn0JhgKz5zOx/tbNvOpgQIwhSX+1EvEsBLbA==", "dependencies": { - "@smithy/middleware-endpoint": "^2.4.1", - "@smithy/middleware-stack": "^2.1.1", - "@smithy/protocol-http": "^3.1.1", - "@smithy/types": "^2.9.1", - "@smithy/util-stream": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.4", + "@smithy/middleware-stack": "^2.1.3", + "@smithy/protocol-http": "^3.2.1", + "@smithy/types": "^2.10.1", + "@smithy/util-stream": "^2.1.3", "tslib": "^2.5.0" }, "engines": { @@ -3413,9 +3906,9 @@ } }, "node_modules/@smithy/types": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.9.1.tgz", - "integrity": "sha512-vjXlKNXyprDYDuJ7UW5iobdmyDm6g8dDG+BFUncAg/3XJaN45Gy5RWWWUVgrzIK7S4R1KWgIX5LeJcfvSI24bw==", + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.10.1.tgz", + "integrity": "sha512-hjQO+4ru4cQ58FluQvKKiyMsFg0A6iRpGm2kqdH8fniyNd2WyanoOsYJfMX/IFLuLxEoW6gnRkNZy1y6fUUhtA==", "dependencies": { "tslib": "^2.5.0" }, @@ -3424,12 +3917,12 @@ } }, "node_modules/@smithy/url-parser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-2.1.1.tgz", - "integrity": "sha512-qC9Bv8f/vvFIEkHsiNrUKYNl8uKQnn4BdhXl7VzQRP774AwIjiSMMwkbT+L7Fk8W8rzYVifzJNYxv1HwvfBo3Q==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-2.1.3.tgz", + "integrity": "sha512-X1NRA4WzK/ihgyzTpeGvI9Wn45y8HmqF4AZ/FazwAv8V203Ex+4lXqcYI70naX9ETqbqKVzFk88W6WJJzCggTQ==", "dependencies": { - "@smithy/querystring-parser": "^2.1.1", - "@smithy/types": "^2.9.1", + "@smithy/querystring-parser": "^2.1.3", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" } }, @@ -3488,13 +3981,13 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-2.1.1.tgz", - "integrity": "sha512-lqLz/9aWRO6mosnXkArtRuQqqZBhNpgI65YDpww4rVQBuUT7qzKbDLG5AmnQTCiU4rOquaZO/Kt0J7q9Uic7MA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-2.1.4.tgz", + "integrity": "sha512-J6XAVY+/g7jf03QMnvqPyU+8jqGrrtXoKWFVOS+n1sz0Lg8HjHJ1ANqaDN+KTTKZRZlvG8nU5ZrJOUL6VdwgcQ==", "dependencies": { - "@smithy/property-provider": "^2.1.1", - "@smithy/smithy-client": "^2.3.1", - "@smithy/types": "^2.9.1", + "@smithy/property-provider": "^2.1.3", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", "bowser": "^2.11.0", "tslib": "^2.5.0" }, @@ -3503,16 +3996,16 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-2.1.1.tgz", - "integrity": "sha512-tYVrc+w+jSBfBd267KDnvSGOh4NMz+wVH7v4CClDbkdPfnjvImBZsOURncT5jsFwR9KCuDyPoSZq4Pa6+eCUrA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-2.2.3.tgz", + "integrity": "sha512-ttUISrv1uVOjTlDa3nznX33f0pthoUlP+4grhTvOzcLhzArx8qHB94/untGACOG3nlf8vU20nI2iWImfzoLkYA==", "dependencies": { - "@smithy/config-resolver": "^2.1.1", - "@smithy/credential-provider-imds": "^2.2.1", - "@smithy/node-config-provider": "^2.2.1", - "@smithy/property-provider": "^2.1.1", - "@smithy/smithy-client": "^2.3.1", - "@smithy/types": "^2.9.1", + "@smithy/config-resolver": "^2.1.4", + "@smithy/credential-provider-imds": "^2.2.4", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/property-provider": "^2.1.3", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3520,12 +4013,12 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-1.1.1.tgz", - "integrity": "sha512-sI4d9rjoaekSGEtq3xSb2nMjHMx8QXcz2cexnVyRWsy4yQ9z3kbDpX+7fN0jnbdOp0b3KSTZJZ2Yb92JWSanLw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-1.1.4.tgz", + "integrity": "sha512-/qAeHmK5l4yQ4/bCIJ9p49wDe9rwWtOzhPHblu386fwPNT3pxmodgcs9jDCV52yK9b4rB8o9Sj31P/7Vzka1cg==", "dependencies": { - "@smithy/node-config-provider": "^2.2.1", - "@smithy/types": "^2.9.1", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3544,11 +4037,11 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-2.1.1.tgz", - "integrity": "sha512-mKNrk8oz5zqkNcbcgAAepeJbmfUW6ogrT2Z2gDbIUzVzNAHKJQTYmH9jcy0jbWb+m7ubrvXKb6uMjkSgAqqsFA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-2.1.3.tgz", + "integrity": "sha512-/+2fm7AZ2ozl5h8wM++ZP0ovE9/tiUUAHIbCfGfb3Zd3+Dyk17WODPKXBeJ/TnK5U+x743QmA0xHzlSm8I/qhw==", "dependencies": { - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3556,12 +4049,12 @@ } }, "node_modules/@smithy/util-retry": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-2.1.1.tgz", - "integrity": "sha512-Mg+xxWPTeSPrthpC5WAamJ6PW4Kbo01Fm7lWM1jmGRvmrRdsd3192Gz2fBXAMURyXpaNxyZf6Hr/nQ4q70oVEA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-2.1.3.tgz", + "integrity": "sha512-Kbvd+GEMuozbNUU3B89mb99tbufwREcyx2BOX0X2+qHjq6Gvsah8xSDDgxISDwcOHoDqUWO425F0Uc/QIRhYkg==", "dependencies": { - "@smithy/service-error-classification": "^2.1.1", - "@smithy/types": "^2.9.1", + "@smithy/service-error-classification": "^2.1.3", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -3569,13 +4062,13 @@ } }, "node_modules/@smithy/util-stream": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-2.1.1.tgz", - "integrity": "sha512-J7SMIpUYvU4DQN55KmBtvaMc7NM3CZ2iWICdcgaovtLzseVhAqFRYqloT3mh0esrFw+3VEK6nQFteFsTqZSECQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-2.1.3.tgz", + "integrity": "sha512-HvpEQbP8raTy9n86ZfXiAkf3ezp1c3qeeO//zGqwZdrfaoOpGKQgF2Sv1IqZp7wjhna7pvczWaGUHjcOPuQwKw==", "dependencies": { - "@smithy/fetch-http-handler": "^2.4.1", - "@smithy/node-http-handler": "^2.3.1", - "@smithy/types": "^2.9.1", + "@smithy/fetch-http-handler": "^2.4.3", + "@smithy/node-http-handler": "^2.4.1", + "@smithy/types": "^2.10.1", "@smithy/util-base64": "^2.1.1", "@smithy/util-buffer-from": "^2.1.1", "@smithy/util-hex-encoding": "^2.1.1", @@ -3609,6 +4102,19 @@ "node": ">=14.0.0" } }, + "node_modules/@smithy/util-waiter": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-2.1.3.tgz", + "integrity": "sha512-3R0wNFAQQoH9e4m+bVLDYNOst2qNxtxFgq03WoNHWTBOqQT3jFnOBRj1W51Rf563xDA5kwqjziksxn6RKkHB+Q==", + "dependencies": { + "@smithy/abort-controller": "^2.1.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@swc/core": { "version": "1.3.107", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.107.tgz", @@ -10028,6 +10534,14 @@ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, + "node_modules/pg-cursor": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.10.3.tgz", + "integrity": "sha512-rDyBVoqPVnx/PTmnwQAYgusSeAKlTL++gmpf5klVK+mYMFEqsOc6VHHZnPKc/4lOvr4r6fiMuoxSFuBF1dx4FQ==", + "peerDependencies": { + "pg": "^8" + } + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -10058,6 +10572,17 @@ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz", "integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==" }, + "node_modules/pg-query-stream": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.5.3.tgz", + "integrity": "sha512-ufa94r/lHJdjAm3+zPZEO0gXAmCb4tZPaOt7O76mjcxdL/HxwTuryy76km+u0odBBgtfdKFYq/9XGfiYeQF0yA==", + "dependencies": { + "pg-cursor": "^2.10.3" + }, + "peerDependencies": { + "pg": "^8" + } + }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 954f1181d..5c22dc237 100644 --- a/backend/package.json +++ b/backend/package.json @@ -70,6 +70,7 @@ "vitest": "^1.2.2" }, "dependencies": { + "@aws-sdk/client-iam": "^3.525.0", "@aws-sdk/client-secrets-manager": "^3.504.0", "@casl/ability": "^6.5.0", "@fastify/cookie": "^9.3.1", @@ -106,6 +107,7 @@ "knex": "^3.0.1", "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", + "ms": "^2.1.3", "mysql2": "^3.9.1", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", @@ -115,6 +117,7 @@ "passport-google-oauth20": "^2.0.0", "passport-ldapauth": "^3.0.1", "pg": "^8.11.3", + "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", "posthog-node": "^3.6.2", diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 2e8b2611d..31b80e0e3 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -32,6 +32,9 @@ import { TIdentityOrgMemberships, TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsUpdate, + TIdentityProjectMembershipRole, + TIdentityProjectMembershipRoleInsert, + TIdentityProjectMembershipRoleUpdate, TIdentityProjectMemberships, TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate, @@ -83,6 +86,9 @@ import { TProjects, TProjectsInsert, TProjectsUpdate, + TProjectUserMembershipRoles, + TProjectUserMembershipRolesInsert, + TProjectUserMembershipRolesUpdate, TSamlConfigs, TSamlConfigsInsert, TSamlConfigsUpdate, @@ -221,6 +227,11 @@ declare module "knex/types/tables" { TProjectEnvironmentsUpdate >; [TableName.ProjectBot]: Knex.CompositeTableType; + [TableName.ProjectUserMembershipRole]: Knex.CompositeTableType< + TProjectUserMembershipRoles, + TProjectUserMembershipRolesInsert, + TProjectUserMembershipRolesUpdate + >; [TableName.ProjectRoles]: Knex.CompositeTableType; [TableName.ProjectKeys]: Knex.CompositeTableType; [TableName.Secret]: Knex.CompositeTableType; @@ -272,6 +283,11 @@ declare module "knex/types/tables" { TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate >; + [TableName.IdentityProjectMembershipRole]: Knex.CompositeTableType< + TIdentityProjectMembershipRole, + TIdentityProjectMembershipRoleInsert, + TIdentityProjectMembershipRoleUpdate + >; [TableName.ScimToken]: Knex.CompositeTableType; [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< TSecretApprovalPolicies, diff --git a/backend/src/db/migrations/20240312162549_temp-roles.ts b/backend/src/db/migrations/20240312162549_temp-roles.ts new file mode 100644 index 000000000..ec78821fe --- /dev/null +++ b/backend/src/db/migrations/20240312162549_temp-roles.ts @@ -0,0 +1,50 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const doesTableExist = await knex.schema.hasTable(TableName.ProjectUserMembershipRole); + if (!doesTableExist) { + await knex.schema.createTable(TableName.ProjectUserMembershipRole, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("role").notNullable(); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + // until role is changed/removed the role should not deleted + t.uuid("customRoleId"); + t.foreign("customRoleId").references("id").inTable(TableName.ProjectRoles); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole); + + const projectMemberships = await knex(TableName.ProjectMembership).select( + "id", + "role", + "createdAt", + "updatedAt", + knex.ref("roleId").withSchema(TableName.ProjectMembership).as("customRoleId") + ); + if (projectMemberships.length) + await knex.batchInsert( + TableName.ProjectUserMembershipRole, + projectMemberships.map((data) => ({ ...data, projectMembershipId: data.id })) + ); + // will be dropped later + // await knex.schema.alterTable(TableName.ProjectMembership, (t) => { + // t.dropColumn("roleId"); + // t.dropColumn("role"); + // }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ProjectUserMembershipRole); + await dropOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole); +} diff --git a/backend/src/db/migrations/20240312162556_temp-role-identity.ts b/backend/src/db/migrations/20240312162556_temp-role-identity.ts new file mode 100644 index 000000000..dbd188d89 --- /dev/null +++ b/backend/src/db/migrations/20240312162556_temp-role-identity.ts @@ -0,0 +1,52 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const doesTableExist = await knex.schema.hasTable(TableName.IdentityProjectMembershipRole); + if (!doesTableExist) { + await knex.schema.createTable(TableName.IdentityProjectMembershipRole, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("role").notNullable(); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId") + .references("id") + .inTable(TableName.IdentityProjectMembership) + .onDelete("CASCADE"); + // until role is changed/removed the role should not deleted + t.uuid("customRoleId"); + t.foreign("customRoleId").references("id").inTable(TableName.ProjectRoles); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityProjectMembershipRole); + + const identityMemberships = await knex(TableName.IdentityProjectMembership).select( + "id", + "role", + "createdAt", + "updatedAt", + knex.ref("roleId").withSchema(TableName.IdentityProjectMembership).as("customRoleId") + ); + if (identityMemberships.length) + await knex.batchInsert( + TableName.IdentityProjectMembershipRole, + identityMemberships.map((data) => ({ ...data, projectMembershipId: data.id })) + ); + // await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => { + // t.dropColumn("roleId"); + // t.dropColumn("role"); + // }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityProjectMembershipRole); + await dropOnUpdateTrigger(knex, TableName.IdentityProjectMembershipRole); +} diff --git a/backend/src/db/schemas/identity-project-membership-role.ts b/backend/src/db/schemas/identity-project-membership-role.ts new file mode 100644 index 000000000..90a0a3538 --- /dev/null +++ b/backend/src/db/schemas/identity-project-membership-role.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityProjectMembershipRoleSchema = z.object({ + id: z.string().uuid(), + role: z.string(), + projectMembershipId: z.string().uuid(), + customRoleId: z.string().uuid().nullable().optional(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityProjectMembershipRole = z.infer; +export type TIdentityProjectMembershipRoleInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TIdentityProjectMembershipRoleUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index fb717d344..001fdbf18 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -8,6 +8,7 @@ export * from "./git-app-org"; export * from "./identities"; export * from "./identity-access-tokens"; export * from "./identity-org-memberships"; +export * from "./identity-project-membership-role"; export * from "./identity-project-memberships"; export * from "./identity-ua-client-secrets"; export * from "./identity-universal-auths"; @@ -25,6 +26,7 @@ export * from "./project-environments"; export * from "./project-keys"; export * from "./project-memberships"; export * from "./project-roles"; +export * from "./project-user-membership-roles"; export * from "./projects"; export * from "./saml-configs"; export * from "./scim-tokens"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index c85aad66a..f85feff9c 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -20,6 +20,7 @@ export enum TableName { Environment = "project_environments", ProjectMembership = "project_memberships", ProjectRoles = "project_roles", + ProjectUserMembershipRole = "project_user_membership_roles", ProjectKeys = "project_keys", Secret = "secrets", SecretBlindIndex = "secret_blind_indexes", @@ -41,6 +42,7 @@ export enum TableName { IdentityUaClientSecret = "identity_ua_client_secrets", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", + IdentityProjectMembershipRole = "identity_project_membership_role", ScimToken = "scim_tokens", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", diff --git a/backend/src/db/schemas/project-user-membership-roles.ts b/backend/src/db/schemas/project-user-membership-roles.ts new file mode 100644 index 000000000..bc7b67208 --- /dev/null +++ b/backend/src/db/schemas/project-user-membership-roles.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProjectUserMembershipRolesSchema = z.object({ + id: z.string().uuid(), + role: z.string(), + projectMembershipId: z.string().uuid(), + customRoleId: z.string().uuid().nullable().optional(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProjectUserMembershipRoles = z.infer; +export type TProjectUserMembershipRolesInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TProjectUserMembershipRolesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/seeds/3-project.ts b/backend/src/db/seeds/3-project.ts index fea71b557..d41efb71c 100644 --- a/backend/src/db/seeds/3-project.ts +++ b/backend/src/db/seeds/3-project.ts @@ -4,7 +4,7 @@ import { Knex } from "knex"; import { encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; -import { OrgMembershipRole, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; +import { ProjectMembershipRole, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; import { buildUserProjectKey, getUserPrivateKey, seedData1 } from "../seed-data"; export const DEFAULT_PROJECT_ENVS = [ @@ -30,10 +30,16 @@ export async function seed(knex: Knex): Promise { }) .returning("*"); - await knex(TableName.ProjectMembership).insert({ - projectId: project.id, - role: OrgMembershipRole.Admin, - userId: seedData1.id + const projectMembership = await knex(TableName.ProjectMembership) + .insert({ + projectId: project.id, + userId: seedData1.id, + role: ProjectMembershipRole.Admin + }) + .returning("*"); + await knex(TableName.ProjectUserMembershipRole).insert({ + role: ProjectMembershipRole.Admin, + projectMembershipId: projectMembership[0].id }); const user = await knex(TableName.UserEncryptionKey).where({ userId: seedData1.id }).first(); diff --git a/backend/src/db/seeds/4-machine-identity.ts b/backend/src/db/seeds/4-machine-identity.ts index 149fba40a..618c47114 100644 --- a/backend/src/db/seeds/4-machine-identity.ts +++ b/backend/src/db/seeds/4-machine-identity.ts @@ -75,9 +75,16 @@ export async function seed(knex: Knex): Promise { } ]); - await knex(TableName.IdentityProjectMembership).insert({ - identityId: seedData1.machineIdentity.id, + const identityProjectMembership = await knex(TableName.IdentityProjectMembership) + .insert({ + identityId: seedData1.machineIdentity.id, + projectId: seedData1.project.id, + role: ProjectMembershipRole.Admin + }) + .returning("*"); + + await knex(TableName.IdentityProjectMembershipRole).insert({ role: ProjectMembershipRole.Admin, - projectId: seedData1.project.id + projectMembershipId: identityProjectMembership[0].id }); } diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 46b80e7a9..1e40d2d80 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -1,6 +1,7 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -13,7 +14,17 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { organizationId: z.string().trim() }), body: z.object({ - slug: z.string().trim(), + slug: z + .string() + .min(1) + .trim() + .refine( + (val) => Object.keys(OrgMembershipRole).includes(val), + "Please choose a different slug, the slug you have entered is reserved" + ) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid" + }), name: z.string().trim(), description: z.string().trim().optional(), permissions: z.any().array() @@ -45,7 +56,17 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim() }), body: z.object({ - slug: z.string().trim().optional(), + slug: z + .string() + .trim() + .optional() + .refine( + (val) => typeof val === "undefined" || Object.keys(OrgMembershipRole).includes(val), + "Please choose a different slug, the slug you have entered is reserved." + ) + .refine((val) => typeof val === "undefined" || slugify(val) === val, { + message: "Slug must be a valid" + }), name: z.string().trim().optional(), description: z.string().trim().optional(), permissions: z.any().array() diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index ea195bc06..d94589b43 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -1,7 +1,9 @@ +import { z } from "zod"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { IdentityProjectMembershipRoleSchema, ProjectUserMembershipRolesSchema, TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { selectAllTableCols } from "@app/lib/knex"; +import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TPermissionDALFactory = ReturnType; @@ -43,21 +45,72 @@ export const permissionDALFactory = (db: TDbClient) => { const getProjectPermission = async (userId: string, projectId: string) => { try { - const membership = await db(TableName.ProjectMembership) - .leftJoin(TableName.ProjectRoles, `${TableName.ProjectMembership}.roleId`, `${TableName.ProjectRoles}.id`) + const docs = await db(TableName.ProjectMembership) + .join( + TableName.ProjectUserMembershipRole, + `${TableName.ProjectUserMembershipRole}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectUserMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) .where("userId", userId) .where(`${TableName.ProjectMembership}.projectId`, projectId) - .select(selectAllTableCols(TableName.ProjectMembership)) + .select(selectAllTableCols(TableName.ProjectUserMembershipRole)) .select( + db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"), + // TODO(roll-forward-migration): remove this field when we drop this in next migration after a week + db.ref("role").withSchema(TableName.ProjectMembership).as("oldRoleField"), + db.ref("createdAt").withSchema(TableName.ProjectMembership).as("membershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), - db.ref("orgId").withSchema(TableName.Project) + db.ref("orgId").withSchema(TableName.Project), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug") ) - .select("permissions") - .first(); + .select("permissions"); - return membership; + const permission = sqlNestRelationships({ + data: docs, + key: "membershipId", + parentMapper: ({ + orgId, + orgAuthEnforced, + membershipId, + membershipCreatedAt, + membershipUpdatedAt, + oldRoleField + }) => ({ + orgId, + orgAuthEnforced, + userId, + role: oldRoleField, + 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) + } + ] + }); + // when introducting cron mode change it here + const activeRoles = permission?.[0]?.roles.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ); + return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined; } catch (error) { throw new DatabaseError({ error, name: "GetProjectPermission" }); } @@ -65,18 +118,62 @@ export const permissionDALFactory = (db: TDbClient) => { const getProjectIdentityPermission = async (identityId: string, projectId: string) => { try { - const membership = await db(TableName.IdentityProjectMembership) + const docs = await db(TableName.IdentityProjectMembership) + .join( + TableName.IdentityProjectMembershipRole, + `${TableName.IdentityProjectMembershipRole}.projectMembershipId`, + `${TableName.IdentityProjectMembership}.id` + ) .leftJoin( TableName.ProjectRoles, - `${TableName.IdentityProjectMembership}.roleId`, + `${TableName.IdentityProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) .where("identityId", identityId) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) - .select(selectAllTableCols(TableName.IdentityProjectMembership)) - .select("permissions") - .first(); - return membership; + .select(selectAllTableCols(TableName.IdentityProjectMembershipRole)) + .select( + db.ref("id").withSchema(TableName.IdentityProjectMembership).as("membershipId"), + db.ref("role").withSchema(TableName.IdentityProjectMembership).as("oldRoleField"), + db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug") + ) + .select("permissions"); + + const permission = sqlNestRelationships({ + data: docs, + key: "membershipId", + parentMapper: ({ membershipId, membershipCreatedAt, membershipUpdatedAt, oldRoleField }) => ({ + id: membershipId, + identityId, + projectId, + role: oldRoleField, + createdAt: membershipCreatedAt, + updatedAt: membershipUpdatedAt, + // just a prefilled value + orgAuthEnforced: false, + orgId: "" + }), + childrenMapper: [ + { + key: "id", + label: "roles" as const, + mapper: (data) => + IdentityProjectMembershipRoleSchema.extend({ + permissions: z.unknown(), + customRoleSlug: z.string().optional().nullable() + }).parse(data) + } + ] + }); + + // when introducting cron mode change it here + const activeRoles = permission?.[0]?.roles.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ); + return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined; } catch (error) { throw new DatabaseError({ error, name: "GetProjectIdentityPermission" }); } diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 67db2473c..c7dcf4b8c 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -18,6 +18,7 @@ import { TServiceTokenDALFactory } from "@app/services/service-token/service-tok import { orgAdminPermissions, orgMemberPermissions, orgNoAccessPermissions, OrgPermissionSet } from "./org-permission"; import { TPermissionDALFactory } from "./permission-dal"; +import { TBuildProjectPermissionDTO } from "./permission-types"; import { buildServiceTokenProjectPermission, projectAdminPermissions, @@ -64,31 +65,35 @@ export const permissionServiceFactory = ({ } }; - const buildProjectPermission = (role: string, permission?: unknown) => { - switch (role) { - case ProjectMembershipRole.Admin: - return projectAdminPermissions; - case ProjectMembershipRole.Member: - return projectMemberPermissions; - case ProjectMembershipRole.Viewer: - return projectViewerPermission; - case ProjectMembershipRole.NoAccess: - return projectNoAccessPermissions; - case ProjectMembershipRole.Custom: - return createMongoAbility( - unpackRules>>( - permission as PackRule>>[] - ), - { - conditionsMatcher + const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => { + const rules = projectUserRoles + .map(({ role, permissions }) => { + switch (role) { + case ProjectMembershipRole.Admin: + return projectAdminPermissions; + case ProjectMembershipRole.Member: + return projectMemberPermissions; + case ProjectMembershipRole.Viewer: + return projectViewerPermission; + case ProjectMembershipRole.NoAccess: + return projectNoAccessPermissions; + case ProjectMembershipRole.Custom: { + return unpackRules>>( + permissions as PackRule>>[] + ); } - ); - default: - throw new BadRequestError({ - name: "ProjectRoleInvalid", - message: "Project role not found" - }); - } + default: + throw new BadRequestError({ + name: "ProjectRoleInvalid", + message: "Project role not found" + }); + } + }) + .reduce((curr, prev) => prev.concat(curr), []); + + return createMongoAbility(rules, { + conditionsMatcher + }); }; /* @@ -145,33 +150,56 @@ export const permissionServiceFactory = ({ }; // user permission for a project in an organization - const getUserProjectPermission = async (userId: string, projectId: string, userOrgId?: string) => { - const membership = await permissionDAL.getProjectPermission(userId, projectId); - if (!membership) throw new UnauthorizedError({ name: "User not in project" }); - if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { + const getUserProjectPermission = async ( + userId: string, + projectId: string, + userOrgId?: string + ): Promise> => { + const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId); + if (!userProjectPermission) throw new UnauthorizedError({ name: "User not in project" }); + + if ( + userProjectPermission.roles.some(({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions) + ) { throw new BadRequestError({ name: "Custom permission not found" }); } - if (membership.orgAuthEnforced && membership.orgId !== userOrgId) { + if (userProjectPermission.orgAuthEnforced && userProjectPermission.orgId !== userOrgId) { throw new BadRequestError({ name: "Cannot access org-scoped resource" }); } return { - permission: buildProjectPermission(membership.role, membership.permissions), - membership + permission: buildProjectPermission(userProjectPermission.roles), + membership: userProjectPermission, + hasRole: (role: string) => + userProjectPermission.roles.findIndex( + ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug + ) !== -1 }; }; - const getIdentityProjectPermission = async (identityId: string, projectId: string) => { - const membership = await permissionDAL.getProjectIdentityPermission(identityId, projectId); - if (!membership) throw new UnauthorizedError({ name: "Identity not in project" }); - if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { + const getIdentityProjectPermission = async ( + identityId: string, + projectId: string + ): Promise> => { + const identityProjectPermission = await permissionDAL.getProjectIdentityPermission(identityId, projectId); + if (!identityProjectPermission) throw new UnauthorizedError({ name: "Identity not in project" }); + + if ( + identityProjectPermission.roles.some( + ({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions + ) + ) { throw new BadRequestError({ name: "Custom permission not found" }); } return { - permission: buildProjectPermission(membership.role, membership.permissions), - membership + permission: buildProjectPermission(identityProjectPermission.roles), + membership: identityProjectPermission, + hasRole: (role: string) => + identityProjectPermission.roles.findIndex( + ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug + ) !== -1 }; }; @@ -191,14 +219,19 @@ export const permissionServiceFactory = ({ }; type TProjectPermissionRT = T extends ActorType.SERVICE - ? { permission: MongoAbility; membership: undefined } + ? { + permission: MongoAbility; + membership: undefined; + hasRole: (arg: string) => boolean; + } // service token doesn't have both membership and roles : { permission: MongoAbility; membership: (T extends ActorType.USER ? TProjectMemberships : TIdentityProjectMemberships) & { - orgAuthEnforced: boolean; + orgAuthEnforced: boolean | null | undefined; orgId: string; - permissions?: unknown; + roles: Array<{ role: string }>; }; + hasRole: (role: string) => boolean; }; const getProjectPermission = async ( @@ -228,11 +261,13 @@ export const permissionServiceFactory = ({ const projectRole = await projectRoleDAL.findOne({ slug: role, projectId }); if (!projectRole) throw new BadRequestError({ message: "Role not found" }); return { - permission: buildProjectPermission(ProjectMembershipRole.Custom, projectRole.permissions), + permission: buildProjectPermission([ + { role: ProjectMembershipRole.Custom, permissions: projectRole.permissions } + ]), role: projectRole }; } - return { permission: buildProjectPermission(role, []) }; + return { permission: buildProjectPermission([{ role, permissions: [] }]) }; }; return { diff --git a/backend/src/ee/services/permission/permission-types.ts b/backend/src/ee/services/permission/permission-types.ts index e69de29bb..a35958ffd 100644 --- a/backend/src/ee/services/permission/permission-types.ts +++ b/backend/src/ee/services/permission/permission-types.ts @@ -0,0 +1,4 @@ +export type TBuildProjectPermissionDTO = { + 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 5245c26e4..46dbdcc3b 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -56,8 +56,8 @@ export type ProjectPermissionSet = | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]; -const buildAdminPermission = () => { - const { can, build } = new AbilityBuilder>(createMongoAbility); +const buildAdminPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); @@ -135,13 +135,13 @@ const buildAdminPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Project); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); - return build({ conditionsMatcher }); + return rules; }; -export const projectAdminPermissions = buildAdminPermission(); +export const projectAdminPermissions = buildAdminPermissionRules(); -const buildMemberPermission = () => { - const { can, build } = new AbilityBuilder>(createMongoAbility); +const buildMemberPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); @@ -196,13 +196,13 @@ const buildMemberPermission = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); - return build({ conditionsMatcher }); + return rules; }; -export const projectMemberPermissions = buildMemberPermission(); +export const projectMemberPermissions = buildMemberPermissionRules(); -const buildViewerPermission = () => { - const { can, build } = new AbilityBuilder>(createMongoAbility); +const buildViewerPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); @@ -220,14 +220,14 @@ const buildViewerPermission = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); - return build({ conditionsMatcher }); + return rules; }; -export const projectViewerPermission = buildViewerPermission(); +export const projectViewerPermission = buildViewerPermissionRules(); const buildNoAccessProjectPermission = () => { - const { build } = new AbilityBuilder>(createMongoAbility); - return build({ conditionsMatcher }); + const { rules } = new AbilityBuilder>(createMongoAbility); + return rules; }; export const buildServiceTokenProjectPermission = ( diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index d2203fea3..b48b6bf95 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -129,14 +129,14 @@ export const secretApprovalRequestServiceFactory = ({ if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); const { policy } = secretApprovalRequest; - const { membership } = await permissionService.getProjectPermission( + const { membership, hasRole } = await permissionService.getProjectPermission( actor, actorId, secretApprovalRequest.projectId, actorOrgId ); if ( - membership.role !== ProjectMembershipRole.Admin && + !hasRole(ProjectMembershipRole.Admin) && secretApprovalRequest.committerId !== membership.id && !policy.approvers.find((approverId) => approverId === membership.id) ) { @@ -156,14 +156,14 @@ export const secretApprovalRequestServiceFactory = ({ if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); const { policy } = secretApprovalRequest; - const { membership } = await permissionService.getProjectPermission( + const { membership, hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, secretApprovalRequest.projectId, actorOrgId ); if ( - membership.role !== ProjectMembershipRole.Admin && + !hasRole(ProjectMembershipRole.Admin) && secretApprovalRequest.committerId !== membership.id && !policy.approvers.find((approverId) => approverId === membership.id) ) { @@ -198,14 +198,14 @@ export const secretApprovalRequestServiceFactory = ({ if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); const { policy } = secretApprovalRequest; - const { membership } = await permissionService.getProjectPermission( + const { membership, hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, secretApprovalRequest.projectId, actorOrgId ); if ( - membership.role !== ProjectMembershipRole.Admin && + !hasRole(ProjectMembershipRole.Admin) && secretApprovalRequest.committerId !== membership.id && !policy.approvers.find((approverId) => approverId === membership.id) ) { @@ -236,9 +236,14 @@ export const secretApprovalRequestServiceFactory = ({ if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); const { policy, folderId, projectId } = secretApprovalRequest; - const { membership } = await permissionService.getProjectPermission(ActorType.USER, actorId, projectId, actorOrgId); + const { membership, hasRole } = await permissionService.getProjectPermission( + ActorType.USER, + actorId, + projectId, + actorOrgId + ); if ( - membership.role !== ProjectMembershipRole.Admin && + !hasRole(ProjectMembershipRole.Admin) && secretApprovalRequest.committerId !== membership.id && !policy.approvers.find((approverId) => approverId === membership.id) ) { diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts index 330f6beca..140a9b671 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts @@ -1,3 +1,10 @@ +import { + CreateAccessKeyCommand, + DeleteAccessKeyCommand, + GetAccessKeyLastUsedCommand, + IAMClient +} from "@aws-sdk/client-iam"; + import { SecretKeyEncoding, SecretType } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { @@ -18,7 +25,12 @@ import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { TSecretRotationDALFactory } from "../secret-rotation-dal"; import { rotationTemplates } from "../templates"; -import { TDbProviderClients, TProviderFunctionTypes, TSecretRotationProviderTemplate } from "../templates/types"; +import { + TAwsProviderSystems, + TDbProviderClients, + TProviderFunctionTypes, + TSecretRotationProviderTemplate +} from "../templates/types"; import { getDbSetQuery, secretRotationDbFn, @@ -127,7 +139,10 @@ export const secretRotationQueueFactory = ({ internal: {} }; - // when its a database we keep cycling the variables accordingly + /* Rotation Function For Database + * A database like sql cannot have multiple password for a user + * thus we ask users to create two users with required permission and then we keep cycling between these two db users + */ if (provider.template.type === TProviderFunctionTypes.DB) { const lastCred = variables.creds.at(-1); if (lastCred && variables.creds.length === 1) { @@ -170,6 +185,65 @@ export const secretRotationQueueFactory = ({ if (variables.creds.length === 2) variables.creds.pop(); } + /* + * Rotation Function For AWS Services + * Due to complexity in AWS Authorization hashing signature process we keep it as seperate entity instead of http template mode + * We first delete old key before creating a new one because aws iam has a quota limit of 2 keys + * */ + if (provider.template.type === TProviderFunctionTypes.AWS) { + if (provider.template.client === TAwsProviderSystems.IAM) { + const client = new IAMClient({ + region: newCredential.inputs.manager_user_aws_region as string, + credentials: { + accessKeyId: newCredential.inputs.manager_user_access_key as string, + secretAccessKey: newCredential.inputs.manager_user_secret_key as string + } + }); + + const iamUserName = newCredential.inputs.iam_username as string; + + if (variables.creds.length === 2) { + const deleteCycleCredential = variables.creds.pop(); + if (deleteCycleCredential) { + const deletedIamAccessKey = await client.send( + new DeleteAccessKeyCommand({ + UserName: iamUserName, + AccessKeyId: deleteCycleCredential.outputs.iam_user_access_key as string + }) + ); + + if ( + !deletedIamAccessKey?.$metadata?.httpStatusCode || + deletedIamAccessKey?.$metadata?.httpStatusCode > 300 + ) { + throw new DisableRotationErrors({ + message: "Failed to delete aws iam access key. Check managed iam user policy" + }); + } + } + } + + const newIamAccessKey = await client.send(new CreateAccessKeyCommand({ UserName: iamUserName })); + if (!newIamAccessKey.AccessKey) + throw new DisableRotationErrors({ message: "Failed to create access key. Check managed iam user policy" }); + + // test + const testAccessKey = await client.send( + new GetAccessKeyLastUsedCommand({ AccessKeyId: newIamAccessKey.AccessKey.AccessKeyId }) + ); + if (testAccessKey?.UserName !== iamUserName) + throw new DisableRotationErrors({ message: "Failed to create access key. Check managed iam user policy" }); + + newCredential.outputs.iam_user_access_key = newIamAccessKey.AccessKey.AccessKeyId; + newCredential.outputs.iam_user_secret_key = newIamAccessKey.AccessKey.SecretAccessKey; + } + } + + /* Rotation function of HTTP infisical template + * This is a generic http based template system for rotation + * we use this for sendgrid and for custom secret rotation + * This will ensure user provided rotation is easier to make + * */ if (provider.template.type === TProviderFunctionTypes.HTTP) { if (provider.template.functions.set?.pre) { secretRotationPreSetFn(provider.template.functions.set.pre, newCredential); @@ -185,6 +259,9 @@ export const secretRotationQueueFactory = ({ } } } + + // insert the new variables to start + // encrypt the data - save it variables.creds.unshift({ outputs: newCredential.outputs, internal: newCredential.internal @@ -200,6 +277,7 @@ export const secretRotationQueueFactory = ({ key ) })); + // map the final values to output keys in the board await secretRotationDAL.transaction(async (tx) => { await secretRotationDAL.updateById( rotationId, diff --git a/backend/src/ee/services/secret-rotation/templates/aws-iam.ts b/backend/src/ee/services/secret-rotation/templates/aws-iam.ts new file mode 100644 index 000000000..d4506c26e --- /dev/null +++ b/backend/src/ee/services/secret-rotation/templates/aws-iam.ts @@ -0,0 +1,21 @@ +import { TAwsProviderSystems, TProviderFunctionTypes } from "./types"; + +export const AWS_IAM_TEMPLATE = { + type: TProviderFunctionTypes.AWS as const, + client: TAwsProviderSystems.IAM, + inputs: { + type: "object" as const, + properties: { + manager_user_access_key: { type: "string" as const }, + manager_user_secret_key: { type: "string" as const }, + manager_user_aws_region: { type: "string" as const }, + iam_username: { type: "string" as const } + }, + required: ["manager_user_access_key", "manager_user_secret_key", "manager_user_aws_region", "iam_username"], + additionalProperties: false + }, + outputs: { + iam_user_access_key: { type: "string" }, + iam_user_secret_key: { type: "string" } + } +}; diff --git a/backend/src/ee/services/secret-rotation/templates/index.ts b/backend/src/ee/services/secret-rotation/templates/index.ts index 3d9fb2298..05811d5bd 100644 --- a/backend/src/ee/services/secret-rotation/templates/index.ts +++ b/backend/src/ee/services/secret-rotation/templates/index.ts @@ -1,3 +1,4 @@ +import { AWS_IAM_TEMPLATE } from "./aws-iam"; import { MYSQL_TEMPLATE } from "./mysql"; import { POSTGRES_TEMPLATE } from "./postgres"; import { SENDGRID_TEMPLATE } from "./sendgrid"; @@ -24,5 +25,12 @@ export const rotationTemplates: TSecretRotationProviderTemplate[] = [ image: "mysql.png", description: "Rotate MySQL@7/MariaDB user credentials", template: MYSQL_TEMPLATE + }, + { + name: "aws-iam", + title: "AWS IAM", + image: "aws-iam.svg", + description: "Rotate AWS IAM User credentials", + template: AWS_IAM_TEMPLATE } ]; diff --git a/backend/src/ee/services/secret-rotation/templates/types.ts b/backend/src/ee/services/secret-rotation/templates/types.ts index cb48a7782..690b6ccf0 100644 --- a/backend/src/ee/services/secret-rotation/templates/types.ts +++ b/backend/src/ee/services/secret-rotation/templates/types.ts @@ -1,6 +1,7 @@ export enum TProviderFunctionTypes { HTTP = "http", - DB = "database" + DB = "database", + AWS = "aws" } export enum TDbProviderClients { @@ -10,6 +11,10 @@ export enum TDbProviderClients { MySql = "mysql" } +export enum TAwsProviderSystems { + IAM = "iam" +} + export enum TAssignOp { Direct = "direct", JmesPath = "jmesopath" @@ -42,7 +47,7 @@ export type TSecretRotationProviderTemplate = { title: string; image?: string; description?: string; - template: THttpProviderTemplate | TDbProviderTemplate; + template: THttpProviderTemplate | TDbProviderTemplate | TAwsProviderTemplate; }; export type THttpProviderTemplate = { @@ -70,3 +75,14 @@ export type TDbProviderTemplate = { }; outputs: Record; }; + +export type TAwsProviderTemplate = { + type: TProviderFunctionTypes.AWS; + client: TAwsProviderSystems; + inputs: { + type: "object"; + properties: Record; + required?: string[]; + }; + outputs: Record; +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index e7a0118d7..0a49806bd 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -53,6 +53,7 @@ import { identityServiceFactory } from "@app/services/identity/identity-service" import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; +import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal"; import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal"; import { identityUaDALFactory } from "@app/services/identity-ua/identity-ua-dal"; @@ -78,6 +79,7 @@ import { projectKeyDALFactory } from "@app/services/project-key/project-key-dal" import { projectKeyServiceFactory } from "@app/services/project-key/project-key-service"; import { projectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { projectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service"; +import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal"; import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal"; import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service"; import { secretDALFactory } from "@app/services/secret/secret-dal"; @@ -141,6 +143,7 @@ export const registerRoutes = async ( const projectDAL = projectDALFactory(db); const projectMembershipDAL = projectMembershipDALFactory(db); + const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(db); const projectRoleDAL = projectRoleDALFactory(db); const projectEnvDAL = projectEnvDALFactory(db); const projectKeyDAL = projectKeyDALFactory(db); @@ -164,6 +167,7 @@ export const registerRoutes = async ( const identityAccessTokenDAL = identityAccessTokenDALFactory(db); const identityOrgMembershipDAL = identityOrgDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db); + const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db); const identityUaDAL = identityUaDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); @@ -321,6 +325,7 @@ export const registerRoutes = async ( const projectMembershipService = projectMembershipServiceFactory({ projectMembershipDAL, + projectUserMembershipRoleDAL, projectDAL, permissionService, projectBotDAL, @@ -352,7 +357,8 @@ export const registerRoutes = async ( projectBotDAL, projectMembershipDAL, secretApprovalRequestDAL, - secretApprovalSecretDAL: sarSecretDAL + secretApprovalSecretDAL: sarSecretDAL, + projectUserMembershipRoleDAL }); const projectService = projectServiceFactory({ @@ -369,8 +375,11 @@ export const registerRoutes = async ( orgService, projectMembershipDAL, folderDAL, - licenseService + licenseService, + projectUserMembershipRoleDAL, + identityProjectMembershipRoleDAL }); + const projectEnvService = projectEnvServiceFactory({ permissionService, projectEnvDAL, @@ -521,7 +530,9 @@ export const registerRoutes = async ( permissionService, projectDAL, identityProjectDAL, - identityOrgMembershipDAL + identityOrgMembershipDAL, + identityProjectMembershipRoleDAL, + projectRoleDAL }); const identityUaService = identityUaServiceFactory({ identityOrgMembershipDAL, diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index afa2135c0..999a5d025 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -1,15 +1,17 @@ +import ms from "ms"; import { z } from "zod"; import { OrgMembershipsSchema, - ProjectMembershipRole, ProjectMembershipsSchema, + ProjectUserMembershipRolesSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; export const registerProjectMembershipRouter = async (server: FastifyZodProvider) => { server.route({ @@ -28,16 +30,31 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider }), response: { 200: z.object({ - memberships: ProjectMembershipsSchema.merge( - z.object({ - user: UsersSchema.pick({ - email: true, - firstName: true, - lastName: true, - id: true - }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) - }) - ) + memberships: ProjectMembershipsSchema.omit({ role: true }) + .merge( + z.object({ + user: UsersSchema.pick({ + email: true, + firstName: true, + lastName: true, + id: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ) + }) + ) .omit({ createdAt: true, updatedAt: true }) .array() }) @@ -86,10 +103,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider actor: req.permission.type, actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, - members: req.body.members.map((member) => ({ - ...member, - projectRole: ProjectMembershipRole.Member - })) + members: req.body.members }); await server.services.auditLog.createAuditLog({ @@ -124,39 +138,56 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider membershipId: z.string().trim() }), body: z.object({ - role: z.string().trim() + roles: z + .array( + z.union([ + z.object({ + role: z.string(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + role: z.string(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .min(1) + .refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least long lived role is required") }), response: { 200: z.object({ - membership: ProjectMembershipsSchema + roles: ProjectUserMembershipRolesSchema.array() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const membership = await server.services.projectMembership.updateProjectMembership({ + const roles = await server.services.projectMembership.updateProjectMembership({ actorId: req.permission.id, actor: req.permission.type, actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, membershipId: req.params.membershipId, - role: req.body.role + roles: req.body.roles }); - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: req.params.workspaceId, - event: { - type: EventType.UPDATE_USER_WORKSPACE_ROLE, - metadata: { - userId: membership.userId, - newRole: req.body.role, - oldRole: membership.role, - email: "" - } - } - }); - return { membership }; + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: req.params.workspaceId, + // event: { + // type: EventType.UPDATE_USER_WORKSPACE_ROLE, + // metadata: { + // userId: membership.userId, + // newRole: req.body.role, + // oldRole: membership.role, + // email: "" + // } + // } + // }); + return { roles }; } }); diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index ef928a4dc..dc32702ff 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -60,17 +60,32 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - users: ProjectMembershipsSchema.merge( - z.object({ - user: UsersSchema.pick({ - username: true, - email: true, - firstName: true, - lastName: true, - id: true - }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) - }) - ) + users: ProjectMembershipsSchema.omit({ role: true }) + .merge( + z.object({ + user: UsersSchema.pick({ + username: true, + email: true, + firstName: true, + lastName: true, + id: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ) + }) + ) .omit({ createdAt: true, updatedAt: true }) .array() }) diff --git a/backend/src/server/routes/v2/identity-project-router.ts b/backend/src/server/routes/v2/identity-project-router.ts index fdd810b4b..09e586839 100644 --- a/backend/src/server/routes/v2/identity-project-router.ts +++ b/backend/src/server/routes/v2/identity-project-router.ts @@ -1,13 +1,15 @@ +import ms from "ms"; import { z } from "zod"; import { IdentitiesSchema, IdentityProjectMembershipsSchema, ProjectMembershipRole, - ProjectRolesSchema + ProjectUserMembershipRolesSchema } from "@app/db/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; export const registerIdentityProjectRouter = async (server: FastifyZodProvider) => { server.route({ @@ -57,24 +59,40 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) identityId: z.string().trim() }), body: z.object({ - role: z.string().trim().min(1).default(ProjectMembershipRole.NoAccess) + roles: z + .array( + z.union([ + z.object({ + role: z.string(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + role: z.string(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .min(1) }), response: { 200: z.object({ - identityMembership: IdentityProjectMembershipsSchema + roles: ProjectUserMembershipRolesSchema.array() }) } }, handler: async (req) => { - const identityMembership = await server.services.identityProject.updateProjectIdentity({ + const roles = await server.services.identityProject.updateProjectIdentity({ actor: req.permission.type, actorId: req.permission.id, actorOrgId: req.permission.orgId, identityId: req.params.identityId, projectId: req.params.projectId, - role: req.body.role + roles: req.body.roles }); - return { identityMembership }; + return { roles }; } }); @@ -127,18 +145,29 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) }), response: { 200: z.object({ - identityMemberships: IdentityProjectMembershipsSchema.merge( - z.object({ - customRole: ProjectRolesSchema.pick({ - id: true, - name: true, - slug: true, - permissions: true, - description: true - }).optional(), + identityMemberships: z + .object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) }) - ).array() + .array() }) } }, diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index dbb864387..dd3ba04f4 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -3,7 +3,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, sqlNestRelationships } from "@app/lib/knex"; export type TIdentityProjectDALFactory = ReturnType; @@ -15,52 +15,81 @@ export const identityProjectDALFactory = (db: TDbClient) => { const docs = await (tx || db)(TableName.IdentityProjectMembership) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) .join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`) + .join( + TableName.IdentityProjectMembershipRole, + `${TableName.IdentityProjectMembershipRole}.projectMembershipId`, + `${TableName.IdentityProjectMembership}.id` + ) .leftJoin( TableName.ProjectRoles, - `${TableName.IdentityProjectMembership}.roleId`, + `${TableName.IdentityProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) - .select(selectAllTableCols(TableName.IdentityProjectMembership)) - // cr stands for custom role - .select(db.ref("id").as("crId").withSchema(TableName.ProjectRoles)) - .select(db.ref("name").as("crName").withSchema(TableName.ProjectRoles)) - .select(db.ref("slug").as("crSlug").withSchema(TableName.ProjectRoles)) - .select(db.ref("description").as("crDescription").withSchema(TableName.ProjectRoles)) - .select(db.ref("permissions").as("crPermission").withSchema(TableName.ProjectRoles)) - .select(db.ref("permissions").as("crPermission").withSchema(TableName.ProjectRoles)) - .select(db.ref("id").as("identityId").withSchema(TableName.Identity)) - .select(db.ref("name").as("identityName").withSchema(TableName.Identity)) - .select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity)); - return docs.map( - ({ - crId, - crDescription, - crSlug, - crPermission, - crName, - identityId, - identityName, - identityAuthMethod, - ...el - }) => ({ - ...el, + .select( + db.ref("id").withSchema(TableName.IdentityProjectMembership), + db.ref("createdAt").withSchema(TableName.IdentityProjectMembership), + db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership), + db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity), + db.ref("id").as("identityId").withSchema(TableName.Identity), + db.ref("name").as("identityName").withSchema(TableName.Identity), + db.ref("id").withSchema(TableName.IdentityProjectMembership), + db.ref("role").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("id").withSchema(TableName.IdentityProjectMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("isTemporary").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("temporaryRange").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole) + ); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ identityId, identityName, identityAuthMethod, id, createdAt, updatedAt }) => ({ + id, identityId, + createdAt, + updatedAt, identity: { id: identityId, name: identityName, authMethod: identityAuthMethod - }, - customRole: el.roleId - ? { - id: crId, - name: crName, - slug: crSlug, - permissions: crPermission, - description: crDescription - } - : undefined - }) - ); + } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; } catch (error) { throw new DatabaseError({ error, name: "FindByProjectId" }); } diff --git a/backend/src/services/identity-project/identity-project-membership-role-dal.ts b/backend/src/services/identity-project/identity-project-membership-role-dal.ts new file mode 100644 index 000000000..3f6c6b589 --- /dev/null +++ b/backend/src/services/identity-project/identity-project-membership-role-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityProjectMembershipRoleDALFactory = ReturnType; + +export const identityProjectMembershipRoleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.IdentityProjectMembershipRole); + return orm; +}; diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index f9d21034f..b6f6e4343 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -1,15 +1,20 @@ import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; -import { ProjectMembershipRole, TProjectRoles } from "@app/db/schemas"; +import { ProjectMembershipRole } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; import { ActorType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TProjectDALFactory } from "../project/project-dal"; +import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; +import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { TIdentityProjectDALFactory } from "./identity-project-dal"; +import { TIdentityProjectMembershipRoleDALFactory } from "./identity-project-membership-role-dal"; import { TCreateProjectIdentityDTO, TDeleteProjectIdentityDTO, @@ -19,7 +24,12 @@ import { type TIdentityProjectServiceFactoryDep = { identityProjectDAL: TIdentityProjectDALFactory; + identityProjectMembershipRoleDAL: Pick< + TIdentityProjectMembershipRoleDALFactory, + "create" | "transaction" | "insertMany" | "delete" + >; projectDAL: Pick; + projectRoleDAL: Pick; identityOrgMembershipDAL: Pick; permissionService: Pick; }; @@ -30,7 +40,9 @@ export const identityProjectServiceFactory = ({ identityProjectDAL, permissionService, identityOrgMembershipDAL, - projectDAL + identityProjectMembershipRoleDAL, + projectDAL, + projectRoleDAL }: TIdentityProjectServiceFactoryDep) => { const createProjectIdentity = async ({ identityId, @@ -70,11 +82,26 @@ export const identityProjectServiceFactory = ({ }); const isCustomRole = Boolean(customRole); - const projectIdentity = await identityProjectDAL.create({ - identityId, - projectId: project.id, - role: isCustomRole ? ProjectMembershipRole.Custom : role, - roleId: customRole?.id + const projectIdentity = await identityProjectDAL.transaction(async (tx) => { + const identityProjectMembership = await identityProjectDAL.create( + { + identityId, + projectId: project.id, + role: isCustomRole ? ProjectMembershipRole.Custom : role, + roleId: customRole?.id + }, + tx + ); + + await identityProjectMembershipRoleDAL.create( + { + projectMembershipId: identityProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : role, + customRoleId: customRole?.id + }, + tx + ); + return identityProjectMembership; }); return projectIdentity; }; @@ -82,7 +109,7 @@ export const identityProjectServiceFactory = ({ const updateProjectIdentity = async ({ projectId, identityId, - role, + roles, actor, actorId, actorOrgId @@ -106,28 +133,51 @@ export const identityProjectServiceFactory = ({ if (!hasRequiredPriviledges) throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); - let customRole: TProjectRoles | undefined; - if (role) { - const { permission: rolePermission, role: customOrgRole } = await permissionService.getProjectPermissionByRole( - role, - projectIdentity.projectId - ); - - const isCustomRole = Boolean(customOrgRole); - const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasRequiredNewRolePermission) - throw new BadRequestError({ message: "Failed to create a more privileged identity" }); - if (isCustomRole) customRole = customOrgRole; - } - - const [updatedProjectIdentity] = await identityProjectDAL.update( - { projectId, identityId: projectIdentity.identityId }, - { - role: customRole ? ProjectMembershipRole.Custom : role, - roleId: customRole ? customRole.id : null - } + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) ); - return updatedProjectIdentity; + const hasCustomRole = Boolean(customInputRoles.length); + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" }); + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const santiziedProjectMembershipRoles = roles.map((inputRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); + if (!inputRole.isTemporary) { + return { + projectMembershipId: projectIdentity.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null + }; + } + + // check cron or relative here later for now its just relative + const relativeTimeInMs = ms(inputRole.temporaryRange); + return { + projectMembershipId: projectIdentity.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, + isTemporary: true, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: inputRole.temporaryRange, + temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) + }; + }); + + const updatedRoles = await identityProjectMembershipRoleDAL.transaction(async (tx) => { + await identityProjectMembershipRoleDAL.delete({ projectMembershipId: projectIdentity.id }, tx); + return identityProjectMembershipRoleDAL.insertMany(santiziedProjectMembershipRoles, tx); + }); + + return updatedRoles; }; const deleteProjectIdentity = async ({ diff --git a/backend/src/services/identity-project/identity-project-types.ts b/backend/src/services/identity-project/identity-project-types.ts index 71e048c19..73e8ec246 100644 --- a/backend/src/services/identity-project/identity-project-types.ts +++ b/backend/src/services/identity-project/identity-project-types.ts @@ -1,12 +1,26 @@ import { TProjectPermission } from "@app/lib/types"; +import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; + export type TCreateProjectIdentityDTO = { identityId: string; role: string; } & TProjectPermission; export type TUpdateProjectIdentityDTO = { - role: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; identityId: string; } & TProjectPermission; diff --git a/backend/src/services/org/org-role-service.ts b/backend/src/services/org/org-role-service.ts index b3d8121c3..fb8a57440 100644 --- a/backend/src/services/org/org-role-service.ts +++ b/backend/src/services/org/org-role-service.ts @@ -58,7 +58,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol { id: roleId, orgId }, { ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined } ); - if (!updateRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); return updatedRole; }; @@ -66,7 +66,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId }); - if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); return deletedRole; }; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 291eb1045..8faab487e 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -1,7 +1,7 @@ import { TDbClient } from "@app/db"; import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TProjectMembershipDALFactory = ReturnType; @@ -11,32 +11,86 @@ export const projectMembershipDALFactory = (db: TDbClient) => { // special query const findAllProjectMembers = async (projectId: string) => { try { - const members = await db(TableName.ProjectMembership) - .where({ projectId }) + const docs = await db(TableName.ProjectMembership) + .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .join( TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id` ) + .join( + TableName.ProjectUserMembershipRole, + `${TableName.ProjectUserMembershipRole}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectUserMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) .select( db.ref("id").withSchema(TableName.ProjectMembership), - db.ref("projectId").withSchema(TableName.ProjectMembership), - db.ref("role").withSchema(TableName.ProjectMembership), - db.ref("roleId").withSchema(TableName.ProjectMembership), db.ref("isGhost").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users), db.ref("publicKey").withSchema(TableName.UserEncryptionKey), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), - db.ref("id").withSchema(TableName.Users).as("userId") + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("role").withSchema(TableName.ProjectUserMembershipRole), + db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole), + db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole) ) .where({ isGhost: false }); - return members.map(({ username, email, firstName, lastName, publicKey, isGhost, ...data }) => ({ - ...data, - user: { username, email, firstName, lastName, id: data.userId, publicKey, isGhost } - })); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, userId }) => ({ + id, + userId, + projectId, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; } catch (error) { throw new DatabaseError({ error, name: "Find all project members" }); } diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 70d1c8023..1f751ee89 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -1,14 +1,13 @@ /* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; import { - OrgMembershipStatus, ProjectMembershipRole, ProjectVersion, SecretKeyEncoding, TableName, - TProjectMemberships, - TUsers + TProjectMemberships } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -29,22 +28,24 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; import { + ProjectUserMembershipTemporaryMode, TAddUsersToWorkspaceDTO, TAddUsersToWorkspaceNonE2EEDTO, TDeleteProjectMembershipOldDTO, TDeleteProjectMembershipsDTO, TGetProjectMembershipDTO, - TInviteUserToProjectDTO, TUpdateProjectMembershipDTO } from "./project-membership-types"; +import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; type TProjectMembershipServiceFactoryDep = { permissionService: Pick; smtpService: TSmtpService; projectBotDAL: TProjectBotDALFactory; projectMembershipDAL: TProjectMembershipDALFactory; + projectUserMembershipRoleDAL: Pick; userDAL: Pick; - projectRoleDAL: Pick; + projectRoleDAL: Pick; orgDAL: Pick; projectDAL: Pick; projectKeyDAL: Pick; @@ -56,6 +57,7 @@ export type TProjectMembershipServiceFactory = ReturnType { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); - - const invitees: TUsers[] = []; - - const project = await projectDAL.findById(projectId); - const users = await userDAL.find({ - $in: { email: emails } - }); - - await projectDAL.transaction(async (tx) => { - for (const invitee of users) { - if (!invitee.isAccepted) - throw new BadRequestError({ - message: "Failed to validate invitee", - name: "Invite user to project" - }); - - const inviteeMembership = await projectMembershipDAL.findOne( - { - userId: invitee.id, - projectId - }, - tx - ); - - if (inviteeMembership) { - throw new BadRequestError({ - message: "Existing member of project", - name: "Invite user to project" - }); - } - - const inviteeMembershipOrg = await orgDAL.findMembership({ - userId: invitee.id, - orgId: project.orgId, - status: OrgMembershipStatus.Accepted - }); - - if (!inviteeMembershipOrg) { - throw new BadRequestError({ - message: "Failed to validate invitee org membership", - name: "Invite user to project" - }); - } - - await projectMembershipDAL.create( - { - userId: invitee.id, - projectId, - role: ProjectMembershipRole.Member - }, - tx - ); - - invitees.push(invitee); - } - - const appCfg = getConfig(); - await smtpService.sendMail({ - template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical project invitation", - recipients: invitees.filter((i) => i.email).map((i) => i.email as string), - substitutions: { - workspaceName: project.name, - callback_url: `${appCfg.SITE_URL}/login` - } - }); - }); - - const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId); - - return { invitees, latestKey }; - }; - const addUsersToProject = async ({ projectId, actorId, @@ -176,17 +102,16 @@ export const projectMembershipServiceFactory = ({ if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); await projectMembershipDAL.transaction(async (tx) => { - await projectMembershipDAL.insertMany( - orgMembers.map(({ userId, id: membershipId }) => { - const role = - members.find((i) => i.orgMembershipId === membershipId)?.projectRole || ProjectMembershipRole.Member; - - return { - projectId, - userId: userId as string, - role - }; - }), + const projectMemberships = await projectMembershipDAL.insertMany( + orgMembers.map(({ userId }) => ({ + projectId, + userId: userId as string, + role: ProjectMembershipRole.Member + })), + tx + ); + await projectUserMembershipRoleDAL.insertMany( + projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })), tx ); const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); @@ -296,7 +221,7 @@ export const projectMembershipServiceFactory = ({ const members: TProjectMemberships[] = []; await projectMembershipDAL.transaction(async (tx) => { - const result = await projectMembershipDAL.insertMany( + const projectMemberships = await projectMembershipDAL.insertMany( orgMembers.map(({ user }) => ({ projectId, userId: user.id, @@ -304,8 +229,12 @@ export const projectMembershipServiceFactory = ({ })), tx ); + await projectUserMembershipRoleDAL.insertMany( + projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })), + tx + ); - members.push(...result); + members.push(...projectMemberships); const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); await projectKeyDAL.insertMany( @@ -346,43 +275,71 @@ export const projectMembershipServiceFactory = ({ actorOrgId, projectId, membershipId, - role + roles }: TUpdateProjectMembershipDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId); - - if (membershipUser?.isGhost) { + if (membershipUser?.isGhost || membershipUser?.projectId !== projectId) { throw new BadRequestError({ message: "Unauthorized member update", name: "Update project membership" }); } - const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole); - if (isCustomRole) { - const customRole = await projectRoleDAL.findOne({ slug: role, projectId }); - if (!customRole) throw new BadRequestError({ name: "Update project membership", message: "Role not found" }); - const project = await projectDAL.findById(customRole.projectId); - const plan = await licenseService.getPlan(project.orgId); + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + ); + const hasCustomRole = Boolean(customInputRoles.length); + if (hasCustomRole) { + const plan = await licenseService.getPlan(actorOrgId as string); if (!plan?.rbac) throw new BadRequestError({ message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." }); - - const [membership] = await projectMembershipDAL.update( - { id: membershipId, projectId }, - { - role: ProjectMembershipRole.Custom, - roleId: customRole.id - } - ); - return membership; } - const [membership] = await projectMembershipDAL.update({ id: membershipId, projectId }, { role, roleId: null }); - return membership; + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" }); + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const santiziedProjectMembershipRoles = roles.map((inputRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); + if (!inputRole.isTemporary) { + return { + projectMembershipId: membershipId, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null + }; + } + + // check cron or relative here later for now its just relative + const relativeTimeInMs = ms(inputRole.temporaryRange); + return { + projectMembershipId: membershipId, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, + isTemporary: true, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: inputRole.temporaryRange, + temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) + }; + }); + + const updatedRoles = await projectMembershipDAL.transaction(async (tx) => { + await projectUserMembershipRoleDAL.delete({ projectMembershipId: membershipId }, tx); + return projectUserMembershipRoleDAL.insertMany(santiziedProjectMembershipRoles, tx); + }); + + return updatedRoles; }; // This is old and should be removed later. Its not used anywhere, but it is exposed in our API. So to avoid breaking changes, we are keeping it for now. @@ -481,7 +438,6 @@ export const projectMembershipServiceFactory = ({ return { getProjectMemberships, - inviteUserToProject, updateProjectMembership, addUsersToProjectNonE2EE, deleteProjectMemberships, diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index abe4d2f72..2ba245c8c 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,7 +1,9 @@ -import { ProjectMembershipRole } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; export type TGetProjectMembershipDTO = TProjectPermission; +export enum ProjectUserMembershipTemporaryMode { + Relative = "relative" +} export type TInviteUserToProjectDTO = { emails: string[]; @@ -9,7 +11,19 @@ export type TInviteUserToProjectDTO = { export type TUpdateProjectMembershipDTO = { membershipId: string; - role: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; } & TProjectPermission; export type TDeleteProjectMembershipOldDTO = { @@ -27,7 +41,6 @@ export type TAddUsersToWorkspaceDTO = { orgMembershipId: string; workspaceEncryptedKey: string; workspaceEncryptedNonce: string; - projectRole: ProjectMembershipRole; }[]; } & TProjectPermission; diff --git a/backend/src/services/project-membership/project-user-membership-role-dal.ts b/backend/src/services/project-membership/project-user-membership-role-dal.ts new file mode 100644 index 000000000..b1cb55b9b --- /dev/null +++ b/backend/src/services/project-membership/project-user-membership-role-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TProjectUserMembershipRoleDALFactory = ReturnType; + +export const projectUserMembershipRoleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ProjectUserMembershipRole); + return orm; +}; diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 7da98b314..b45a6e8a5 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -76,7 +76,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Role); const [deletedRole] = await projectRoleDAL.delete({ id: roleId, projectId }); - if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); return deletedRole; }; @@ -92,7 +92,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: name: "Admin", slug: ProjectMembershipRole.Admin, description: "Complete administration access over the project", - permissions: packRules(projectAdminPermissions.rules), + permissions: packRules(projectAdminPermissions), createdAt: new Date(), updatedAt: new Date() }, @@ -102,7 +102,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: name: "Developer", slug: ProjectMembershipRole.Member, description: "Non-administrative role in an project", - permissions: packRules(projectMemberPermissions.rules), + permissions: packRules(projectMemberPermissions), createdAt: new Date(), updatedAt: new Date() }, @@ -112,7 +112,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: name: "Viewer", slug: ProjectMembershipRole.Viewer, description: "Non-administrative role in an project", - permissions: packRules(projectViewerPermission.rules), + permissions: packRules(projectViewerPermission), createdAt: new Date(), updatedAt: new Date() }, @@ -122,7 +122,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: name: "No Access", slug: "no-access", description: "No access to any resources in the project", - permissions: packRules(projectNoAccessPermissions.rules), + permissions: packRules(projectNoAccessPermissions), createdAt: new Date(), updatedAt: new Date() }, diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts index e1d45e96d..4431855ce 100644 --- a/backend/src/services/project/project-queue.ts +++ b/backend/src/services/project/project-queue.ts @@ -38,6 +38,7 @@ import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -58,9 +59,9 @@ type TProjectQueueFactoryDep = { projectBotDAL: Pick; orgService: Pick; projectMembershipDAL: Pick; + projectUserMembershipRoleDAL: Pick; integrationAuthDAL: TIntegrationAuthDALFactory; userDAL: Pick; - projectEnvDAL: Pick; projectDAL: Pick; orgDAL: Pick; @@ -81,7 +82,8 @@ export const projectQueueFactory = ({ orgDAL, projectDAL, orgService, - projectMembershipDAL + projectMembershipDAL, + projectUserMembershipRoleDAL }: TProjectQueueFactoryDep) => { const upgradeProject = async (dto: TQueueJobTypes["upgrade-project-to-ghost"]["payload"]) => { await queueService.queue(QueueName.UpgradeProjectToGhost, QueueJobs.UpgradeProjectToGhost, dto, { @@ -227,7 +229,7 @@ export const projectQueueFactory = ({ ); // Create a membership for the ghost user - await projectMembershipDAL.create( + const projectMembership = await projectMembershipDAL.create( { projectId: project.id, userId: ghostUser.user.id, @@ -235,6 +237,10 @@ export const projectQueueFactory = ({ }, tx ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, + tx + ); // If a bot already exists, delete it if (existingBot) { diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index c7c3af6fa..873a7d36f 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -17,11 +17,13 @@ import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; +import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TUserDALFactory } from "../user/user-dal"; @@ -50,9 +52,11 @@ type TProjectServiceFactoryDep = { projectEnvDAL: Pick; identityOrgMembershipDAL: TIdentityOrgDALFactory; identityProjectDAL: TIdentityProjectDALFactory; + identityProjectMembershipRoleDAL: Pick; projectKeyDAL: Pick; projectBotDAL: Pick; projectMembershipDAL: Pick; + projectUserMembershipRoleDAL: Pick; secretBlindIndexDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; @@ -75,7 +79,9 @@ export const projectServiceFactory = ({ secretBlindIndexDAL, projectMembershipDAL, projectEnvDAL, - licenseService + licenseService, + projectUserMembershipRoleDAL, + identityProjectMembershipRoleDAL }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -114,14 +120,18 @@ export const projectServiceFactory = ({ tx ); // set ghost user as admin of project - await projectMembershipDAL.create( + const projectMembership = await projectMembershipDAL.create( { userId: ghostUser.user.id, - role: ProjectMembershipRole.Admin, - projectId: project.id + projectId: project.id, + role: ProjectMembershipRole.Admin }, tx ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, + tx + ); // generate the blind index for project await secretBlindIndexDAL.create( @@ -213,7 +223,7 @@ export const projectServiceFactory = ({ }); // Create a membership for the user - await projectMembershipDAL.create( + const userProjectMembership = await projectMembershipDAL.create( { projectId: project.id, userId: user.id, @@ -221,6 +231,10 @@ export const projectServiceFactory = ({ }, tx ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: userProjectMembership.id, role: projectAdmin.projectRole }, + tx + ); // Create a project key for the user await projectKeyDAL.create( @@ -266,7 +280,7 @@ export const projectServiceFactory = ({ }); const isCustomRole = Boolean(customRole); - await identityProjectDAL.create( + const identityProjectMembership = await identityProjectDAL.create( { identityId: actorId, projectId: project.id, @@ -275,6 +289,15 @@ export const projectServiceFactory = ({ }, tx ); + + await identityProjectMembershipRoleDAL.create( + { + projectMembershipId: identityProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin, + customRoleId: customRole?.id + }, + tx + ); } return { @@ -350,11 +373,11 @@ export const projectServiceFactory = ({ }; const upgradeProject = async ({ projectId, actor, actorId, userPrivateKey }: TUpgradeProjectDTO) => { - const { permission, membership } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission, hasRole } = await permissionService.getProjectPermission(actor, actorId, projectId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); - if (membership?.role !== ProjectMembershipRole.Admin) { + if (!hasRole(ProjectMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "User must be admin" }); diff --git a/backend/src/services/secret-blind-index/secret-blind-index-service.ts b/backend/src/services/secret-blind-index/secret-blind-index-service.ts index da7a44df2..b681266fd 100644 --- a/backend/src/services/secret-blind-index/secret-blind-index-service.ts +++ b/backend/src/services/secret-blind-index/secret-blind-index-service.ts @@ -37,8 +37,8 @@ export const secretBlindIndexServiceFactory = ({ }; const getProjectSecrets = async ({ projectId, actorId, actor }: TGetProjectSecretsDTO) => { - const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId); - if (membership?.role !== ProjectMembershipRole.Admin) { + const { hasRole } = await permissionService.getProjectPermission(actor, actorId, projectId); + if (!hasRole(ProjectMembershipRole.Admin)) { throw new UnauthorizedError({ message: "User must be admin" }); } @@ -53,8 +53,8 @@ export const secretBlindIndexServiceFactory = ({ actorOrgId, secretsToUpdate }: TUpdateProjectSecretNameDTO) => { - const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); - if (membership?.role !== ProjectMembershipRole.Admin) { + const { hasRole } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); + if (!hasRole(ProjectMembershipRole.Admin)) { throw new UnauthorizedError({ message: "User must be admin" }); } diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 413afb2b8..11b3396dc 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -97,6 +97,11 @@ func RequireLogin() { } } +func IsLoggedIn() bool { + configFile, _ := GetConfigFile() + return configFile.LoggedInUserEmail != "" +} + func RequireServiceToken() { serviceToken := os.Getenv(INFISICAL_TOKEN_NAME) if serviceToken == "" { diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index a1764d721..8c142a4ec 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -427,6 +427,8 @@ func ExpandSecrets(secrets []models.SingleEnvironmentVariable, auth models.Expan refSecs, err = GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: env, InfisicalToken: auth.InfisicalToken, SecretsPath: secPath}, projectConfigPathDir) } else if auth.UniversalAuthAccessToken != "" { refSecs, err = GetAllEnvironmentVariables((models.GetAllSecretsParameters{Environment: env, UniversalAuthAccessToken: auth.UniversalAuthAccessToken, SecretsPath: secPath, WorkspaceId: sec.WorkspaceId}), projectConfigPathDir) + } else if IsLoggedIn() { + refSecs, err = GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: env, SecretsPath: secPath}, projectConfigPathDir) } else { HandleError(errors.New("no authentication provided"), "Please provide authentication to fetch secrets") } diff --git a/docs/cli/usage.mdx b/docs/cli/usage.mdx index 2ea7b6425..aa60c3db1 100644 --- a/docs/cli/usage.mdx +++ b/docs/cli/usage.mdx @@ -135,3 +135,38 @@ Another option to point the CLI to your self hosted Infisical instance is to set infisical --domain="https://your-self-hosted-infisical.com/api" ``` + +## History + +Your terminal keeps a history with the commands you run. When you create Infisical secrets directly from your terminal, they'll stay there for a while. + +For security and privacy concerns, we recommend you to configure your terminal to ignore those specific Infisical commands. + + + + + + + `$HOME/.profile` is pretty common but, you could place it under `$HOME/.profile.d/infisical.sh` or any profile file run at login + + + ```bash + cat <> $HOME/.profile && source $HOME/.profile + + # Ignoring specific Infisical CLI commands + DEFAULT_HISTIGNORE=$HISTIGNORE + export HISTIGNORE="*infisical secrets set*:$DEFAULT_HISTIGNORE" + EOF + ``` + + + + If you're on WSL, then you can use the Unix/Linux method. + + + Here's some [documentation](https://superuser.com/a/1658331) about how to clear the terminal history, in PowerShell and CMD + + + + + \ No newline at end of file diff --git a/docs/documentation/platform/secret-rotation/aws-iam.mdx b/docs/documentation/platform/secret-rotation/aws-iam.mdx new file mode 100644 index 000000000..b4247af80 --- /dev/null +++ b/docs/documentation/platform/secret-rotation/aws-iam.mdx @@ -0,0 +1,143 @@ +--- +title: "AWS IAM User" +description: "Rotated access key id and secret key of AWS IAM Users" +--- + +Infisical's AWS IAM User secret rotation capability lets you update the **Access key** and **Secret access key** credentials of a target IAM user from within Infisical +at a specified interval or on-demand. + +## Workflow + +The typical workflow for using the AWS IAM User rotation strategy consists of four steps: + +1. Creating the target IAM user whose credentials you wish to rotate. +2. Creating the managing IAM user used by Infisical to rotate the credentials of the target IAM user. +3. Configuring the rotation strategy in Infisical with the credentials of the managing IAM user. +4. Pressing the **Rotate** button in the Infisical dashboard to trigger the rotation of the target IAM user's credentials. The strategy can also be configured to rotate the credentials automatically at a specified interval. + +In the following steps, we explore the end-to-end workflow for setting up this strategy in Infisical. + + + + To begin, create an IAM user whose credentials you wish to rotate. If you already have an IAM user, + then you can skip this step. + + + Next, create another IAM user to be used by Infisical to rotate the credentials of the IAM user in the previous step. + + 2.1. In your AWS console, head to IAM > Access management > Users and press **Create user**. + + ![iam user secret rotation create user](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-user.png) + + 2.2. Next, give the user a username like **infisical-rotation-manager** and press **Next**. + + ![iam user secret rotation username](../../../images/platform/secret-rotation/aws-iam/rotation-manager-username.png) + + 2.3. Next, in the **Set permissions** step, select **Attach policies directly** and then press **Create policy**. + + ![iam user secret rotation create policy](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-policy.png) + + 2.4. Next, in the **Policy editor**, paste the following JSON and press **Next**: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "iam:DeleteAccessKey", + "iam:GetAccessKeyLastUsed", + "iam:CreateAccessKey" + ], + "Resource": "*" + } + ] + } + ``` + + + The IAM policy above uses the wildcard option in Resource: "*". + + You may want to restrict the policy to a specific path, and make any adjustments as necessary, to control access for the managing user in production. + + Read more about this [here](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/). + + + In the **Review and create** step, give the policy a name like **infisical-rotation-manager**, press **Create policy** to finish creating the policy. + + ![iam user secret rotation policy review](../../../images/platform/secret-rotation/aws-iam/rotation-manager-policy-review.png) + + 2.5. Back in the **Set permissions** step from step 2.3, refresh the policy list and search for the policy you just created from step 2.4. + + Select the policy and press **Next**. + + ![iam user secret rotation attach policy](../../../images/platform/secret-rotation/aws-iam/rotation-manager-attach-policy.png) + + In the **Review and create** step, press **Create user** to finish creating the IAM user. + + ![iam user secret rotation manager user review](../../../images/platform/secret-rotation/aws-iam/rotation-manager-user-review.png) + + 2.5. Having created the user, head to its Security credentials > Access keys and press **Create access key**. + + Follow the subsequent steps to create the **access key** and **secret access key** credential pair for the user. + + ![iam user secret rotation manager create access key](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-access-key.png) + + At the end of the flow, copy the **Access key** and **Secret access key** to use when configuring the AWS IAM User rotation strategy back in Infisical next. + + ![iam user secret rotation manager access keys](../../../images/platform/secret-rotation/aws-iam/rotation-manager-access-keys.png) + + + 3.1. Back in Infisical, head to the Project > Secrets > Environment and path where you want the rotated AWS IAM credentials to appear and create two placeholder secrets. + + In this example, we'll create two secrets called `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY`. + + ![iam user secret rotation secrets](../../../images/platform/secret-rotation/aws-iam/rotation-config-secrets.png) + + 3.2. Next, in the **Secret Rotation** tab, press on the **AWS IAM** tile to configure the AWS IAM User rotation strategy. + + ![iam user secret rotation select aws iam user method](../../../images/platform/secret-rotation/aws-iam/rotations-select-aws-iam-user.png) + + 3.3. Input the configuration details for the AWS IAM User rotation strategy obtained from steps 1 and 2: + + ![iam user secret rotation config 1](../../../images/platform/secret-rotation/aws-iam/rotation-config-1.png) + + Here's some guidance on each field: + + - Manager User Access Key: The managing IAM user's access key from step 2.5. + - Manager User Secret Key: The managing IAM user's secret access key from step 2.5. + - Manager User AWS Region: The [AWS region](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html) for Infisical to make requests to such as `us-east-1`. + - IAM Username: The IAM username of the user from step 1. + + Next, specify the output secret mappings configuration for the rotated AWS IAM credentials; this is the secrets whose values will be replaced with new credentials after each rotation. + Here, you can also specify a rotation interval for the credentials to be automatically rotated periodically. + + In this example, we want to map the output of the rotated AWS IAM credentials to the secrets that we created in step 3.1 (i.e. `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY`). + + ![iam user secret rotation config 2](../../../images/platform/secret-rotation/aws-iam/rotation-config-2.png) + + Finally, press **Submit** to create the secret rotation strategy. + + + You should now see the AWS IAM User rotation strategy listed in the **Secret Rotation** tab. + + To manually trigger a rotation, you can press the **Rotate** button on the strategy. + Once triggered, the secrets in step 3.1 should be updated with new rotated credential values. + + ![iam user secret rotations aws iam user](../../../images/platform/secret-rotation/aws-iam/rotations-aws-iam-user.png) + + + +**FAQ** + + + + There are a few reasons for why this might happen: + + - The strategy configuration is invalid (e.g. the managing IAM user's credentials are incorrect, the target IAM username is incorrect, etc.). + - The managing IAM user is insufficently permissioned to rotate the credentials of the target IAM user. For instance, you may have setup [paths](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) for the managing IAM user and the policy does not have the necessary permissions to rotate the credentials. + - The target IAM user already has 2 access keys configured in AWS; you should delete one of the access keys to allow for rotation. + + \ No newline at end of file diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-config-1.png b/docs/images/platform/secret-rotation/aws-iam/rotation-config-1.png new file mode 100644 index 000000000..2e6e5b830 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-config-1.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-config-2.png b/docs/images/platform/secret-rotation/aws-iam/rotation-config-2.png new file mode 100644 index 000000000..487147c47 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-config-2.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-config-secrets.png b/docs/images/platform/secret-rotation/aws-iam/rotation-config-secrets.png new file mode 100644 index 000000000..8a15d486f Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-config-secrets.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-access-key-third-party.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-access-key-third-party.png new file mode 100644 index 000000000..8e01b60f5 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-access-key-third-party.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-access-keys.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-access-keys.png new file mode 100644 index 000000000..f30b69dad Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-access-keys.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-attach-policy.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-attach-policy.png new file mode 100644 index 000000000..4944366de Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-attach-policy.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-access-key.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-access-key.png new file mode 100644 index 000000000..7a71e4a5e Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-access-key.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-policy.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-policy.png new file mode 100644 index 000000000..46ae782e1 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-policy.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-user.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-user.png new file mode 100644 index 000000000..05542dae3 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-user.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-policy-review.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-policy-review.png new file mode 100644 index 000000000..ae81055b0 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-policy-review.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-user-review.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-user-review.png new file mode 100644 index 000000000..34773f913 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-user-review.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-username.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-username.png new file mode 100644 index 000000000..573c3cf94 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-username.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotations-aws-iam-user.png b/docs/images/platform/secret-rotation/aws-iam/rotations-aws-iam-user.png new file mode 100644 index 000000000..5ea395e68 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotations-aws-iam-user.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotations-select-aws-iam-user.png b/docs/images/platform/secret-rotation/aws-iam/rotations-select-aws-iam-user.png new file mode 100644 index 000000000..17bbe3b1f Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotations-select-aws-iam-user.png differ diff --git a/docs/mint.json b/docs/mint.json index cb6ce08ac..74b56f3b0 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -134,7 +134,8 @@ "documentation/platform/secret-rotation/overview", "documentation/platform/secret-rotation/sendgrid", "documentation/platform/secret-rotation/postgres", - "documentation/platform/secret-rotation/mysql" + "documentation/platform/secret-rotation/mysql", + "documentation/platform/secret-rotation/aws-iam" ] }, { diff --git a/frontend/public/images/secretRotation/aws-iam.svg b/frontend/public/images/secretRotation/aws-iam.svg new file mode 100644 index 000000000..0b3b2387b --- /dev/null +++ b/frontend/public/images/secretRotation/aws-iam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index 2bf596915..92a6875a6 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -56,18 +56,20 @@ export default function NavHeader({ return (
-
+
{currentOrg?.name?.charAt(0)}
- + {currentOrg?.name} {isProjectRelated && ( <> -
{currentWorkspace?.name}
+
+ {currentWorkspace?.name} +
)} {isOrganizationRelated && ( diff --git a/frontend/src/components/signup/UserInfoStep.tsx b/frontend/src/components/signup/UserInfoStep.tsx index 7af9ccc8f..994ad5a72 100644 --- a/frontend/src/components/signup/UserInfoStep.tsx +++ b/frontend/src/components/signup/UserInfoStep.tsx @@ -245,6 +245,7 @@ export default function UserInfoStep({ placeholder="Infisical" onChange={(e) => setOrganizationName(e.target.value)} value={organizationName} + maxLength={64} isRequired className="h-12" /> diff --git a/frontend/src/components/v2/Popoverv2/Popoverv2.tsx b/frontend/src/components/v2/Popoverv2/Popoverv2.tsx index 2fea00206..d78ede201 100644 --- a/frontend/src/components/v2/Popoverv2/Popoverv2.tsx +++ b/frontend/src/components/v2/Popoverv2/Popoverv2.tsx @@ -12,6 +12,7 @@ export const Popover = PopoverPrimitive.Root; export type PopoverContentProps = { children?: ReactNode; + arrowClassName?: string; hideCloseBtn?: boolean; } & PopoverPrimitive.PopoverContentProps; @@ -19,6 +20,7 @@ export const PopoverContent = ({ children, className, hideCloseBtn, + arrowClassName, ...props }: PopoverContentProps) => ( @@ -48,7 +50,7 @@ export const PopoverContent = ({ )} - + ); diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 7a8e207e2..fd3e88614 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -1,4 +1,4 @@ -import { TOrgRole, TProjectRole } from "../roles/types"; +import { TOrgRole } from "../roles/types"; import { IdentityAuthMethod } from "./enums"; export type IdentityTrustedIp = { @@ -29,9 +29,18 @@ export type IdentityMembershipOrg = { export type IdentityMembership = { id: string; identity: Identity; - organization: string; - role: "admin" | "member" | "viewer" | "no-access" | "custom"; - customRole?: TProjectRole; + roles: { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: boolean; + temporaryMode: string | null; + temporaryRange: string | null; + temporaryAccessStartTime: string | null; + temporaryAccessEndTime: string | null; + }[]; createdAt: string; updatedAt: string; }; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 65a603698..edc3bdf9e 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -64,7 +64,33 @@ export type TProjectMembership = { roleId: string; }; -export type TWorkspaceUser = OrgUser; +export type TWorkspaceUser = { + id: string; + user: { + email: string; + username: string; + firstName: string; + lastName: string; + id: string; + publicKey: string; + }; + inviteEmail: string; + organization: string; + roles: { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: boolean; + temporaryMode: string | null; + temporaryRange: string | null; + temporaryAccessStartTime: string | null; + temporaryAccessEndTime: string | null; + }[]; + status: "invited" | "accepted" | "verified" | "completed"; + deniedPermissions: any[]; +}; export type AddUserToWsDTOE2EE = { workspaceId: string; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 2d28be501..37e512ac8 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -16,6 +16,8 @@ import { RenameWorkspaceDTO, TGetUpgradeProjectStatusDTO, ToggleAutoCapitalizationDTO, + TUpdateWorkspaceIdentityRoleDTO, + TUpdateWorkspaceUserRoleDTO, UpdateEnvironmentDTO, Workspace } from "./types"; @@ -340,27 +342,19 @@ export const useDeleteUserFromWorkspace = () => { export const useUpdateUserWorkspaceRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ - membershipId, - role, - workspaceId - }: { - membershipId: string; - role: string; - workspaceId: string; - }) => { + mutationFn: async ({ membershipId, roles, workspaceId }: TUpdateWorkspaceUserRoleDTO) => { const { data: { membership } } = await apiRequest.patch<{ membership: { projectId: string } }>( `/api/v1/workspace/${workspaceId}/memberships/${membershipId}`, { - role + roles } ); return membership; }, - onSuccess: (res) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(res.projectId)); + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); } }); }; @@ -400,18 +394,14 @@ export const useUpdateIdentityWorkspaceRole = () => { mutationFn: async ({ identityId, workspaceId, - role - }: { - identityId: string; - workspaceId: string; - role?: string; - }) => { + roles + }:TUpdateWorkspaceIdentityRoleDTO)=> { const { data: { identityMembership } } = await apiRequest.patch( `/api/v2/workspace/${workspaceId}/identity-memberships/${identityId}`, { - role + roles } ); diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index c4aada862..828a7a890 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -3,6 +3,10 @@ export enum ProjectVersion { V2 = 2 } +export enum ProjectUserMembershipTemporaryMode { + Relative = "relative" +} + export type Workspace = { __v: number; id: string; @@ -72,3 +76,39 @@ export type UpdateEnvironmentDTO = { }; export type DeleteEnvironmentDTO = { workspaceId: string; id: string }; + +export type TUpdateWorkspaceUserRoleDTO = { + membershipId: string; + workspaceId: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; +}; + +export type TUpdateWorkspaceIdentityRoleDTO = { + identityId: string; + workspaceId: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; +}; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 8df1d15d2..7b0eeddbf 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -28,6 +28,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; +import { twMerge } from "tailwind-merge"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; @@ -99,7 +100,7 @@ const supportOptions = [ ]; const formSchema = yup.object({ - name: yup.string().required().label("Project Name").trim(), + name: yup.string().required().label("Project Name").trim().max(64, "Too long, maximum length is 64 characters"), addMembers: yup.bool().required().label("Add Members") }); @@ -273,13 +274,16 @@ export const AppLayout = ({ children }: LayoutProps) => { )} - +
-
+
{currentOrg?.name.charAt(0)}
{currentOrg?.name} @@ -323,7 +327,7 @@ export const AppLayout = ({ children }: LayoutProps) => { ) } > -
+
{org.name}
@@ -422,7 +426,7 @@ export const AppLayout = ({ children }: LayoutProps) => { + + )} + /> +
+ {isTemporaryFieldValue && ( + + )} + {!isTemporaryFieldValue ? ( + + ) : ( + + )} +
+
+ + + ); +}; + +const formSchema = z.record( + z.object({ + isChecked: z.boolean().optional(), + temporaryAccess: z.union([ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.boolean() + ]) + }) +); +type TForm = z.infer; + +export type TMemberRolesProp = { + disableEdit?: boolean; + identityId: string; + roles: TWorkspaceUser["roles"]; +}; + +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; + +export const IdentityRoles = ({ + roles = [], + disableEdit = false, + identityId +}: TMemberRolesProp) => { + const { currentWorkspace } = useWorkspace(); + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); + const [searchRoles, setSearchRoles] = useState(""); + + const { + handleSubmit, + control, + reset, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const workspaceId = currentWorkspace?.id || ""; + + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); + + const updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole(); + + const handleRoleUpdate = async (data: TForm) => { + const selectedRoles = Object.keys(data) + .filter((el) => Boolean(data[el].isChecked)) + .map((el) => { + const isTemporary = Boolean(data[el].temporaryAccess); + if (!isTemporary) { + return { role: el, isTemporary: false as const }; + } + + const tempCfg = data[el].temporaryAccess as { + temporaryRange: string; + temporaryAccessStartTime: string; + }; + + return { + role: el, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: tempCfg.temporaryRange, + temporaryAccessStartTime: tempCfg.temporaryAccessStartTime + }; + }); + + try { + await updateIdentityWorkspaceRole.mutateAsync({ + workspaceId, + identityId, + roles: selectedRoles + }); + createNotification({ text: "Successfully updated identity role", type: "success" }); + handlePopUpToggle("editRole"); + setSearchRoles(""); + } catch (err) { + createNotification({ text: "Failed to update identity role", type: "error" }); + } + }; + + const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + return role; + }; + + return ( +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + })} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + new Date(temporaryAccessEndTime as string) && + "text-red-600" + )} + /> + +
+ )} +
+
+ ); + })}{" "} +
+
+ )} +
+ { + handlePopUpToggle("editRole", isOpen); + reset(); + }} + > + {!disableEdit && ( + + + + + + )} + + {isRolesLoading ? ( +
+ +
+ ) : ( +
+
+ {projectRoles + ?.filter( + ({ name, slug }) => + name.toLowerCase().includes(searchRoles.toLowerCase()) || + slug.toLowerCase().includes(searchRoles.toLowerCase()) + ) + ?.map(({ id, name, slug }) => { + const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; + + return ( +
+
+ ( + { + field.onChange(isChecked); + setValue(`${slug}.temporaryAccess`, false); + }} + > + {name} + + )} + /> +
+
+ ( + { + setValue(`${slug}.isChecked`, true, { shouldDirty: true }); + console.log(data); + field.onChange({ isTemporary: true, ...data }); + }} + onRemoveTemporary={() => { + setValue(`${slug}.isChecked`, false, { shouldDirty: true }); + field.onChange(false); + }} + /> + )} + /> +
+
+ ); + })} +
+
+
+ setSearchRoles(el.target.value)} + leftIcon={} + placeholder="Search roles.." + /> +
+
+ +
+
+
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx index eb7320b89..24197e9fe 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx @@ -2,13 +2,10 @@ import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; -import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, - Select, - SelectItem, Table, TableContainer, TableSkeleton, @@ -19,13 +16,11 @@ import { Tr } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { - useGetProjectRoles, - useGetWorkspaceIdentityMemberships, - useUpdateIdentityWorkspaceRole -} from "@app/hooks/api"; +import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { IdentityRoles } from "./IdentityRoles"; + type Props = { handlePopUpOpen: ( popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>, @@ -37,39 +32,9 @@ type Props = { }; export const IdentityTable = ({ handlePopUpOpen }: Props) => { - const { createNotification } = useNotificationContext(); const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || ""); - const { data: roles } = useGetProjectRoles(workspaceId); - - const { mutateAsync: updateMutateAsync } = useUpdateIdentityWorkspaceRole(); - - const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => { - try { - await updateMutateAsync({ - identityId, - workspaceId, - role - }); - - createNotification({ - text: "Successfully updated identity role", - type: "success" - }); - } catch (err) { - console.error(err); - const error = err as any; - const text = error?.response?.data?.message ?? "Failed to update identity role"; - - createNotification({ - text, - type: "error" - }); - } - }; - return ( @@ -86,7 +51,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { {!isLoading && data && data.length > 0 && - data.map(({ identity: { id, name }, role, customRole, createdAt }) => { + data.map(({ identity: { id, name }, roles, createdAt }) => { return ( @@ -95,28 +60,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Identity} > - {(isAllowed) => { - return ( - - ); - }} + {(isAllowed) => ( + + )} diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index bae397f67..d892d42ae 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; @@ -34,7 +34,6 @@ import { ProjectPermissionActions, ProjectPermissionSub, useOrganization, - useSubscription, useUser, useWorkspace } from "@app/context"; @@ -44,14 +43,13 @@ import { useAddUserToWsNonE2EE, useDeleteUserFromWorkspace, useGetOrgUsers, - useGetProjectRoles, useGetUserWsKey, - useGetWorkspaceUsers, - useUpdateUserWorkspaceRole + useGetWorkspaceUsers } from "@app/hooks/api"; -import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; +import { MemberRoles } from "./MemberRoles"; + const addMemberFormSchema = z.object({ orgMembershipId: z.string().trim() }); @@ -60,7 +58,6 @@ type TAddMemberForm = z.infer; export const MemberListTab = () => { const { createNotification } = useNotificationContext(); - const { subscription } = useSubscription(); const { t } = useTranslation(); const { currentOrg } = useOrganization(); @@ -71,8 +68,6 @@ export const MemberListTab = () => { const orgId = currentOrg?.id || ""; const workspaceId = currentWorkspace?.id || ""; - const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); - const { data: wsKey } = useGetUserWsKey(workspaceId); const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId); const { data: orgUsers } = useGetOrgUsers(orgId); @@ -95,7 +90,6 @@ export const MemberListTab = () => { const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE(); const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE(); const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace(); - const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole(); const onAddMember = async ({ orgMembershipId }: TAddMemberForm) => { if (!currentWorkspace) return; @@ -167,47 +161,6 @@ export const MemberListTab = () => { handlePopUpClose("removeMember"); }; - const isIamOwner = useMemo( - () => members?.find(({ user: u }) => userId === u?.id)?.role === "owner", - [userId, members] - ); - - const findRoleFromId = useCallback( - (roleId: string) => { - return (roles || []).find(({ id }) => id === roleId); - }, - [roles] - ); - - const onRoleChange = async (membershipId: string, role: string) => { - if (!currentOrg?.id) return; - - try { - const isCustomRole = !Object.values(ProjectMembershipRole).includes( - role as ProjectMembershipRole - ); - - if (isCustomRole && subscription && !subscription?.rbac) { - handlePopUpOpen("upgradePlan", { - description: "You can assign custom roles to members if you upgrade your Infisical plan." - }); - return; - } - - await updateUserWorkspaceRole({ membershipId, role, workspaceId }); - createNotification({ - text: "Successfully updated user role", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to update user role", - type: "error" - }); - } - }; - const filterdUsers = useMemo( () => members?.filter( @@ -231,8 +184,6 @@ export const MemberListTab = () => { ); }, [orgUsers, members]); - const isLoading = isMembersLoading || isRolesLoading; - return (
@@ -269,75 +220,61 @@ export const MemberListTab = () => {
- {isLoading && } - {!isLoading && - filterdUsers?.map( - ({ user: u, id: membershipId, roleId, role }) => { - const name = u ? `${u.firstName} ${u.lastName}` : "-"; - const username = u?.username ?? "-"; - return ( - - - - + + + + - - - ); - } - )} + )} + + + ); + })}
{name}{format(new Date(createdAt), "yyyy-MM-dd")}
{name}{username} + {isMembersLoading && } + {!isMembersLoading && + filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => { + const name = u ? `${u.firstName} ${u.lastName}` : "-"; + const email = u?.email || inviteEmail; + + return ( +
{name}{email} + + {(isAllowed) => ( + + handlePopUpOpen("upgradePlan", { description }) + } + membershipId={membershipId} + /> + )} + + + {userId !== u?.id && ( {(isAllowed) => ( - + handlePopUpOpen("removeMember", { email: u.email })} + > + + )} - - {userId !== u?.id && ( - - {(isAllowed) => ( - - handlePopUpOpen("removeMember", { username: u.username }) - } - > - - - )} - - )} -
- {!isLoading && filterdUsers?.length === 0 && ( + {!isMembersLoading && filterdUsers?.length === 0 && ( )}
diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx new file mode 100644 index 000000000..fbc36aa95 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx @@ -0,0 +1,473 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + Checkbox, + FormControl, + HoverCard, + HoverCardContent, + HoverCardTrigger, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { useSubscription, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; +import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; +import { groupBy } from "@app/lib/fn/array"; + +const temporaryRoleFormSchema = z.object({ + temporaryRange: z.string().min(1, "Required") +}); + +type TTemporaryRoleFormSchema = z.infer; + +type TTemporaryRoleFormProps = { + temporaryConfig?: { + isTemporary?: boolean; + temporaryAccessEndTime?: string | null; + temporaryAccessStartTime?: string | null; + temporaryRange?: string | null; + }; + onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void; + onRemoveTemporary: () => void; +}; + +const TemporaryRoleForm = ({ + temporaryConfig: defaultValues = {}, + onSetTemporary, + onRemoveTemporary +}: TTemporaryRoleFormProps) => { + const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); + const { control, handleSubmit } = useForm({ + resolver: zodResolver(temporaryRoleFormSchema), + values: { + temporaryRange: defaultValues.temporaryRange || "1h" + } + }); + const isTemporaryFieldValue = defaultValues.isTemporary; + const isExpired = + isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); + + return ( + { + handlePopUpToggle("setTempRole", isOpen); + }} + > + + + + + + + + +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + + 1m, 2h, 3d.{" "} + + More + + + } + > + + + )} + /> +
+ {isTemporaryFieldValue && ( + + )} + {!isTemporaryFieldValue ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +const formSchema = z.record( + z.object({ + isChecked: z.boolean().optional(), + temporaryAccess: z.union([ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.boolean() + ]) + }) +); +type TForm = z.infer; + +export type TMemberRolesProp = { + disableEdit?: boolean; + membershipId: string; + onOpenUpgradeModal: (description: string) => void; + roles: TWorkspaceUser["roles"]; +}; + +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; + +export const MemberRoles = ({ + roles = [], + disableEdit = false, + membershipId, + onOpenUpgradeModal +}: TMemberRolesProp) => { + const { currentWorkspace } = useWorkspace(); + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); + const [searchRoles, setSearchRoles] = useState(""); + const { subscription } = useSubscription(); + + const { + handleSubmit, + control, + reset, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const workspaceId = currentWorkspace?.id || ""; + + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); + + const updateMembershipRole = useUpdateUserWorkspaceRole(); + + const handleRoleUpdate = async (data: TForm) => { + const selectedRoles = Object.keys(data) + .filter((el) => Boolean(data[el].isChecked)) + .map((el) => { + const isTemporary = Boolean(data[el].temporaryAccess); + if (!isTemporary) { + return { role: el, isTemporary: false as const }; + } + + const tempCfg = data[el].temporaryAccess as { + temporaryRange: string; + temporaryAccessStartTime: string; + }; + + return { + role: el, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: tempCfg.temporaryRange, + temporaryAccessStartTime: tempCfg.temporaryAccessStartTime + }; + }); + + const hasCustomRoleSelected = selectedRoles.some( + (el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole) + ); + + if (hasCustomRoleSelected && subscription && !subscription?.rbac) { + onOpenUpgradeModal( + "You can assign custom roles to members if you upgrade your Infisical plan." + ); + return; + } + + try { + await updateMembershipRole.mutateAsync({ + workspaceId, + membershipId, + roles: selectedRoles + }); + createNotification({ text: "Successfully updated role", type: "success" }); + handlePopUpToggle("editRole"); + setSearchRoles(""); + } catch (err) { + createNotification({ text: "Failed to update role", type: "error" }); + } + }; + + const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + return role; + }; + + return ( +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + })} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + new Date(temporaryAccessEndTime as string) && + "text-red-600" + )} + /> + +
+ )} +
+
+ ); + })}{" "} +
+
+ )} +
+ { + handlePopUpToggle("editRole", isOpen); + reset(); + }} + > + {!disableEdit && ( + + + + + + )} + + {isRolesLoading ? ( +
+ +
+ ) : ( +
+
+ {projectRoles + ?.filter( + ({ name, slug }) => + name.toLowerCase().includes(searchRoles.toLowerCase()) || + slug.toLowerCase().includes(searchRoles.toLowerCase()) + ) + ?.map(({ id, name, slug }) => { + const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; + + return ( +
+
+ ( + { + field.onChange(isChecked); + setValue(`${slug}.temporaryAccess`, false); + }} + > + {name} + + )} + /> +
+
+ ( + { + setValue(`${slug}.isChecked`, true, { shouldDirty: true }); + console.log(data); + field.onChange({ isTemporary: true, ...data }); + }} + onRemoveTemporary={() => { + setValue(`${slug}.isChecked`, false, { shouldDirty: true }); + field.onChange(false); + }} + /> + )} + /> +
+
+ ); + })} +
+
+
+ setSearchRoles(el.target.value)} + leftIcon={} + placeholder="Search roles.." + /> +
+
+ +
+
+
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 37916884b..ec8b5c2f9 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; import { subject } from "@casl/ability"; -import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { faCheckCircle, faCircle } from "@fortawesome/free-regular-svg-icons"; import { faAngleDown, faArrowDown, @@ -18,7 +18,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import NavHeader from "@app/components/navigation/NavHeader"; -import { PermissionDeniedBanner, ProjectPermissionCan } from "@app/components/permissions"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DropdownMenu, @@ -108,7 +108,11 @@ export const SecretOverviewPage = () => { }, [isWorkspaceLoading, workspaceId, router.isReady]); const userAvailableEnvs = currentWorkspace?.environments || []; - const [visibleEnvs, setVisisbleEnvs] = useState(userAvailableEnvs); + const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs); + + useEffect(() => { + setVisibleEnvs(userAvailableEnvs); + }, [userAvailableEnvs]); const { data: secrets, @@ -208,9 +212,9 @@ export const SecretOverviewPage = () => { const handleEnvSelect = (envId: string) => { if (visibleEnvs.map((env) => env.id).includes(envId)) { - setVisisbleEnvs(visibleEnvs.filter((env) => env.id !== envId)); + setVisibleEnvs(visibleEnvs.filter((env) => env.id !== envId)); } else { - setVisisbleEnvs(visibleEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId))); + setVisibleEnvs(visibleEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId))); } }; @@ -390,41 +394,44 @@ export const SecretOverviewPage = () => {
- - - - - - - - - - Choose visible environments - {userAvailableEnvs.map((avaiableEnv) => { - const { id: envId, name } = avaiableEnv; + {userAvailableEnvs.length > 0 && ( + + + + + + + + + + Choose visible environments + {userAvailableEnvs.map((availableEnv) => { + const { id: envId, name } = availableEnv; - const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); - return ( - handleEnvSelect(envId)} - key={envId} - icon={ - isEnvSelected && ( - - ) - } - iconPos="left" - > -
{name}
-
- ); - })} - {/* + const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); + return ( + handleEnvSelect(envId)} + key={envId} + icon={ + isEnvSelected ? ( + + ) : ( + + ) + } + iconPos="left" + > +
{name}
+
+ ); + })} + {/* */} -
-
+
+
+ )}
{ leftIcon={} />
-
- - {(isAllowed) => ( - - )} - - handlePopUpToggle("misc", isOpen)} - > - - - - - - -
- 0 && ( +
+ + {(isAllowed) => ( + - )} - -
- - -
+ Add Secret + + )} + + handlePopUpToggle("misc", isOpen)} + > + + + + + + +
+ + {(isAllowed) => ( + + )} + +
+
+
+
+ )}
@@ -565,14 +575,25 @@ export const SecretOverviewPage = () => { className="bg-mineshaft-700" /> )} - {isTableEmpty && !isTableLoading && ( + {userAvailableEnvs.length > 0 && visibleEnvs.length === 0 && ( - + + + + )} + {userAvailableEnvs.length === 0 && ( + + + )} + {isTableEmpty && !isTableLoading && visibleEnvs.length > 0 && ( + + + + + + + + )} {!isTableLoading && filteredFolderNames.map((folderName, index) => ( { /> ))} {!isTableLoading && - (visibleEnvs?.length > 0 ? ( - filteredSecretNames.map((key, index) => ( - - )) - ) : ( - + visibleEnvs?.length > 0 && + filteredSecretNames.map((key, index) => ( + ))} diff --git a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx index 403564274..8c305af1a 100644 --- a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -171,7 +171,7 @@ export const CreateSecretForm = ({ )} /> -
+
{environments.map((env) => { return ( - {env.name} - {getSecretByKey(env.slug, newSecretKey) && ( - - - - )} + + + {env.name} + + + {getSecretByKey(env.slug, newSecretKey) && ( + + + + )} + + )} /> diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index 719b17d44..8ae96d888 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -148,8 +148,13 @@ export const SecretOverviewTableRow = ({ key={`secret-expanded-${slug}-${secretKey}`} className="hover:bg-mineshaft-700" > - -
{name}
+ +
+ {name} +
(
rotation provider logo
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx index e009ff9b8..63e5fe40d 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx @@ -10,7 +10,7 @@ import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@a import { useUpdateOrg } from "@app/hooks/api"; const formSchema = yup.object({ - name: yup.string().required().label("Organization Name"), + name: yup.string().required().label("Organization Name").max(64, "Too long, maximum length is 64 characters"), slug: yup .string() .matches(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens") diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx index 20f163f45..92ba623a2 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx @@ -63,7 +63,10 @@ export const EnvironmentSection = () => { }; return ( -
+

Environments

diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx index 34261f47a..f9ff5761f 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx @@ -12,7 +12,7 @@ import { useRenameWorkspace } from "@app/hooks/api"; import { CopyButton } from "./CopyButton"; const formSchema = yup.object({ - name: yup.string().required().label("Project Name") + name: yup.string().required().label("Project Name").max(64, "Too long, maximum length is 64 characters"), }); type FormData = yup.InferType; @@ -78,18 +78,26 @@ export const ProjectNameChangeSection = () => {
-
- ( - - - + + {(isAllowed) => ( + ( + + + + )} + control={control} + name="name" + /> )} - control={control} - name="name" - /> +
{(isAllowed) => ( diff --git a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx index eb9daafb0..918a62551 100644 --- a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -244,6 +244,7 @@ export const UserInfoSSOStep = ({ onChange={(e) => setOrganizationName(e.target.value)} isRequired className="h-12" + maxLength={64} disabled /> {organizationNameError && (