diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 6b6aa64a3..4bbe0e2ad 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -8,7 +8,7 @@ assignees: '' --- ### Feature description -A clear and concise description of what the the feature should be. +A clear and concise description of what the feature should be. ### Why would it be useful? Why would this feature be useful for Infisical users? diff --git a/.github/workflows/build-docker-image-to-prod.yml b/.github/workflows/build-docker-image-to-prod.yml index 322a553c4..116ca0cf9 100644 --- a/.github/workflows/build-docker-image-to-prod.yml +++ b/.github/workflows/build-docker-image-to-prod.yml @@ -17,9 +17,9 @@ jobs: - name: 📦 Install dependencies to test all dependencies run: npm ci --only-production working-directory: backend - - name: 🧪 Run tests - run: npm run test:ci - working-directory: backend + # - name: 🧪 Run tests + # run: npm run test:ci + # working-directory: backend - name: Save commit hashes for tag id: commit uses: pr-mpt/actions-commit-hash@v2 diff --git a/.github/workflows/build-staging-img.yml b/.github/workflows/build-staging-img.yml index 3ff23ba23..806e89b87 100644 --- a/.github/workflows/build-staging-img.yml +++ b/.github/workflows/build-staging-img.yml @@ -11,9 +11,9 @@ jobs: - name: 📦 Install dependencies to test all dependencies run: npm ci --only-production working-directory: backend - - name: 🧪 Run tests - run: npm run test:ci - working-directory: backend + # - name: 🧪 Run tests + # run: npm run test:ci + # working-directory: backend - name: Save commit hashes for tag id: commit uses: pr-mpt/actions-commit-hash@v2 diff --git a/.gitignore b/.gitignore index db04f4ec2..da6eb5142 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ yarn-error.log* # Infisical init .infisical.json + +# Editor specific +.vscode/* \ No newline at end of file diff --git a/.infisicalignore b/.infisicalignore index fa3815a6e..b8fafe6db 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -1 +1 @@ -.github/resources/docker-compose.be-test.yml:generic-api-key:16 \ No newline at end of file +.github/resources/docker-compose.be-test.yml:generic-api-key:16 diff --git a/README.md b/README.md index 483195c8a..499f534a1 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ git commit activity - Cloudsmith downloads + Cloudsmith downloads Slack community channel diff --git a/backend/Dockerfile b/backend/Dockerfile index 249ece4f6..06448ad91 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -17,17 +17,17 @@ WORKDIR /app ENV npm_config_cache /home/node/.npm COPY package*.json ./ -RUN npm ci --only-production +RUN npm ci --only-production && npm cache clean --force COPY --from=build /app . RUN apk add --no-cache bash curl && curl -1sLf \ 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \ - && apk add infisical=0.8.1 + && apk add infisical=0.8.1 && apk add --no-cache git HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \ CMD node healthcheck.js EXPOSE 4000 -CMD ["npm", "run", "start"] +CMD ["node", "build/index.js"] diff --git a/backend/package-lock.json b/backend/package-lock.json index 49b39c030..3cc3c9667 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,8 @@ "license": "ISC", "dependencies": { "@aws-sdk/client-secrets-manager": "^3.319.0", + "@casl/ability": "^6.5.0", + "@casl/mongoose": "^7.2.1", "@godaddy/terminus": "^4.12.0", "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^19.0.5", @@ -17,6 +19,7 @@ "@sentry/tracing": "^7.48.0", "@types/crypto-js": "^4.1.1", "@types/libsodium-wrappers": "^0.7.10", + "@ucast/mongo2js": "^1.3.4", "argon2": "^0.30.3", "aws-sdk": "^2.1364.0", "axios": "^1.3.5", @@ -34,6 +37,7 @@ "handlebars": "^4.7.7", "helmet": "^5.1.1", "infisical-node": "^1.2.1", + "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "jsrp": "^0.2.4", @@ -53,14 +57,14 @@ "query-string": "^7.1.3", "rate-limit-mongo": "^2.3.2", "rimraf": "^3.0.2", - "swagger-autogen": "^2.22.0", "swagger-ui-express": "^4.6.2", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", "typescript": "^4.9.3", "utility-types": "^3.10.0", "winston": "^3.8.2", - "winston-loki": "^6.0.7" + "winston-loki": "^6.0.6", + "zod": "^3.21.4" }, "devDependencies": { "@jest/globals": "^29.3.1", @@ -93,6 +97,7 @@ "npm": "^8.19.3", "smee-client": "^1.2.3", "supertest": "^6.3.3", + "swagger-autogen": "^2.23.5", "ts-jest": "^29.0.3", "ts-node": "^10.9.1" } @@ -3340,6 +3345,26 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@casl/ability": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz", + "integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, + "node_modules/@casl/mongoose": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@casl/mongoose/-/mongoose-7.2.1.tgz", + "integrity": "sha512-pojgSWYKNIwFM6wWDNct1YD0+8nIxhe2jp5jBbK8JGU60dEs2o0Yw3mCo2y7nBwbvRC2oEots/BlLMVb1Wdo8A==", + "peerDependencies": { + "@casl/ability": "^6.3.2", + "mongoose": "^6.0.13 || ^7.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -3485,8 +3510,7 @@ "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", - "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", - "dev": true + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", @@ -6175,6 +6199,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "node_modules/@ucast/js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz", + "integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -6977,39 +7032,6 @@ "node": ">=12" } }, - "node_modules/bull/node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "dev": true, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/bull/node_modules/ioredis": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", - "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", - "dev": true, - "dependencies": { - "@ioredis/commands": "^1.1.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -9017,30 +9039,36 @@ } }, "node_modules/ioredis": { - "version": "4.28.5", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.5.tgz", - "integrity": "sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", + "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", "dependencies": { + "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", - "debug": "^4.3.1", - "denque": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", "lodash.defaults": "^4.2.0", - "lodash.flatten": "^4.4.0", "lodash.isarguments": "^3.1.0", - "p-map": "^2.1.0", - "redis-commands": "1.7.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" }, "engines": { - "node": ">=6" + "node": ">=12.22.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/ioredis" } }, + "node_modules/ioredis/node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -9969,6 +9997,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -14401,6 +14430,31 @@ "node": ">=10" } }, + "node_modules/probot/node_modules/ioredis": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.5.tgz", + "integrity": "sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==", + "dependencies": { + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.1", + "denque": "^1.1.0", + "lodash.defaults": "^4.2.0", + "lodash.flatten": "^4.4.0", + "lodash.isarguments": "^3.1.0", + "p-map": "^2.1.0", + "redis-commands": "1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/probot/node_modules/js-yaml": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", @@ -15516,6 +15570,7 @@ "version": "2.23.5", "resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.23.5.tgz", "integrity": "sha512-4Tl2+XhZMyHoBYkABnScHtQE0lKPKUD3NBt09mClrI6UKOUYljKlYw1xiFVwsHCTGR2hAXmhT4PpgjruCtt1ZA==", + "dev": true, "dependencies": { "acorn": "^7.4.1", "deepmerge": "^4.2.2", @@ -15527,6 +15582,7 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -16638,6 +16694,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -19336,6 +19400,20 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@casl/ability": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz", + "integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==", + "requires": { + "@ucast/mongo2js": "^1.3.0" + } + }, + "@casl/mongoose": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@casl/mongoose/-/mongoose-7.2.1.tgz", + "integrity": "sha512-pojgSWYKNIwFM6wWDNct1YD0+8nIxhe2jp5jBbK8JGU60dEs2o0Yw3mCo2y7nBwbvRC2oEots/BlLMVb1Wdo8A==", + "requires": {} + }, "@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -19449,8 +19527,7 @@ "@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", - "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", - "dev": true + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", @@ -21587,6 +21664,37 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "@ucast/js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz", + "integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==", + "requires": { + "@ucast/core": "^1.0.0" + } + }, + "@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "requires": { + "@ucast/core": "^1.4.1" + } + }, + "@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "requires": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -22203,31 +22311,6 @@ "msgpackr": "^1.5.2", "semver": "^7.3.2", "uuid": "^8.3.0" - }, - "dependencies": { - "denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "dev": true - }, - "ioredis": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", - "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", - "dev": true, - "requires": { - "@ioredis/commands": "^1.1.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - } - } } }, "bytes": { @@ -23710,21 +23793,26 @@ "dev": true }, "ioredis": { - "version": "4.28.5", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.5.tgz", - "integrity": "sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", + "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", "requires": { + "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", - "debug": "^4.3.1", - "denque": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", "lodash.defaults": "^4.2.0", - "lodash.flatten": "^4.4.0", "lodash.isarguments": "^3.1.0", - "p-map": "^2.1.0", - "redis-commands": "1.7.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" + }, + "dependencies": { + "denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==" + } } }, "ip": { @@ -24425,7 +24513,8 @@ "json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true }, "jsonwebtoken": { "version": "9.0.1", @@ -27699,6 +27788,24 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==" }, + "ioredis": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.5.tgz", + "integrity": "sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==", + "requires": { + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.1", + "denque": "^1.1.0", + "lodash.defaults": "^4.2.0", + "lodash.flatten": "^4.4.0", + "lodash.isarguments": "^3.1.0", + "p-map": "^2.1.0", + "redis-commands": "1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + } + }, "js-yaml": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", @@ -28526,6 +28633,7 @@ "version": "2.23.5", "resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.23.5.tgz", "integrity": "sha512-4Tl2+XhZMyHoBYkABnScHtQE0lKPKUD3NBt09mClrI6UKOUYljKlYw1xiFVwsHCTGR2hAXmhT4PpgjruCtt1ZA==", + "dev": true, "requires": { "acorn": "^7.4.1", "deepmerge": "^4.2.2", @@ -28536,7 +28644,8 @@ "acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true } } }, @@ -29293,6 +29402,11 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==" } } } diff --git a/backend/package.json b/backend/package.json index 31528343f..79a0bba86 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,8 @@ { "dependencies": { "@aws-sdk/client-secrets-manager": "^3.319.0", + "@casl/ability": "^6.5.0", + "@casl/mongoose": "^7.2.1", "@godaddy/terminus": "^4.12.0", "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^19.0.5", @@ -8,6 +10,7 @@ "@sentry/tracing": "^7.48.0", "@types/crypto-js": "^4.1.1", "@types/libsodium-wrappers": "^0.7.10", + "@ucast/mongo2js": "^1.3.4", "argon2": "^0.30.3", "aws-sdk": "^2.1364.0", "axios": "^1.3.5", @@ -25,6 +28,7 @@ "handlebars": "^4.7.7", "helmet": "^5.1.1", "infisical-node": "^1.2.1", + "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "jsrp": "^0.2.4", @@ -44,14 +48,14 @@ "query-string": "^7.1.3", "rate-limit-mongo": "^2.3.2", "rimraf": "^3.0.2", - "swagger-autogen": "^2.22.0", "swagger-ui-express": "^4.6.2", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", "typescript": "^4.9.3", "utility-types": "^3.10.0", "winston": "^3.8.2", - "winston-loki": "^6.0.7" + "winston-loki": "^6.0.6", + "zod": "^3.21.4" }, "name": "infisical-api", "version": "1.0.0", @@ -111,6 +115,7 @@ "npm": "^8.19.3", "smee-client": "^1.2.3", "supertest": "^6.3.3", + "swagger-autogen": "^2.23.5", "ts-jest": "^29.0.3", "ts-node": "^10.9.1" }, diff --git a/backend/spec.json b/backend/spec.json index 1afbc8dce..ce6638839 100644 --- a/backend/spec.json +++ b/backend/spec.json @@ -68,9 +68,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -112,9 +109,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -156,9 +150,16 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" + } + } + } + }, + "/api/v1/users/me/ip": { + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" } } } @@ -215,9 +216,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -243,9 +241,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -284,9 +279,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -395,9 +387,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -407,6 +396,147 @@ ] } }, + "/api/v1/workspace/{workspaceId}/audit-logs": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/workspace/{workspaceId}/audit-logs/filters/actors": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/workspace/{workspaceId}/trusted-ips": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "post": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/workspace/{workspaceId}/trusted-ips/{trustedIpId}": { + "patch": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trustedIpId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + }, + "delete": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trustedIpId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, "/api/v1/action/{actionId}": { "get": { "description": "", @@ -427,81 +557,495 @@ } } }, - "/api/v1/signup/email/signup": { + "/api/v1/organizations/{organizationId}/plans/table": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/organizations/{organizationId}/plan": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/organizations/{organizationId}/session/trial": { + "post": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/organizations/{organizationId}/plan/billing": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/organizations/{organizationId}/plan/table": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/organizations/{organizationId}/billing-details": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "patch": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/organizations/{organizationId}/billing-details/payment-methods": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "post": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/organizations/{organizationId}/billing-details/payment-methods/{pmtMethodId}": { + "delete": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pmtMethodId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/organizations/{organizationId}/billing-details/tax-ids": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "post": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/organizations/{organizationId}/billing-details/tax-ids/{taxId}": { + "delete": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "taxId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/organizations/{organizationId}/invoices": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/organizations/{organizationId}/licenses": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/sso/redirect/google": { + "get": { + "description": "", + "parameters": [ + { + "name": "callback_port", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "description": "" + } + } + } + }, + "/api/v1/sso/google": { + "get": { + "description": "", + "responses": { + "default": { + "description": "" + } + } + } + }, + "/api/v1/sso/redirect/github": { + "get": { + "description": "", + "parameters": [ + { + "name": "callback_port", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "description": "" + } + } + } + }, + "/api/v1/sso/github": { + "get": { + "description": "", + "responses": { + "default": { + "description": "" + } + } + } + }, + "/api/v1/sso/redirect/saml2/{ssoIdentifier}": { + "get": { + "description": "", + "parameters": [ + { + "name": "ssoIdentifier", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "callback_port", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "description": "" + } + } + } + }, + "/api/v1/sso/saml2/{ssoIdentifier}": { + "post": { + "description": "", + "parameters": [ + { + "name": "ssoIdentifier", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "description": "" + } + } + } + }, + "/api/v1/sso/config": { + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + }, "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" }, "400": { "description": "Bad Request" + } + } + }, + "patch": { + "description": "", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/cloud-products/": { + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/signup/email/signup": { + "post": { + "description": "", + "responses": { + "200": { + "description": "OK" }, "403": { "description": "Forbidden" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - } - } - } - } - } } } }, "/api/v1/signup/email/verify": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" }, - "400": { - "description": "Bad Request" - }, "403": { "description": "Forbidden" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - }, - "code": { - "example": "any" - } - } - } - } - } } } }, "/api/v1/auth/token": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -509,30 +1053,9 @@ "/api/v1/auth/login1": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - }, - "clientPublicKey": { - "example": "any" - } - } - } - } } } } @@ -556,23 +1079,6 @@ "400": { "description": "Bad Request" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - }, - "clientProof": { - "example": "any" - } - } - } - } - } } } }, @@ -591,9 +1097,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -601,7 +1104,16 @@ "/api/v1/auth/checkAuth": { "post": { "description": "", - "parameters": [], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/auth/sessions": { + "delete": { + "description": "", "responses": { "200": { "description": "OK" @@ -625,9 +1137,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -652,30 +1161,12 @@ "400": { "description": "Bad Request" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "isActive": { - "example": "any" - }, - "botKey": { - "example": "any" - } - } - } - } - } } } }, "/api/v1/user/": { "get": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" @@ -686,47 +1177,17 @@ "/api/v1/user-action/": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "action": { - "example": "any" - } - } - } - } } } }, "get": { "description": "", - "parameters": [ - { - "name": "action", - "in": "query", - "schema": { - "type": "string" - } - } - ], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -734,39 +1195,17 @@ "/api/v1/organization/": { "get": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } }, "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "organizationName": { - "example": "any" - } - } - } - } } } } @@ -787,9 +1226,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -810,9 +1246,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -833,9 +1266,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -856,23 +1286,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "example": "any" - } - } - } - } } } } @@ -893,9 +1306,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } }, @@ -914,23 +1324,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - } - } - } - } } } }, @@ -949,23 +1342,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - } - } - } - } } } } @@ -986,32 +1362,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/v1/organization/{organizationId}/subscriptions": { - "get": { - "description": "", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -1052,9 +1402,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -1075,9 +1422,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -1085,19 +1429,14 @@ "/api/v1/workspace/": { "get": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } }, "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" @@ -1105,23 +1444,6 @@ "400": { "description": "Bad Request" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "workspaceName": { - "example": "any" - }, - "organizationId": { - "example": "any" - } - } - } - } - } } } }, @@ -1141,9 +1463,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } }, @@ -1162,9 +1481,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -1185,23 +1501,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "example": "any" - } - } - } - } } } } @@ -1222,23 +1521,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - } - } - } - } } } } @@ -1259,9 +1541,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -1282,9 +1561,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -1305,9 +1581,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -1346,8 +1619,8 @@ } ], "responses": { - "400": { - "description": "Bad Request" + "default": { + "description": "" } } } @@ -1368,9 +1641,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -1391,9 +1661,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -1414,57 +1681,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "role": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/v1/membership/{membershipId}/deny-permissions": { - "post": { - "description": "", - "parameters": [ - { - "name": "membershipId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "permissions": { - "example": "any" - } - } - } - } } } } @@ -1485,23 +1701,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "key": { - "example": "any" - } - } - } - } } } } @@ -1522,9 +1721,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -1548,53 +1744,15 @@ "400": { "description": "Bad Request" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "organizationId": { - "example": "any" - }, - "inviteeEmail": { - "example": "any" - } - } - } - } - } } } }, "/api/v1/invite-org/verify": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - }, - "code": { - "example": "any" - } - } - } - } } } } @@ -1615,9 +1773,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } }, "requestBody": { @@ -1673,9 +1828,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -1710,9 +1862,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -1720,7 +1869,6 @@ "/api/v1/service-token/": { "get": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" @@ -1729,7 +1877,6 @@ }, "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" @@ -1775,27 +1922,9 @@ "/api/v1/password/srp1": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "clientPublicKey": { - "example": "any" - } - } - } - } } } } @@ -1803,7 +1932,6 @@ "/api/v1/password/change-password": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" @@ -1811,74 +1939,15 @@ "400": { "description": "Bad Request" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "clientProof": { - "example": "any" - }, - "protectedKey": { - "example": "any" - }, - "protectedKeyIV": { - "example": "any" - }, - "protectedKeyTag": { - "example": "any" - }, - "encryptedPrivateKey": { - "example": "any" - }, - "encryptedPrivateKeyIV": { - "example": "any" - }, - "encryptedPrivateKeyTag": { - "example": "any" - }, - "salt": { - "example": "any" - }, - "verifier": { - "example": "any" - } - } - } - } - } } } }, "/api/v1/password/email/password-reset": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Forbidden" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - } - } - } - } } } } @@ -1886,53 +1955,27 @@ "/api/v1/password/email/password-reset-verify": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" }, - "400": { - "description": "Bad Request" - }, "403": { "description": "Forbidden" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - }, - "code": { - "example": "any" - } - } - } - } - } } } }, "/api/v1/password/backup-private-key": { "get": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } }, "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" @@ -1940,105 +1983,15 @@ "400": { "description": "Bad Request" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "clientProof": { - "example": "any" - }, - "encryptedPrivateKey": { - "example": "any" - }, - "iv": { - "example": "any" - }, - "tag": { - "example": "any" - }, - "salt": { - "example": "any" - }, - "verifier": { - "example": "any" - } - } - } - } - } } } }, "/api/v1/password/password-reset": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "protectedKey": { - "example": "any" - }, - "protectedKeyIV": { - "example": "any" - }, - "protectedKeyTag": { - "example": "any" - }, - "encryptedPrivateKey": { - "example": "any" - }, - "encryptedPrivateKeyIV": { - "example": "any" - }, - "encryptedPrivateKeyTag": { - "example": "any" - }, - "salt": { - "example": "any" - }, - "verifier": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/v1/stripe/webhook": { - "post": { - "description": "", - "parameters": [ - { - "name": "stripe-signature", - "in": "header", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -2046,60 +1999,9 @@ "/api/v1/integration/": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "integrationAuthId": { - "example": "any" - }, - "app": { - "example": "any" - }, - "appId": { - "example": "any" - }, - "isActive": { - "example": "any" - }, - "sourceEnvironment": { - "example": "any" - }, - "targetEnvironment": { - "example": "any" - }, - "targetEnvironmentId": { - "example": "any" - }, - "targetService": { - "example": "any" - }, - "targetServiceId": { - "example": "any" - }, - "owner": { - "example": "any" - }, - "path": { - "example": "any" - }, - "region": { - "example": "any" - } - } - } - } } } } @@ -2120,38 +2022,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "environment": { - "example": "any" - }, - "isActive": { - "example": "any" - }, - "app": { - "example": "any" - }, - "appId": { - "example": "any" - }, - "targetEnvironment": { - "example": "any" - }, - "owner": { - "example": "any" - } - } - } - } } } }, @@ -2170,9 +2040,16 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" + } + } + } + }, + "/api/v1/integration/manual-sync": { + "post": { + "description": "", + "responses": { + "200": { + "description": "OK" } } } @@ -2180,7 +2057,6 @@ "/api/v1/integration-auth/integration-options": { "get": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" @@ -2235,33 +2111,9 @@ "/api/v1/integration-auth/oauth-token": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "workspaceId": { - "example": "any" - }, - "code": { - "example": "any" - }, - "integration": { - "example": "any" - } - } - } - } } } } @@ -2269,36 +2121,9 @@ "/api/v1/integration-auth/access-token": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "workspaceId": { - "example": "any" - }, - "accessId": { - "example": "any" - }, - "accessToken": { - "example": "any" - }, - "integration": { - "example": "any" - } - } - } - } } } } @@ -2314,21 +2139,11 @@ "schema": { "type": "string" } - }, - { - "name": "teamId", - "in": "query", - "schema": { - "type": "string" - } } ], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -2364,13 +2179,6 @@ "schema": { "type": "string" } - }, - { - "name": "appId", - "in": "query", - "schema": { - "type": "string" - } } ], "responses": { @@ -2391,13 +2199,6 @@ "schema": { "type": "string" } - }, - { - "name": "appId", - "in": "query", - "schema": { - "type": "string" - } } ], "responses": { @@ -2418,13 +2219,991 @@ "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/integration-auth/{integrationAuthId}/bitbucket/workspaces": { + "get": { + "description": "", + "parameters": [ + { + "name": "integrationAuthId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/integration-auth/{integrationAuthId}/northflank/secret-groups": { + "get": { + "description": "", + "parameters": [ + { + "name": "integrationAuthId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/integration-auth/{integrationAuthId}/teamcity/build-configs": { + "get": { + "description": "", + "parameters": [ + { + "name": "integrationAuthId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/folders/": { + "post": { + "summary": "Create a folder", + "description": "Create a new folder in a specified workspace and environment", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "someFolderId" + }, + "name": { + "type": "string", + "example": "my_folder" + } + }, + "description": "Details of the created folder" + } + } + } + } + } + }, + "400": { + "description": "Bad Request. For example, 'Folder name cannot contain spaces. Only underscore and dashes'" + }, + "401": { + "description": "Unauthorized request. For example, 'Folder Permission Denied'" + } + }, + "security": [ + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "description": "ID of the workspace where the folder will be created", + "example": "someWorkspaceId" + }, + "environment": { + "type": "string", + "description": "Environment where the folder will reside", + "example": "production" + }, + "folderName": { + "type": "string", + "description": "Name of the folder to be created", + "example": "my_folder" + }, + "parentFolderId": { + "type": "string", + "description": "ID of the parent folder under which this folder will be created. If not specified, it will be created at the root level.", + "example": "someParentFolderId" + } + }, + "required": [ + "workspaceId", + "environment", + "folderName" + ] + } + } + } + } + }, + "get": { + "summary": "Retrieve folders based on specific conditions", + "description": "Fetches folders from the specified workspace and environment, optionally providing either a parentFolderId or a parentFolderPath to narrow down results", + "parameters": [ + { + "name": "workspaceId", + "description": "ID of the workspace from which the folders are to be fetched", + "required": true, + "in": "query", + "schema": { + "type": "string" + } }, { - "name": "appId", + "name": "environment", + "description": "Environment where the folder is located", + "required": true, "in": "query", "schema": { "type": "string" } + }, + { + "name": "parentFolderId", + "description": "ID of the parent folder", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "parentFolderPath", + "description": "Path of the parent folder, like /folder1/folder2", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "folders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "someFolderId" + }, + "name": { + "type": "string", + "example": "someFolderName" + } + } + }, + "description": "List of folders" + }, + "dir": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "parentFolderName" + }, + "id": { + "type": "string", + "example": "parentFolderId" + } + } + }, + "description": "List of directories" + } + } + } + } + } + }, + "400": { + "description": "Bad Request. For instance, 'The folder doesn't exist'" + }, + "401": { + "description": "Unauthorized request. For example, 'Folder Permission Denied'" + } + }, + "security": [ + { + "apiKeyAuth": [] + } + ] + } + }, + "/api/v1/folders/{folderId}": { + "patch": { + "summary": "Update a folder by ID", + "description": "Update the name of a folder in a specified workspace and environment by its ID", + "parameters": [ + { + "name": "folderId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of the folder to be updated" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully updated folder" + }, + "folder": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "updated_folder_name" + }, + "id": { + "type": "string", + "example": "someFolderId" + } + }, + "description": "Details of the updated folder" + } + } + } + } + } + }, + "400": { + "description": "Bad Request. Reasons can include 'The folder doesn't exist' or 'Folder name cannot contain spaces. Only underscore and dashes'" + }, + "401": { + "description": "Unauthorized request. For example, 'Folder Permission Denied'" + } + }, + "security": [ + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "description": "ID of the workspace where the folder is located", + "example": "someWorkspaceId" + }, + "environment": { + "type": "string", + "description": "Environment where the folder is located", + "example": "production" + }, + "name": { + "type": "string", + "description": "New name for the folder", + "example": "updated_folder_name" + } + }, + "required": [ + "workspaceId", + "environment", + "name" + ] + } + } + } + } + }, + "delete": { + "summary": "Delete a folder by ID", + "description": "Delete the specified folder from a specified workspace and environment using its ID", + "parameters": [ + { + "name": "folderId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of the folder to be deleted" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "successfully deleted folders" + }, + "folders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "someFolderId" + }, + "name": { + "type": "string", + "example": "someFolderName" + } + } + }, + "description": "List of IDs and names of the deleted folders" + } + } + } + } + } + }, + "400": { + "description": "Bad Request. Reasons can include 'The folder doesn't exist'" + }, + "401": { + "description": "Unauthorized request. For example, 'Folder Permission Denied'" + } + }, + "security": [ + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "description": "ID of the workspace where the folder is located", + "example": "someWorkspaceId" + }, + "environment": { + "type": "string", + "description": "Environment where the folder is located", + "example": "production" + } + }, + "required": [ + "workspaceId", + "environment" + ] + } + } + } + } + } + }, + "/api/v1/secret-scanning/create-installation-session/organization/{organizationId}": { + "post": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/secret-scanning/link-installation": { + "post": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/secret-scanning/installation-status/organization/{organizationId}": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/secret-scanning/organization/{organizationId}/risks": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/secret-scanning/organization/{organizationId}/risks/{riskId}/status": { + "post": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "riskId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/webhooks/": { + "post": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/webhooks/{webhookId}": { + "patch": { + "description": "", + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "description": "", + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/webhooks/{webhookId}/test": { + "post": { + "description": "", + "parameters": [ + { + "name": "webhookId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/secret-imports/": { + "post": { + "summary": "Create secret import", + "description": "Create a new secret import for a specified workspace and environment", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "successfully created secret import" + } + }, + "description": "Confirmation of secret import creation" + } + } + } + }, + "400": { + "description": "Bad Request. For example, 'Secret import already exist'" + }, + "401": { + "description": "Unauthorized request. For example, 'Folder Permission Denied'" + }, + "404": { + "description": "Resource Not Found. For example, 'Failed to find folder'" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "description": "ID of the workspace where the secret import will be created", + "example": "someWorkspaceId" + }, + "environment": { + "type": "string", + "description": "Environment to import to", + "example": "production" + }, + "folderId": { + "type": "string", + "description": "Folder ID. Use root for the root folder.", + "example": "my_folder" + }, + "secretImport": { + "type": "object", + "properties": { + "environment": { + "type": "string", + "description": "Import from environment", + "example": "development" + }, + "secretPath": { + "type": "string", + "description": "Import from secret path", + "example": "/user/oauth" + } + } + } + }, + "required": [ + "workspaceId", + "environment", + "folderName" + ] + } + } + } + } + }, + "get": { + "summary": "Retrieve secret imports", + "description": "Fetches the secret imports based on the workspaceId, environment, and folderId", + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "description": "ID of the workspace of secret imports to get", + "required": true, + "example": "workspace12345", + "schema": { + "type": "string" + } + }, + { + "name": "environment", + "in": "query", + "description": "Environment of secret imports to get", + "required": true, + "example": "production", + "schema": { + "type": "string" + } + }, + { + "name": "folderId", + "in": "query", + "description": "ID of the folder containing the secret imports. Default: root", + "required": false, + "example": "folder12345", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully retrieved secret import", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secretImport": { + "type": "object", + "description": "Details of a secret import" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized access due to invalid token or scope" + }, + "403": { + "description": "Forbidden access due to insufficient permissions" + } + } + } + }, + "/api/v1/secret-imports/{id}": { + "put": { + "summary": "Update a secret import", + "description": "Updates an existing secret import based on the provided ID and new import details", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of the secret import to be updated", + "example": "import12345" + } + ], + "responses": { + "200": { + "description": "Successfully updated the secret import", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "successfully updated secret import" + } + } + } + } + } + }, + "400": { + "description": "Bad Request - Import not found" + }, + "401": { + "description": "Unauthorized access due to invalid token or scope" + }, + "403": { + "description": "Forbidden access due to insufficient permissions" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secretImports": { + "type": "array", + "description": "List of new secret imports", + "items": { + "type": "object", + "properties": { + "environment": { + "type": "string", + "description": "Environment of the secret import", + "example": "production" + }, + "secretPath": { + "type": "string", + "description": "Path of the secret import", + "example": "/path/to/secret" + } + }, + "required": [ + "environment", + "secretPath" + ] + } + } + }, + "required": [ + "secretImports" + ] + } + } + } + } + }, + "delete": { + "summary": "Delete secret import", + "description": "Delete secret import", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of the secret import", + "example": "12345abcde" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "successfully delete secret import" + } + }, + "description": "Confirmation of secret import deletion" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secretImportEnv": { + "type": "string", + "description": "Import from environment", + "example": "someWorkspaceId" + }, + "secretImportPath": { + "type": "string", + "description": "Import from secret path", + "example": "production" + } + }, + "required": [ + "id", + "secretImportEnv", + "secretImportPath" + ] + } + } + } + } + } + }, + "/api/v1/secret-imports/secrets": { + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/roles/": { + "post": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/roles/{id}": { + "patch": { + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/roles/organization/{orgId}/permissions": { + "get": { + "description": "", + "parameters": [ + { + "name": "orgId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/roles/workspace/{workspaceId}/permissions": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } } ], "responses": { @@ -2437,14 +3216,19 @@ "/api/v2/signup/complete-account/signup": { "post": { "description": "", - "parameters": [], + "parameters": [ + { + "name": "user-agent", + "in": "header", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "OK" }, - "400": { - "description": "Bad Request" - }, "403": { "description": "Forbidden" } @@ -2504,14 +3288,19 @@ "/api/v2/signup/complete-account/invite": { "post": { "description": "", - "parameters": [], + "parameters": [ + { + "name": "user-agent", + "in": "header", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "OK" }, - "400": { - "description": "Bad Request" - }, "403": { "description": "Forbidden" } @@ -2568,13 +3357,9 @@ "/api/v2/auth/login1": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } }, "requestBody": { @@ -2638,27 +3423,9 @@ "/api/v2/auth/mfa/send": { "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - } - } - } - } } } } @@ -2679,23 +3446,6 @@ "200": { "description": "OK" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "example": "any" - }, - "mfaToken": { - "example": "any" - } - } - } - } - } } } }, @@ -2703,7 +3453,6 @@ "get": { "summary": "Retrieve the current user on the request", "description": "Retrieve the current user on the request", - "parameters": [], "responses": { "200": { "description": "OK", @@ -2721,9 +3470,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -2736,7 +3482,26 @@ "/api/v2/users/me/mfa": { "patch": { "description": "", - "parameters": [], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v2/users/me/name": { + "patch": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v2/users/me/auth-methods": { + "put": { + "description": "", "responses": { "200": { "description": "OK" @@ -2744,20 +3509,6 @@ "400": { "description": "Bad Request" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "isMfaEnabled": { - "example": "any" - } - } - } - } - } } } }, @@ -2765,7 +3516,6 @@ "get": { "summary": "Return organizations that current user is part of", "description": "Return organizations that current user is part of", - "parameters": [], "responses": { "200": { "description": "OK", @@ -2785,9 +3535,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -2797,6 +3544,62 @@ ] } }, + "/api/v2/users/me/api-keys": { + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + }, + "post": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v2/users/me/api-keys/{apiKeyDataId}": { + "delete": { + "description": "", + "parameters": [ + { + "name": "apiKeyDataId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v2/users/me/sessions": { + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/v2/organizations/{organizationId}/memberships": { "get": { "summary": "Return organization memberships", @@ -2831,9 +3634,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -2883,9 +3683,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -2949,9 +3746,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -3026,7 +3820,8 @@ }, "/api/v2/workspace/{workspaceId}/environments": { "post": { - "description": "", + "summary": "Create environment", + "description": "Create environment", "parameters": [ { "name": "workspaceId", @@ -3034,17 +3829,54 @@ "required": true, "schema": { "type": "string" - } + }, + "description": "ID of project" } ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully created new environment" + }, + "workspace": { + "type": "string", + "example": "someWorkspaceId" + }, + "environment": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "someEnvironmentName" + }, + "slug": { + "type": "string", + "example": "someEnvironmentSlug" + } + } + } + }, + "description": "Response after creating a new environment" + } + } + } }, "400": { "description": "Bad Request" } }, + "security": [ + { + "apiKeyAuth": [] + } + ], "requestBody": { "content": { "application/json": { @@ -3052,19 +3884,28 @@ "type": "object", "properties": { "environmentName": { - "example": "any" + "type": "string", + "description": "Name of the environment", + "example": "development" }, "environmentSlug": { - "example": "any" + "type": "string", + "description": "Slug of the environment", + "example": "dev-environment" } - } + }, + "required": [ + "environmentName", + "environmentSlug" + ] } } } } }, "put": { - "description": "", + "summary": "Rename workspace environment", + "description": "Rename a specific environment within a workspace", "parameters": [ { "name": "workspaceId", @@ -3072,15 +3913,44 @@ "required": true, "schema": { "type": "string" - } + }, + "description": "ID of the workspace" } ], "responses": { "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully update environment" + }, + "workspace": { + "type": "string", + "example": "someWorkspaceId" + }, + "environment": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Staging-Renamed" + }, + "slug": { + "type": "string", + "example": "staging-renamed" + } + } + } + }, + "description": "Details of the renamed environment" + } + } + } } }, "requestBody": { @@ -3090,21 +3960,32 @@ "type": "object", "properties": { "environmentName": { - "example": "any" + "type": "string", + "description": "New name for the environment", + "example": "Staging-Renamed" }, "environmentSlug": { - "example": "any" + "type": "string", + "description": "New slug for the environment", + "example": "staging-renamed" }, "oldEnvironmentSlug": { - "example": "any" + "type": "string", + "description": "Current slug of the environment to rename", + "example": "staging-old" } - } + }, + "required": [ + "environmentName", + "environmentSlug", + "oldEnvironmentSlug" + ] } } } } }, - "delete": { + "patch": { "description": "", "parameters": [ { @@ -3119,11 +4000,55 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" + } + } + }, + "delete": { + "summary": "Delete workspace environment", + "description": "Delete a specific environment from a workspace", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of the workspace" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully deleted environment" + }, + "workspace": { + "type": "string", + "example": "someWorkspaceId" + }, + "environment": { + "type": "string", + "example": "dev-environment" + } + }, + "description": "Response after deleting an environment from a workspace" + } + } + } } }, + "security": [ + { + "apiKeyAuth": [] + } + ], "requestBody": { "content": { "application/json": { @@ -3131,16 +4056,22 @@ "type": "object", "properties": { "environmentSlug": { - "example": "any" + "type": "string", + "description": "Slug of the environment to delete", + "example": "dev-environment" } - } + }, + "required": [ + "environmentSlug" + ] } } } } }, "get": { - "description": "", + "summary": "Get all accessible environments of a workspace", + "description": "Fetch all environments that the user has access to in a specified workspace", "parameters": [ { "name": "workspaceId", @@ -3148,14 +4079,54 @@ "required": true, "schema": { "type": "string" - } + }, + "description": "ID of the workspace" } ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accessibleEnvironments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Development" + }, + "slug": { + "type": "string", + "example": "development" + }, + "isWriteDenied": { + "type": "boolean", + "example": false + }, + "isReadDenied": { + "type": "boolean", + "example": false + } + } + } + } + }, + "description": "List of environments the user has access to in the specified workspace" + } + } + } } - } + }, + "security": [ + { + "apiKeyAuth": [] + } + ] } }, "/api/v2/workspace/{workspaceId}/tags": { @@ -3193,26 +4164,6 @@ "200": { "description": "OK" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "example": "any" - }, - "tagColor": { - "example": "any" - }, - "slug": { - "example": "any" - } - } - } - } - } } } }, @@ -3252,9 +4203,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } }, "requestBody": { @@ -3310,9 +4258,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -3346,9 +4291,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -3374,9 +4316,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } } } @@ -3415,9 +4354,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -3467,9 +4403,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -3533,9 +4466,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -3561,23 +4491,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "autoCapitalization": { - "example": "any" - } - } - } - } } } } @@ -3873,26 +4786,6 @@ "200": { "description": "OK" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "workspaceId": { - "example": "any" - }, - "environment": { - "example": "any" - }, - "requests": { - "example": "any" - } - } - } - } - } } } }, @@ -3988,13 +4881,6 @@ "schema": { "type": "string" } - }, - { - "name": "content", - "in": "query", - "schema": { - "type": "string" - } } ], "responses": { @@ -4027,7 +4913,6 @@ "patch": { "summary": "Update secret(s)", "description": "Update secret(s)", - "parameters": [], "responses": { "200": { "description": "OK", @@ -4131,7 +5016,6 @@ "get": { "summary": "Return Infisical Token data", "description": "Return Infisical Token data", - "parameters": [], "responses": { "200": { "description": "OK", @@ -4159,46 +5043,10 @@ }, "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "example": "any" - }, - "workspaceId": { - "example": "any" - }, - "environment": { - "example": "any" - }, - "encryptedKey": { - "example": "any" - }, - "iv": { - "example": "any" - }, - "tag": { - "example": "any" - }, - "expiresIn": { - "example": "any" - }, - "permissions": { - "example": "any" - } - } - } - } - } } } }, @@ -4222,10 +5070,9 @@ } } }, - "/api/v2/service-accounts/me": { - "get": { + "/api/v3/auth/login1": { + "post": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" @@ -4233,12 +5080,44 @@ } } }, - "/api/v2/service-accounts/{serviceAccountId}": { + "/api/v3/auth/login2": { + "post": { + "description": "", + "parameters": [ + { + "name": "user-agent", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v3/secrets/raw": { + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v3/secrets/raw/{secretName}": { "get": { "description": "", "parameters": [ { - "name": "serviceAccountId", + "name": "secretName", "in": "path", "required": true, "schema": { @@ -4252,11 +5131,11 @@ } } }, - "delete": { + "post": { "description": "", "parameters": [ { - "name": "serviceAccountId", + "name": "secretName", "in": "path", "required": true, "schema": { @@ -4269,59 +5148,12 @@ "description": "OK" } } - } - }, - "/api/v2/service-accounts/": { - "post": { - "description": "", - "parameters": [], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v2/service-accounts/{serviceAccountId}/name": { + }, "patch": { "description": "", "parameters": [ { - "name": "serviceAccountId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/v2/service-accounts/{serviceAccountId}/permissions/workspace": { - "get": { - "description": "", - "parameters": [ - { - "name": "serviceAccountId", + "name": "secretName", "in": "path", "required": true, "schema": { @@ -4335,71 +5167,11 @@ } } }, - "post": { - "description": "", - "parameters": [ - { - "name": "serviceAccountId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "environment": { - "example": "any" - }, - "workspaceId": { - "example": "any" - }, - "read": { - "example": "any" - }, - "write": { - "example": "any" - }, - "encryptedKey": { - "example": "any" - }, - "nonce": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/v2/service-accounts/{serviceAccountId}/permissions/workspace/{serviceAccountWorkspacePermissionId}": { "delete": { "description": "", "parameters": [ { - "name": "serviceAccountId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "serviceAccountWorkspacePermissionId", + "name": "secretName", "in": "path", "required": true, "schema": { @@ -4414,118 +5186,9 @@ } } }, - "/api/v2/service-accounts/{serviceAccountId}/keys": { - "get": { - "description": "", - "parameters": [ - { - "name": "serviceAccountId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "workspaceId", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/v2/api-key/": { - "get": { - "description": "", - "parameters": [], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "post": { - "description": "", - "parameters": [], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "example": "any" - }, - "expiresIn": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/v2/api-key/{apiKeyDataId}": { - "delete": { - "description": "", - "parameters": [ - { - "name": "apiKeyDataId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, "/api/v3/secrets/": { "get": { "description": "", - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "environment", - "in": "query", - "schema": { - "type": "string" - } - } - ], "responses": { "200": { "description": "OK" @@ -4550,53 +5213,6 @@ "200": { "description": "OK" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "workspaceId": { - "example": "any" - }, - "environment": { - "example": "any" - }, - "type": { - "example": "any" - }, - "secretKeyCiphertext": { - "example": "any" - }, - "secretKeyIV": { - "example": "any" - }, - "secretKeyTag": { - "example": "any" - }, - "secretValueCiphertext": { - "example": "any" - }, - "secretValueIV": { - "example": "any" - }, - "secretValueTag": { - "example": "any" - }, - "secretCommentCiphertext": { - "example": "any" - }, - "secretCommentIV": { - "example": "any" - }, - "secretCommentTag": { - "example": "any" - } - } - } - } - } } }, "get": { @@ -4609,27 +5225,6 @@ "schema": { "type": "string" } - }, - { - "name": "workspaceId", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "environment", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "type", - "in": "query", - "schema": { - "type": "string" - } } ], "responses": { @@ -4654,35 +5249,6 @@ "200": { "description": "OK" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "workspaceId": { - "example": "any" - }, - "environment": { - "example": "any" - }, - "type": { - "example": "any" - }, - "secretValueCiphertext": { - "example": "any" - }, - "secretValueIV": { - "example": "any" - }, - "secretValueTag": { - "example": "any" - } - } - } - } - } } }, "delete": { @@ -4701,26 +5267,6 @@ "200": { "description": "OK" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "workspaceId": { - "example": "any" - }, - "environment": { - "example": "any" - }, - "type": { - "example": "any" - } - } - } - } - } } } }, @@ -4781,19 +5327,37 @@ "200": { "description": "OK" } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "secretsToUpdate": { - "example": "any" - } - } - } + } + } + }, + "/api/v3/signup/complete-account/signup": { + "post": { + "description": "", + "parameters": [ + { + "name": "authorization", + "in": "header", + "schema": { + "type": "string" } + }, + { + "name": "user-agent", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" } } } @@ -4801,7 +5365,6 @@ "/api/status": { "get": { "description": "", - "parameters": [], "responses": { "200": { "description": "OK" diff --git a/backend/src/controllers/v1/authController.ts b/backend/src/controllers/v1/authController.ts index 14c717e38..6f6411541 100644 --- a/backend/src/controllers/v1/authController.ts +++ b/backend/src/controllers/v1/authController.ts @@ -17,6 +17,8 @@ import { getJwtRefreshSecret } from "../../config"; import { ActorType } from "../../ee/models"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/auth"; declare module "jsonwebtoken" { export interface UserIDJwtPayload extends jwt.JwtPayload { @@ -32,7 +34,9 @@ declare module "jsonwebtoken" { * @returns */ export const login1 = async (req: Request, res: Response) => { - const { email, clientPublicKey }: { email: string; clientPublicKey: string } = req.body; + const { + body: { email, clientPublicKey } + } = await validateRequest(reqValidator.Login1V1, req); const user = await User.findOne({ email @@ -76,7 +80,10 @@ export const login1 = async (req: Request, res: Response) => { * @returns */ export const login2 = async (req: Request, res: Response) => { - const { email, clientProof } = req.body; + const { + body: { email, clientProof } + } = await validateRequest(reqValidator.Login2V1, req); + const user = await User.findOne({ email }).select("+salt +verifier +publicKey +encryptedPrivateKey +iv +tag"); diff --git a/backend/src/controllers/v1/botController.ts b/backend/src/controllers/v1/botController.ts index b2e757541..65251e83a 100644 --- a/backend/src/controllers/v1/botController.ts +++ b/backend/src/controllers/v1/botController.ts @@ -2,10 +2,19 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { Bot, BotKey } from "../../models"; import { createBot } from "../../helpers/bot"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/bot"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { ForbiddenError } from "@casl/ability"; +import { BadRequestError } from "../../utils/errors"; interface BotKey { - encryptedKey: string; - nonce: string; + encryptedKey: string; + nonce: string; } /** @@ -16,23 +25,30 @@ interface BotKey { * @returns */ export const getBotByWorkspaceId = async (req: Request, res: Response) => { - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetBotByWorkspaceIdV1, req); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); let bot = await Bot.findOne({ - workspace: workspaceId, + workspace: workspaceId }); - + if (!bot) { - // case: bot doesn't exist for workspace with id [workspaceId] - // -> create a new bot and return it - bot = await createBot({ - name: "Infisical Bot", - workspaceId: new Types.ObjectId(workspaceId), - }); + // case: bot doesn't exist for workspace with id [workspaceId] + // -> create a new bot and return it + bot = await createBot({ + name: "Infisical Bot", + workspaceId: new Types.ObjectId(workspaceId) + }); } - + return res.status(200).send({ - bot, + bot }); }; @@ -43,46 +59,69 @@ export const getBotByWorkspaceId = async (req: Request, res: Response) => { * @returns */ export const setBotActiveState = async (req: Request, res: Response) => { - const { isActive, botKey }: { isActive: boolean, botKey: BotKey } = req.body; - - if (isActive) { - // bot state set to active -> share workspace key with bot - if (!botKey?.encryptedKey || !botKey?.nonce) { - return res.status(400).send({ - message: "Failed to set bot state to active - missing bot key", - }); - } - - await BotKey.findOneAndUpdate({ - workspace: req.bot.workspace, - }, { - encryptedKey: botKey.encryptedKey, - nonce: botKey.nonce, - sender: req.user._id, - bot: req.bot._id, - workspace: req.bot.workspace, - }, { - upsert: true, - new: true, - }); - } else { - // case: bot state set to inactive -> delete bot's workspace key - await BotKey.deleteOne({ - bot: req.bot._id, - }); + const { + body: { botKey, isActive }, + params: { botId } + } = await validateRequest(reqValidator.SetBotActiveStateV1, req); + + const bot = await Bot.findById(botId); + if (!bot) { + throw BadRequestError({ message: "Bot not found" }); + } + const userId = req.user._id; + + const { permission } = await getUserProjectPermissions(userId, bot.workspace.toString()); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Integrations + ); + + if (isActive) { + // bot state set to active -> share workspace key with bot + if (!botKey?.encryptedKey || !botKey?.nonce) { + return res.status(400).send({ + message: "Failed to set bot state to active - missing bot key" + }); } - const bot = await Bot.findOneAndUpdate({ - _id: req.bot._id, - }, { - isActive, - }, { - new: true, - }); - - if (!bot) throw new Error("Failed to update bot active state"); - - return res.status(200).send({ - bot, + await BotKey.findOneAndUpdate( + { + workspace: bot.workspace + }, + { + encryptedKey: botKey.encryptedKey, + nonce: botKey.nonce, + sender: userId, + bot: bot._id, + workspace: bot.workspace + }, + { + upsert: true, + new: true + } + ); + } else { + // case: bot state set to inactive -> delete bot's workspace key + await BotKey.deleteOne({ + bot: bot._id }); + } + + const updatedBot = await Bot.findOneAndUpdate( + { + _id: bot._id + }, + { + isActive + }, + { + new: true + } + ); + + if (!updatedBot) throw new Error("Failed to update bot active state"); + + return res.status(200).send({ + bot + }); }; diff --git a/backend/src/controllers/v1/index.ts b/backend/src/controllers/v1/index.ts index 422aaa5ec..a9bde9971 100644 --- a/backend/src/controllers/v1/index.ts +++ b/backend/src/controllers/v1/index.ts @@ -15,7 +15,7 @@ import * as userController from "./userController"; import * as workspaceController from "./workspaceController"; import * as secretScanningController from "./secretScanningController"; import * as webhookController from "./webhookController"; -import * as secretImportController from "./secretImportController"; +import * as secretImpsController from "./secretImpsController"; export { authController, @@ -35,5 +35,5 @@ export { workspaceController, secretScanningController, webhookController, - secretImportController + secretImpsController }; diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index b91319a2f..06b245659 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { standardRequest } from "../../config/request"; import { getApps, getTeams, revokeAccess } from "../../integrations"; -import { Bot, IntegrationAuth } from "../../models"; +import { Bot, IntegrationAuth, Workspace } from "../../models"; import { EventType } from "../../ee/models"; import { IntegrationService } from "../../services"; import { EEAuditLogService } from "../../ee/services"; @@ -10,18 +10,34 @@ import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_UTF8, INTEGRATION_BITBUCKET_API_URL, + INTEGRATION_GCP_SECRET_MANAGER, INTEGRATION_NORTHFLANK_API_URL, + INTEGRATION_QOVERY_API_URL, INTEGRATION_RAILWAY_API_URL, INTEGRATION_SET, INTEGRATION_VERCEL_API_URL, getIntegrationOptions as getIntegrationOptionsFunc } from "../../variables"; +import { exchangeRefresh } from "../../integrations"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/integrationAuth"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { ForbiddenError } from "@casl/ability"; +import { getIntegrationAuthAccessHelper } from "../../helpers"; +import { ObjectId } from "mongodb"; /*** * Return integration authorization with id [integrationAuthId] */ export const getIntegrationAuth = async (req: Request, res: Response) => { - const { integrationAuthId } = req.params; + const { + params: { integrationAuthId } + } = await validateRequest(reqValidator.GetIntegrationAuthV1, req); + const integrationAuth = await IntegrationAuth.findById(integrationAuthId); if (!integrationAuth) @@ -29,6 +45,15 @@ export const getIntegrationAuth = async (req: Request, res: Response) => { message: "Failed to find integration authorization" }); + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + return res.status(200).send({ integrationAuth }); @@ -49,10 +74,19 @@ export const getIntegrationOptions = async (req: Request, res: Response) => { * @returns */ export const oAuthExchange = async (req: Request, res: Response) => { - const { workspaceId, code, integration } = req.body; + const { + body: { integration, workspaceId, code, url } + } = await validateRequest(reqValidator.OauthExchangeV1, req); if (!INTEGRATION_SET.has(integration)) throw new Error("Failed to validate integration"); - const environments = req.membership.workspace?.environments || []; + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Integrations + ); + + const workspace = await Workspace.findById(workspaceId); + const environments = workspace?.environments || []; if (environments.length === 0) { throw new Error("Failed to get environments"); } @@ -61,7 +95,8 @@ export const oAuthExchange = async (req: Request, res: Response) => { workspaceId, integration, code, - environment: environments[0].slug + environment: environments[0].slug, + url }); await EEAuditLogService.createAuditLog( @@ -88,26 +123,19 @@ export const oAuthExchange = async (req: Request, res: Response) => { * @param req * @param res */ -export const saveIntegrationAccessToken = async (req: Request, res: Response) => { +export const saveIntegrationToken = async (req: Request, res: Response) => { // TODO: refactor // TODO: check if access token is valid for each integration - let integrationAuth; const { - workspaceId, - accessId, - accessToken, - url, - namespace, - integration - }: { - workspaceId: string; - accessId: string | null; - accessToken: string; - url: string; - namespace: string; - integration: string; - } = req.body; + body: { workspaceId, integration, url, accessId, namespace, accessToken, refreshToken } + } = await validateRequest(reqValidator.SaveIntegrationAccessTokenV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Integrations + ); const bot = await Bot.findOne({ workspace: new Types.ObjectId(workspaceId), @@ -127,7 +155,14 @@ export const saveIntegrationAccessToken = async (req: Request, res: Response) => url, namespace, algorithm: ALGORITHM_AES_256_GCM, - keyEncoding: ENCODING_SCHEME_UTF8 + keyEncoding: ENCODING_SCHEME_UTF8, + ...(integration === INTEGRATION_GCP_SECRET_MANAGER + ? { + metadata: { + authMethod: "serviceAccount" + } + } + : {}) }, { new: true, @@ -136,15 +171,25 @@ export const saveIntegrationAccessToken = async (req: Request, res: Response) => ); // encrypt and save integration access details - integrationAuth = await IntegrationService.setIntegrationAuthAccess({ - integrationAuthId: integrationAuth._id.toString(), - accessId, - accessToken, - accessExpiresAt: undefined - }); + if (refreshToken) { + await exchangeRefresh({ + integrationAuth, + refreshToken + }); + } + + // encrypt and save integration access details + if (accessId || accessToken) { + integrationAuth = await IntegrationService.setIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString(), + accessId, + accessToken, + accessExpiresAt: undefined + }); + } if (!integrationAuth) throw new Error("Failed to save integration access token"); - + await EEAuditLogService.createAuditLog( req.authData, { @@ -170,13 +215,29 @@ export const saveIntegrationAccessToken = async (req: Request, res: Response) => * @returns */ export const getIntegrationAuthApps = async (req: Request, res: Response) => { - const teamId = req.query.teamId as string; - const workspaceSlug = req.query.workspaceSlug as string; + const { + params: { integrationAuthId }, + query: { teamId, workspaceSlug } + } = await validateRequest(reqValidator.GetIntegrationAuthAppsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken, accessId } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); const apps = await getApps({ - integrationAuth: req.integrationAuth, - accessToken: req.accessToken, - accessId: req.accessId, + integrationAuth: integrationAuth, + accessToken: accessToken, + accessId: accessId, ...(teamId && { teamId }), ...(workspaceSlug && { workspaceSlug }) }); @@ -193,9 +254,27 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => { * @returns */ export const getIntegrationAuthTeams = async (req: Request, res: Response) => { + const { + params: { integrationAuthId } + } = await validateRequest(reqValidator.GetIntegrationAuthTeamsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const teams = await getTeams({ - integrationAuth: req.integrationAuth, - accessToken: req.accessToken + integrationAuth: integrationAuth, + accessToken: accessToken }); return res.status(200).send({ @@ -210,7 +289,24 @@ export const getIntegrationAuthTeams = async (req: Request, res: Response) => { * @param res */ export const getIntegrationAuthVercelBranches = async (req: Request, res: Response) => { - const appId = req.query.appId as string; + const { + params: { integrationAuthId }, + query: { appId } + } = await validateRequest(reqValidator.GetIntegrationAuthVercelBranchesV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); interface VercelBranch { ref: string; @@ -220,9 +316,9 @@ export const getIntegrationAuthVercelBranches = async (req: Request, res: Respon const params = new URLSearchParams({ projectId: appId, - ...(req.integrationAuth.teamId + ...(integrationAuth.teamId ? { - teamId: req.integrationAuth.teamId + teamId: integrationAuth.teamId } : {}) }); @@ -235,7 +331,7 @@ export const getIntegrationAuthVercelBranches = async (req: Request, res: Respon { params, headers: { - Authorization: `Bearer ${req.accessToken}`, + Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json" } } @@ -249,6 +345,362 @@ export const getIntegrationAuthVercelBranches = async (req: Request, res: Respon }); }; +/** + * Return list of Qovery Orgs for a specific user + * @param req + * @param res + */ +export const getIntegrationAuthQoveryOrgs = async (req: Request, res: Response) => { + const { + params: { integrationAuthId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryOrgsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/organization`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + interface QoveryOrg { + id: string; + name: string; + } + + const orgs = data.results.map((a: QoveryOrg) => { + return { + name: a.name, + orgId: a.id, + }; + }); + + return res.status(200).send({ + orgs + }); +}; + +/** + * Return list of Qovery Projects for a specific orgId + * @param req + * @param res + */ +export const getIntegrationAuthQoveryProjects = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { orgId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryProjectsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + interface Project { + name: string; + projectId: string; + } + + interface QoveryProject { + id: string; + name: string; + } + + let projects: Project[] = []; + + if (orgId && orgId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/organization/${orgId}/project`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + projects = data.results.map((a: QoveryProject) => { + return { + name: a.name, + projectId: a.id, + }; + }); + } + + return res.status(200).send({ + projects + }); +}; + +/** + * Return list of Qovery environments for project with id [projectId] + * @param req + * @param res + */ +export const getIntegrationAuthQoveryEnvironments = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { projectId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryEnvironmentsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + interface Environment { + name: string; + environmentId: string; + } + + interface QoveryEnvironment { + id: string; + name: string; + } + + let environments: Environment[] = []; + + if (projectId && projectId !== "" && projectId !== "none") { // TODO: fix + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/project/${projectId}/environment`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + environments = data.results.map((a: QoveryEnvironment) => { + return { + name: a.name, + environmentId: a.id, + }; + }); + } + + return res.status(200).send({ + environments + }); +}; + +/** + * Return list of Qovery apps for environment with id [environmentId] + * @param req + * @param res + */ +export const getIntegrationAuthQoveryApps = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { environmentId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryScopesV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + interface App { + name: string; + appId: string; + } + + interface QoveryApp { + id: string; + name: string; + } + + let apps: App[] = []; + + if (environmentId && environmentId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/application`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + apps = data.results.map((a: QoveryApp) => { + return { + name: a.name, + appId: a.id, + }; + }); + } + + return res.status(200).send({ + apps + }); +}; + +/** + * Return list of Qovery containers for environment with id [environmentId] + * @param req + * @param res + */ +export const getIntegrationAuthQoveryContainers = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { environmentId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryScopesV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + interface Container { + name: string; + appId: string; + } + + interface QoveryContainer { + id: string; + name: string; + } + + let containers: Container[] = []; + + if (environmentId && environmentId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/container`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + containers = data.results.map((a: QoveryContainer) => { + return { + name: a.name, + appId: a.id, + }; + }); + } + + return res.status(200).send({ + containers + }); +}; + +/** + * Return list of Qovery jobs for environment with id [environmentId] + * @param req + * @param res + */ +export const getIntegrationAuthQoveryJobs = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { environmentId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryScopesV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + interface Job { + name: string; + appId: string; + } + + interface QoveryJob { + id: string; + name: string; + } + + let jobs: Job[] = []; + + if (environmentId && environmentId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/job`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + jobs = data.results.map((a: QoveryJob) => { + return { + name: a.name, + appId: a.id, + }; + }); + } + + return res.status(200).send({ + jobs + }); +}; + /** * Return list of Railway environments for Railway project with * id [appId] @@ -256,7 +708,24 @@ export const getIntegrationAuthVercelBranches = async (req: Request, res: Respon * @param res */ export const getIntegrationAuthRailwayEnvironments = async (req: Request, res: Response) => { - const appId = req.query.appId as string; + const { + params: { integrationAuthId }, + query: { appId } + } = await validateRequest(reqValidator.GetIntegrationAuthRailwayEnvironmentsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); interface RailwayEnvironment { node: { @@ -306,7 +775,7 @@ export const getIntegrationAuthRailwayEnvironments = async (req: Request, res: R }, { headers: { - Authorization: `Bearer ${req.accessToken}`, + Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" } } @@ -332,7 +801,24 @@ export const getIntegrationAuthRailwayEnvironments = async (req: Request, res: R * @param res */ export const getIntegrationAuthRailwayServices = async (req: Request, res: Response) => { - const appId = req.query.appId as string; + const { + params: { integrationAuthId }, + query: { appId } + } = await validateRequest(reqValidator.GetIntegrationAuthRailwayServicesV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); interface RailwayService { node: { @@ -397,7 +883,7 @@ export const getIntegrationAuthRailwayServices = async (req: Request, res: Respo }, { headers: { - Authorization: `Bearer ${req.accessToken}`, + Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" } } @@ -421,7 +907,6 @@ export const getIntegrationAuthRailwayServices = async (req: Request, res: Respo * @returns */ export const getIntegrationAuthBitBucketWorkspaces = async (req: Request, res: Response) => { - interface WorkspaceResponse { size: number; page: number; @@ -441,31 +926,46 @@ export const getIntegrationAuthBitBucketWorkspaces = async (req: Request, res: R updated_on: string; } + const { + params: { integrationAuthId } + } = await validateRequest(reqValidator.GetIntegrationAuthBitbucketWorkspacesV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + const workspaces: Workspace[] = []; let hasNextPage = true; - let workspaceUrl = `${INTEGRATION_BITBUCKET_API_URL}/2.0/workspaces` + let workspaceUrl = `${INTEGRATION_BITBUCKET_API_URL}/2.0/workspaces`; while (hasNextPage) { - const { data }: { data: WorkspaceResponse } = await standardRequest.get( - workspaceUrl, - { - headers: { - Authorization: `Bearer ${req.accessToken}`, - "Accept-Encoding": "application/json" - } + const { data }: { data: WorkspaceResponse } = await standardRequest.get(workspaceUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" } - ); - + }); + if (data?.values.length > 0) { data.values.forEach((workspace) => { - workspaces.push(workspace) - }) + workspaces.push(workspace); + }); } if (data.next) { - workspaceUrl = data.next + workspaceUrl = data.next; } else { - hasNextPage = false + hasNextPage = false; } } @@ -476,13 +976,30 @@ export const getIntegrationAuthBitBucketWorkspaces = async (req: Request, res: R /** * Return list of secret groups for Northflank project with id [appId] - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getIntegrationAuthNorthflankSecretGroups = async (req: Request, res: Response) => { - const appId = req.query.appId as string; - + const { + params: { integrationAuthId }, + query: { appId } + } = await validateRequest(reqValidator.GetIntegrationAuthNorthflankSecretGroupsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + interface NorthflankSecretGroup { id: string; name: string; @@ -490,43 +1007,41 @@ export const getIntegrationAuthNorthflankSecretGroups = async (req: Request, res priority: number; projectId: string; } - + interface SecretGroup { name: string; groupId: string; } - + const secretGroups: SecretGroup[] = []; - if (appId && appId !== "") { + if (appId && appId !== "") { let page = 1; const perPage = 10; let hasMorePages = true; - - while(hasMorePages) { + + while (hasMorePages) { const params = new URLSearchParams({ page: String(page), per_page: String(perPage), - filter: "all", + filter: "all" }); const { data: { - data: { - secrets - } + data: { secrets } } - } = await standardRequest.get<{ data: { secrets: NorthflankSecretGroup[] }}>( + } = await standardRequest.get<{ data: { secrets: NorthflankSecretGroup[] } }>( `${INTEGRATION_NORTHFLANK_API_URL}/v1/projects/${appId}/secrets`, { params, headers: { - Authorization: `Bearer ${req.accessToken}`, - "Accept-Encoding": "application/json", - }, + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } } ); - + secrets.forEach((a: any) => { secretGroups.push({ name: a.name, @@ -541,21 +1056,38 @@ export const getIntegrationAuthNorthflankSecretGroups = async (req: Request, res page++; } } - + return res.status(200).send({ secretGroups }); -} +}; /** * Return list of build configs for TeamCity project with id [appId] - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getIntegrationAuthTeamCityBuildConfigs = async (req: Request, res: Response) => { - const appId = req.query.appId as string; + const { + params: { integrationAuthId }, + query: { appId } + } = await validateRequest(reqValidator.GetIntegrationAuthTeamCityBuildConfigsV1, req); + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + interface TeamCityBuildConfig { id: string; name: string; @@ -564,27 +1096,29 @@ export const getIntegrationAuthTeamCityBuildConfigs = async (req: Request, res: href: string; webUrl: string; } - + interface GetTeamCityBuildConfigsRes { count: number; href: string; buildType: TeamCityBuildConfig[]; } - if (appId && appId !== "") { - const { data: { buildType } } = ( - await standardRequest.get(`${req.integrationAuth.url}/app/rest/buildTypes`, { + const { + data: { buildType } + } = await standardRequest.get( + `${integrationAuth.url}/app/rest/buildTypes`, + { params: { locator: `project:${appId}` }, headers: { - Authorization: `Bearer ${req.accessToken}`, - Accept: "application/json", - }, - }) + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } ); - + return res.status(200).send({ buildConfigs: buildType.map((buildConfig) => ({ name: buildConfig.name, @@ -592,11 +1126,11 @@ export const getIntegrationAuthTeamCityBuildConfigs = async (req: Request, res: })) }); } - + return res.status(200).send({ buildConfigs: [] }); -} +}; /** * Delete integration authorization with id [integrationAuthId] @@ -605,30 +1139,48 @@ export const getIntegrationAuthTeamCityBuildConfigs = async (req: Request, res: * @returns */ export const deleteIntegrationAuth = async (req: Request, res: Response) => { - const integrationAuth = await revokeAccess({ - integrationAuth: req.integrationAuth, - accessToken: req.accessToken + const { + params: { integrationAuthId } + } = await validateRequest(reqValidator.DeleteIntegrationAuthV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) }); - - if (!integrationAuth) return res.status(400).send({ - message: "Failed to find integration authorization" + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Integrations + ); + + const deletedIntegrationAuth = await revokeAccess({ + integrationAuth: integrationAuth, + accessToken: accessToken }); + if (!deletedIntegrationAuth) + return res.status(400).send({ + message: "Failed to find integration authorization" + }); + await EEAuditLogService.createAuditLog( req.authData, { type: EventType.UNAUTHORIZE_INTEGRATION, metadata: { - integration: integrationAuth.integration + integration: deletedIntegrationAuth.integration } }, { - workspaceId: integrationAuth.workspace + workspaceId: deletedIntegrationAuth.workspace } ); return res.status(200).send({ - integrationAuth + integrationAuth: deletedIntegrationAuth }); }; - diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index 84d00f6bb..e709d9d0c 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -1,6 +1,6 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; -import { Folder, Integration } from "../../models"; +import { Folder, IWorkspace, Integration, IntegrationAuth } from "../../models"; import { EventService } from "../../services"; import { eventStartIntegration } from "../../events"; import { getFolderByPath } from "../../services/FolderService"; @@ -8,6 +8,14 @@ import { BadRequestError } from "../../utils/errors"; import { EEAuditLogService } from "../../ee/services"; import { EventType } from "../../ee/models"; import { syncSecretsToActiveIntegrationsQueue } from "../../queues/integrations/syncSecretsToThirdPartyServices"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/integration"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { ForbiddenError } from "@casl/ability"; /** * Create/initialize an (empty) integration for integration authorization @@ -17,24 +25,44 @@ import { syncSecretsToActiveIntegrationsQueue } from "../../queues/integrations/ */ export const createIntegration = async (req: Request, res: Response) => { const { - integrationAuthId, - app, - appId, - isActive, - sourceEnvironment, - targetEnvironment, - targetEnvironmentId, - targetService, - targetServiceId, - owner, - path, - region, - secretPath, - metadata - } = req.body; + body: { + isActive, + sourceEnvironment, + secretPath, + app, + path, + appId, + owner, + region, + scope, + targetService, + targetServiceId, + integrationAuthId, + targetEnvironment, + targetEnvironmentId, + metadata + } + } = await validateRequest(reqValidator.CreateIntegrationV1, req); + + const integrationAuth = await IntegrationAuth.findById(integrationAuthId) + .populate<{ workspace: IWorkspace }>("workspace") + .select( + "+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt" + ); + + if (!integrationAuth) throw BadRequestError({ message: "Integration auth not found" }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace._id.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Integrations + ); const folders = await Folder.findOne({ - workspace: req.integrationAuth.workspace._id, + workspace: integrationAuth.workspace._id, environment: sourceEnvironment }); @@ -42,7 +70,7 @@ export const createIntegration = async (req: Request, res: Response) => { const folder = getFolderByPath(folders.nodes, secretPath); if (!folder) { throw BadRequestError({ - message: "Path for service token does not exist" + message: "Folder path doesn't exist" }); } } @@ -51,7 +79,7 @@ export const createIntegration = async (req: Request, res: Response) => { // initialize new integration after saving integration access token const integration = await new Integration({ - workspace: req.integrationAuth.workspace._id, + workspace: integrationAuth.workspace._id, environment: sourceEnvironment, isActive, app, @@ -63,8 +91,9 @@ export const createIntegration = async (req: Request, res: Response) => { owner, path, region, + scope, secretPath, - integration: req.integrationAuth.integration, + integration: integrationAuth.integration, integrationAuth: new Types.ObjectId(integrationAuthId), metadata }).save(); @@ -120,17 +149,32 @@ export const updateIntegration = async (req: Request, res: Response) => { // integration has the correct fields populated in [Integration] const { - environment, - isActive, - app, - appId, - targetEnvironment, - owner, // github-specific integration param - secretPath - } = req.body; + body: { + environment, + isActive, + app, + appId, + targetEnvironment, + owner, // github-specific integration param + secretPath + }, + params: { integrationId } + } = await validateRequest(reqValidator.UpdateIntegrationV1, req); + + const integration = await Integration.findById(integrationId); + if (!integration) throw BadRequestError({ message: "Integration not found" }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integration.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Integrations + ); const folders = await Folder.findOne({ - workspace: req.integration.workspace, + workspace: integration.workspace, environment }); @@ -143,9 +187,9 @@ export const updateIntegration = async (req: Request, res: Response) => { } } - const integration = await Integration.findOneAndUpdate( + const updatedIntegration = await Integration.findOneAndUpdate( { - _id: req.integration._id + _id: integration._id }, { environment, @@ -161,18 +205,18 @@ export const updateIntegration = async (req: Request, res: Response) => { } ); - if (integration) { + if (updatedIntegration) { // trigger event - push secrets EventService.handleEvent({ event: eventStartIntegration({ - workspaceId: integration.workspace, + workspaceId: updatedIntegration.workspace, environment }) }); } return res.status(200).send({ - integration + integration: updatedIntegration }); }; @@ -183,13 +227,27 @@ export const updateIntegration = async (req: Request, res: Response) => { * @returns */ export const deleteIntegration = async (req: Request, res: Response) => { - const { integrationId } = req.params; + const { + params: { integrationId } + } = await validateRequest(reqValidator.DeleteIntegrationV1, req); - const integration = await Integration.findOneAndDelete({ + const integration = await Integration.findById(integrationId); + if (!integration) throw BadRequestError({ message: "Integration not found" }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integration.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Integrations + ); + + const deletedIntegration = await Integration.findOneAndDelete({ _id: integrationId }); - if (!integration) throw new Error("Failed to find integration"); + if (!deletedIntegration) throw new Error("Failed to find integration"); await EEAuditLogService.createAuditLog( req.authData, @@ -221,14 +279,22 @@ export const deleteIntegration = async (req: Request, res: Response) => { }); }; -// Will trigger sync for all integrations within the given env and workspace id +// Will trigger sync for all integrations within the given env and workspace id export const manualSync = async (req: Request, res: Response) => { - const { workspaceId, environment } = req.body; + const { + body: { workspaceId, environment } + } = await validateRequest(reqValidator.ManualSyncV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Integrations + ); + syncSecretsToActiveIntegrationsQueue({ workspaceId, environment - }) + }); - res.status(200).send() + res.status(200).send(); }; - diff --git a/backend/src/controllers/v1/keyController.ts b/backend/src/controllers/v1/keyController.ts index caf4fbf40..242cb8f82 100644 --- a/backend/src/controllers/v1/keyController.ts +++ b/backend/src/controllers/v1/keyController.ts @@ -4,6 +4,14 @@ import { Key } from "../../models"; import { findMembership } from "../../helpers/membership"; import { EventType } from "../../ee/models"; import { EEAuditLogService } from "../../ee/services"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/key"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { ForbiddenError } from "@casl/ability"; /** * Add (encrypted) copy of workspace key for workspace with id [workspaceId] for user with @@ -13,13 +21,21 @@ import { EEAuditLogService } from "../../ee/services"; * @returns */ export const uploadKey = async (req: Request, res: Response) => { - const { workspaceId } = req.params; - const { key } = req.body; + const { + params: { workspaceId }, + body: { key } + } = await validateRequest(reqValidator.UploadKeyV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); // validate membership of receiver const receiverMembership = await findMembership({ user: key.userId, - workspace: workspaceId, + workspace: workspaceId }); if (!receiverMembership) { @@ -31,12 +47,12 @@ export const uploadKey = async (req: Request, res: Response) => { nonce: key.nonce, sender: req.user._id, receiver: key.userId, - workspace: workspaceId, + workspace: workspaceId }).save(); - return res.status(200).send({ - message: "Successfully uploaded key to workspace", - }); + return res.status(200).send({ + message: "Successfully uploaded key to workspace" + }); }; /** @@ -46,21 +62,23 @@ export const uploadKey = async (req: Request, res: Response) => { * @returns */ export const getLatestKey = async (req: Request, res: Response) => { - const { workspaceId } = req.params; - + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetLatestKeyV1, req); + // get latest key const latestKey = await Key.find({ workspace: workspaceId, - receiver: req.user._id, + receiver: req.user._id }) .sort({ createdAt: -1 }) .limit(1) .populate("sender", "+publicKey"); - const resObj: any = {}; + const resObj: any = {}; - if (latestKey.length > 0) { - resObj["latestKey"] = latestKey[0]; + if (latestKey.length > 0) { + resObj["latestKey"] = latestKey[0]; await EEAuditLogService.createAuditLog( req.authData, { @@ -73,7 +91,7 @@ export const getLatestKey = async (req: Request, res: Response) => { workspaceId: new Types.ObjectId(workspaceId) } ); - } + } - return res.status(200).send(resObj); + return res.status(200).send(resObj); }; diff --git a/backend/src/controllers/v1/membershipController.ts b/backend/src/controllers/v1/membershipController.ts index 885f16690..b022ac5de 100644 --- a/backend/src/controllers/v1/membershipController.ts +++ b/backend/src/controllers/v1/membershipController.ts @@ -1,12 +1,23 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; -import { IUser, Key, Membership, MembershipOrg, User } from "../../models"; +import { IUser, Key, Membership, MembershipOrg, User, Workspace } from "../../models"; import { EventType } from "../../ee/models"; import { deleteMembership as deleteMember, findMembership } from "../../helpers/membership"; import { sendMail } from "../../helpers/nodemailer"; -import { ACCEPTED, ADMIN, MEMBER } from "../../variables"; +import { ACCEPTED, ADMIN, CUSTOM, MEMBER, VIEWER } from "../../variables"; import { getSiteURL } from "../../config"; import { EEAuditLogService } from "../../ee/services"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/membership"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { ForbiddenError } from "@casl/ability"; +import Role from "../../ee/models/role"; +import { BadRequestError } from "../../utils/errors"; +import { InviteUserToWorkspaceV1 } from "../../validation/workspace"; /** * Check that user is a member of workspace with id [workspaceId] @@ -15,7 +26,10 @@ import { EEAuditLogService } from "../../ee/services"; * @returns */ export const validateMembership = async (req: Request, res: Response) => { - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(reqValidator.ValidateMembershipV1, req); + // validate membership const membership = await findMembership({ user: req.user._id, @@ -38,8 +52,10 @@ export const validateMembership = async (req: Request, res: Response) => { * @returns */ export const deleteMembership = async (req: Request, res: Response) => { - const { membershipId } = req.params; - + const { + params: { membershipId } + } = await validateRequest(reqValidator.DeleteMembershipV1, req); + // check if membership to delete exists const membershipToDelete = await Membership.findOne({ _id: membershipId @@ -49,27 +65,20 @@ export const deleteMembership = async (req: Request, res: Response) => { throw new Error("Failed to delete workspace membership that doesn't exist"); } - // check if user is a member and admin of the workspace - // whose membership we wish to delete - const membership = await Membership.findOne({ - user: req.user._id, - workspace: membershipToDelete.workspace - }); - - if (!membership) { - throw new Error("Failed to validate workspace membership"); - } - - if (membership.role !== ADMIN) { - // user is not an admin member of the workspace - throw new Error("Insufficient role for deleting workspace membership"); - } + const { permission } = await getUserProjectPermissions( + req.user._id, + membershipToDelete.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Member + ); // delete workspace membership const deletedMembership = await deleteMember({ membershipId: membershipToDelete._id.toString() }); - + await EEAuditLogService.createAuditLog( req.authData, { @@ -80,7 +89,7 @@ export const deleteMembership = async (req: Request, res: Response) => { } }, { - workspaceId: membership.workspace + workspaceId: membershipToDelete.workspace } ); @@ -96,43 +105,61 @@ export const deleteMembership = async (req: Request, res: Response) => { * @returns */ export const changeMembershipRole = async (req: Request, res: Response) => { - const { membershipId } = req.params; - const { role } = req.body; - - if (![ADMIN, MEMBER].includes(role)) { - throw new Error("Failed to validate role"); - } + const { + body: { role }, + params: { membershipId } + } = await validateRequest(reqValidator.ChangeMembershipRoleV1, req); // validate target membership - const membershipToChangeRole = await Membership - .findById(membershipId) - .populate<{ user: IUser }>("user"); + const membershipToChangeRole = await Membership.findById(membershipId).populate<{ user: IUser }>( + "user" + ); if (!membershipToChangeRole) { throw new Error("Failed to find membership to change role"); } - // check if user is a member and admin of target membership's - // workspace - const membership = await findMembership({ - user: req.user._id, - workspace: membershipToChangeRole.workspace - }); + const { permission } = await getUserProjectPermissions( + req.user._id, + membershipToChangeRole.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); - if (!membership) { - throw new Error("Failed to validate membership"); + const isCustomRole = ![ADMIN, MEMBER, VIEWER].includes(role); + if (isCustomRole) { + const wsRole = await Role.findOne({ + slug: role, + isOrgRole: false, + workspace: membershipToChangeRole.workspace + }); + if (!wsRole) throw BadRequestError({ message: "Role not found" }); + const membership = await Membership.findByIdAndUpdate(membershipId, { + role: CUSTOM, + customRole: wsRole + }); + return res.status(200).send({ + membership + }); } - if (membership.role !== ADMIN) { - // user is not an admin member of the workspace - throw new Error("Insufficient role for changing member roles"); - } - - const oldRole = membershipToChangeRole.role; + const membership = await Membership.findByIdAndUpdate( + membershipId, + { + $set: { + role + }, + $unset: { + customRole: 1 + } + }, + { + new: true + } + ); - membershipToChangeRole.role = role; - await membershipToChangeRole.save(); - await EEAuditLogService.createAuditLog( req.authData, { @@ -140,8 +167,8 @@ export const changeMembershipRole = async (req: Request, res: Response) => { metadata: { userId: membershipToChangeRole.user._id.toString(), email: membershipToChangeRole.user.email, - oldRole, - newRole: membershipToChangeRole.role + oldRole: membershipToChangeRole.role, + newRole: role } }, { @@ -150,7 +177,7 @@ export const changeMembershipRole = async (req: Request, res: Response) => { ); return res.status(200).send({ - membership: membershipToChangeRole + membership }); }; @@ -161,8 +188,15 @@ export const changeMembershipRole = async (req: Request, res: Response) => { * @returns */ export const inviteUserToWorkspace = async (req: Request, res: Response) => { - const { workspaceId } = req.params; - const { email }: { email: string } = req.body; + const { + params: { workspaceId }, + body: { email } + } = await validateRequest(InviteUserToWorkspaceV1, req); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Member + ); const invitee = await User.findOne({ email @@ -179,11 +213,13 @@ export const inviteUserToWorkspace = async (req: Request, res: Response) => { if (inviteeMembership) throw new Error("Failed to add existing member of workspace"); + const workspace = await Workspace.findById(workspaceId); + if (!workspace) throw new Error("Failed to find workspace"); // validate invitee's organization membership - ensure that only // (accepted) organization members can be added to the workspace const membershipOrg = await MembershipOrg.findOne({ user: invitee._id, - organization: req.membership.workspace.organization, + organization: workspace.organization, status: ACCEPTED }); @@ -211,7 +247,7 @@ export const inviteUserToWorkspace = async (req: Request, res: Response) => { substitutions: { inviterFirstName: req.user.firstName, inviterEmail: req.user.email, - workspaceName: req.membership.workspace.name, + workspaceName: workspace.name, callback_url: (await getSiteURL()) + "/login" } }); diff --git a/backend/src/controllers/v1/membershipOrgController.ts b/backend/src/controllers/v1/membershipOrgController.ts index 0c319fe62..57ebb79aa 100644 --- a/backend/src/controllers/v1/membershipOrgController.ts +++ b/backend/src/controllers/v1/membershipOrgController.ts @@ -8,14 +8,8 @@ import { updateSubscriptionOrgQuantity } from "../../helpers/organization"; import { sendMail } from "../../helpers/nodemailer"; import { TokenService } from "../../services"; import { EELicenseService } from "../../ee/services"; -import { - ACCEPTED, - ADMIN, - INVITED, - MEMBER, - OWNER, - TOKEN_EMAIL_ORG_INVITATION -} from "../../variables"; +import { ACCEPTED, INVITED, MEMBER, TOKEN_EMAIL_ORG_INVITATION } from "../../variables"; +import * as reqValidator from "../../validation/membershipOrg"; import { getJwtSignupLifetime, getJwtSignupSecret, @@ -23,6 +17,13 @@ import { getSmtpConfigured } from "../../config"; import { validateUserEmail } from "../../validation"; +import { validateRequest } from "../../helpers/validation"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + getUserOrgPermissions +} from "../../ee/services/RoleService"; +import { ForbiddenError } from "@casl/ability"; /** * Delete organization membership with id [membershipOrgId] from organization @@ -31,7 +32,9 @@ import { validateUserEmail } from "../../validation"; * @returns */ export const deleteMembershipOrg = async (req: Request, _res: Response) => { - const { membershipOrgId } = req.params; + const { + params: { membershipOrgId } + } = await validateRequest(reqValidator.DelOrgMembershipv1, req); // check if organization membership to delete exists const membershipOrgToDelete = await MembershipOrg.findOne({ @@ -42,21 +45,14 @@ export const deleteMembershipOrg = async (req: Request, _res: Response) => { throw new Error("Failed to delete organization membership that doesn't exist"); } - // check if user is a member and admin of the organization - // whose membership we wish to delete - const membershipOrg = await MembershipOrg.findOne({ - user: req.user._id, - organization: membershipOrgToDelete.organization - }); - - if (!membershipOrg) { - throw new Error("Failed to validate organization membership"); - } - - if (membershipOrg.role !== OWNER && membershipOrg.role !== ADMIN) { - // user is not an admin member of the organization - throw new Error("Insufficient role for deleting organization membership"); - } + const { permission, membership: membershipOrg } = await getUserOrgPermissions( + req.user._id, + membershipOrgToDelete.organization.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.Member + ); // delete organization membership await deleteMemberFromOrg({ @@ -96,22 +92,20 @@ export const changeMembershipOrgRole = async (req: Request, res: Response) => { */ export const inviteUserToOrganization = async (req: Request, res: Response) => { let inviteeMembershipOrg, completeInviteLink; - const { organizationId, inviteeEmail } = req.body; + const { + body: { inviteeEmail, organizationId } + } = await validateRequest(reqValidator.InviteUserToOrgv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Member + ); + const host = req.headers.host; const siteUrl = `${req.protocol}://${host}`; - - // validate membership - const membershipOrg = await MembershipOrg.findOne({ - user: req.user._id, - organization: new Types.ObjectId(organizationId) - }); - - if (!membershipOrg) { - throw new Error("Failed to validate organization membership"); - } - const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); - + const ssoConfig = await SSOConfig.findOne({ organization: new Types.ObjectId(organizationId) }); @@ -119,9 +113,8 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { if (ssoConfig && ssoConfig.isActive) { // case: SAML SSO is enabled for the organization return res.status(400).send({ - message: - "Failed to invite member due to SAML SSO configured for organization" - }); + message: "Failed to invite member due to SAML SSO configured for organization" + }); } if (plan.memberLimit !== null) { @@ -231,7 +224,10 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { */ export const verifyUserToOrganization = async (req: Request, res: Response) => { let user; - const { email, organizationId, code } = req.body; + + const { + body: { organizationId, email, code } + } = await validateRequest(reqValidator.VerifyUserToOrgv1, req); user = await User.findOne({ email }).select("+publicKey"); diff --git a/backend/src/controllers/v1/organizationController.ts b/backend/src/controllers/v1/organizationController.ts index f738891a2..757d1aed7 100644 --- a/backend/src/controllers/v1/organizationController.ts +++ b/backend/src/controllers/v1/organizationController.ts @@ -1,28 +1,37 @@ import { Request, Response } from "express"; import { - IncidentContactOrg, - Membership, - MembershipOrg, - Organization, - Workspace, + IncidentContactOrg, + Membership, + MembershipOrg, + Organization, + Workspace } from "../../models"; import { createOrganization as create } from "../../helpers/organization"; import { addMembershipsOrg } from "../../helpers/membershipOrg"; -import { ACCEPTED, OWNER } from "../../variables"; +import { ACCEPTED, ADMIN } from "../../variables"; import { getLicenseServerUrl, getSiteURL } from "../../config"; import { licenseServerKeyRequest } from "../../config/request"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/organization"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + getUserOrgPermissions +} from "../../ee/services/RoleService"; +import { OrganizationNotFoundError } from "../../utils/errors"; +import { ForbiddenError } from "@casl/ability"; export const getOrganizations = async (req: Request, res: Response) => { const organizations = ( await MembershipOrg.find({ user: req.user._id, - status: ACCEPTED, + status: ACCEPTED }).populate("organization") ).map((m) => m.organization); - return res.status(200).send({ - organizations, - }); + return res.status(200).send({ + organizations + }); }; /** @@ -33,28 +42,26 @@ export const getOrganizations = async (req: Request, res: Response) => { * @returns */ export const createOrganization = async (req: Request, res: Response) => { - const { organizationName } = req.body; - - if (organizationName.length < 1) { - throw new Error("Organization names must be at least 1-character long"); - } + const { + body: { organizationName } + } = await validateRequest(reqValidator.CreateOrgv1, req); // create organization and add user as member const organization = await create({ email: req.user.email, - name: organizationName, + name: organizationName }); await addMembershipsOrg({ userIds: [req.user._id.toString()], organizationId: organization._id.toString(), - roles: [OWNER], - statuses: [ACCEPTED], + roles: [ADMIN], + statuses: [ACCEPTED] }); - return res.status(200).send({ - organization, - }); + return res.status(200).send({ + organization + }); }; /** @@ -64,10 +71,23 @@ export const createOrganization = async (req: Request, res: Response) => { * @returns */ export const getOrganization = async (req: Request, res: Response) => { - const organization = req.organization - return res.status(200).send({ - organization, - }); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgv1, req); + + // ensure user has membership + await getUserOrgPermissions(req.user._id, organizationId); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + return res.status(200).send({ + organization + }); }; /** @@ -77,15 +97,23 @@ export const getOrganization = async (req: Request, res: Response) => { * @returns */ export const getOrganizationMembers = async (req: Request, res: Response) => { - const { organizationId } = req.params; + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgMembersv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Member + ); const users = await MembershipOrg.find({ - organization: organizationId, + organization: organizationId }).populate("user", "+publicKey"); - return res.status(200).send({ - users, - }); + return res.status(200).send({ + users + }); }; /** @@ -94,17 +122,22 @@ export const getOrganizationMembers = async (req: Request, res: Response) => { * @param res * @returns */ -export const getOrganizationWorkspaces = async ( - req: Request, - res: Response -) => { - const { organizationId } = req.params; +export const getOrganizationWorkspaces = async (req: Request, res: Response) => { + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgWorkspacesv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Workspace + ); const workspacesSet = new Set( ( await Workspace.find( { - organization: organizationId, + organization: organizationId }, "_id" ) @@ -113,15 +146,15 @@ export const getOrganizationWorkspaces = async ( const workspaces = ( await Membership.find({ - user: req.user._id, + user: req.user._id }).populate("workspace") ) .filter((m) => workspacesSet.has(m.workspace._id.toString())) .map((m) => m.workspace); - return res.status(200).send({ - workspaces, - }); + return res.status(200).send({ + workspaces + }); }; /** @@ -131,25 +164,33 @@ export const getOrganizationWorkspaces = async ( * @returns */ export const changeOrganizationName = async (req: Request, res: Response) => { - const { organizationId } = req.params; - const { name } = req.body; + const { + params: { organizationId }, + body: { name } + } = await validateRequest(reqValidator.ChangeOrgNamev1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Settings + ); const organization = await Organization.findOneAndUpdate( { - _id: organizationId, + _id: organizationId }, { - name, + name }, { - new: true, + new: true } ); - return res.status(200).send({ - message: "Successfully changed organization name", - organization, - }); + return res.status(200).send({ + message: "Successfully changed organization name", + organization + }); }; /** @@ -158,19 +199,24 @@ export const changeOrganizationName = async (req: Request, res: Response) => { * @param res * @returns */ -export const getOrganizationIncidentContacts = async ( - req: Request, - res: Response -) => { - const { organizationId } = req.params; +export const getOrganizationIncidentContacts = async (req: Request, res: Response) => { + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgIncidentContactv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.IncidentAccount + ); const incidentContactsOrg = await IncidentContactOrg.find({ - organization: organizationId, + organization: organizationId }); - return res.status(200).send({ - incidentContactsOrg, - }); + return res.status(200).send({ + incidentContactsOrg + }); }; /** @@ -179,12 +225,17 @@ export const getOrganizationIncidentContacts = async ( * @param res * @returns */ -export const addOrganizationIncidentContact = async ( - req: Request, - res: Response -) => { - const { organizationId } = req.params; - const { email } = req.body; +export const addOrganizationIncidentContact = async (req: Request, res: Response) => { + const { + params: { organizationId }, + body: { email } + } = await validateRequest(reqValidator.CreateOrgIncideContact, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.IncidentAccount + ); const incidentContactOrg = await IncidentContactOrg.findOneAndUpdate( { email, organization: organizationId }, @@ -192,9 +243,9 @@ export const addOrganizationIncidentContact = async ( { upsert: true, new: true } ); - return res.status(200).send({ - incidentContactOrg, - }); + return res.status(200).send({ + incidentContactOrg + }); }; /** @@ -203,22 +254,27 @@ export const addOrganizationIncidentContact = async ( * @param res * @returns */ -export const deleteOrganizationIncidentContact = async ( - req: Request, - res: Response -) => { - const { organizationId } = req.params; - const { email } = req.body; +export const deleteOrganizationIncidentContact = async (req: Request, res: Response) => { + const { + params: { organizationId }, + body: { email } + } = await validateRequest(reqValidator.DelOrgIncideContact, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.IncidentAccount + ); const incidentContactOrg = await IncidentContactOrg.findOneAndDelete({ email, - organization: organizationId, + organization: organizationId }); - return res.status(200).send({ - message: "Successfully deleted organization incident contact", - incidentContactOrg, - }); + return res.status(200).send({ + message: "Successfully deleted organization incident contact", + incidentContactOrg + }); }; /** @@ -228,19 +284,41 @@ export const deleteOrganizationIncidentContact = async ( * @param res * @returns */ -export const createOrganizationPortalSession = async ( - req: Request, - res: Response -) => { - const { data: { pmtMethods } } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods`, +export const createOrganizationPortalSession = async (req: Request, res: Response) => { + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgPlanBillingInfov1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Billing ); - + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { + data: { pmtMethods } + } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/payment-methods` + ); + if (pmtMethods.length < 1) { // case: organization has no payment method on file - // -> redirect to add payment method portal - const { data: { url } } = await licenseServerKeyRequest.post( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods`, + // -> redirect to add payment method portal + const { + data: { url } + } = await licenseServerKeyRequest.post( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/payment-methods`, { success_url: (await getSiteURL()) + "/dashboard", cancel_url: (await getSiteURL()) + "/dashboard" @@ -250,8 +328,12 @@ export const createOrganizationPortalSession = async ( } else { // case: organization has payment method on file // -> redirect to billing portal - const { data: { url } } = await licenseServerKeyRequest.post( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/billing-portal`, + const { + data: { url } + } = await licenseServerKeyRequest.post( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/billing-portal`, { return_url: (await getSiteURL()) + "/dashboard" } @@ -266,36 +348,43 @@ export const createOrganizationPortalSession = async ( * @param res * @returns */ -export const getOrganizationMembersAndTheirWorkspaces = async ( - req: Request, - res: Response -) => { - const { organizationId } = req.params; +export const getOrganizationMembersAndTheirWorkspaces = async (req: Request, res: Response) => { + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgMembersv1, req); - const workspacesSet = ( - await Workspace.find( - { - organization: organizationId, - }, - "_id" - ) - ).map((w) => w._id.toString()); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Member + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Workspace + ); - const memberships = ( - await Membership.find({ - workspace: { $in: workspacesSet }, - }).populate("workspace") - ); - const userToWorkspaceIds: any = {}; + const workspacesSet = ( + await Workspace.find( + { + organization: organizationId + }, + "_id" + ) + ).map((w) => w._id.toString()); - memberships.forEach(membership => { - const user = membership.user.toString(); - if (userToWorkspaceIds[user]) { - userToWorkspaceIds[user].push(membership.workspace); - } else { - userToWorkspaceIds[user] = [membership.workspace]; - } - }); + const memberships = await Membership.find({ + workspace: { $in: workspacesSet } + }).populate("workspace"); + const userToWorkspaceIds: any = {}; - return res.json(userToWorkspaceIds); + memberships.forEach((membership) => { + const user = membership.user.toString(); + if (userToWorkspaceIds[user]) { + userToWorkspaceIds[user].push(membership.workspace); + } else { + userToWorkspaceIds[user] = [membership.workspace]; + } + }); + + return res.json(userToWorkspaceIds); }; diff --git a/backend/src/controllers/v1/passwordController.ts b/backend/src/controllers/v1/passwordController.ts index 22b90a61a..65447bcf8 100644 --- a/backend/src/controllers/v1/passwordController.ts +++ b/backend/src/controllers/v1/passwordController.ts @@ -14,6 +14,8 @@ import { getSiteURL } from "../../config"; import { ActorType } from "../../ee/models"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/auth"; /** * Password reset step 1: Send email verification link to email [email] @@ -23,7 +25,9 @@ import { ActorType } from "../../ee/models"; * @returns */ export const emailPasswordReset = async (req: Request, res: Response) => { - const email: string = req.body.email; + const { + body: { email } + } = await validateRequest(reqValidator.EmailPasswordResetV1, req); const user = await User.findOne({ email }).select("+publicKey"); if (!user || !user?.publicKey) { @@ -62,7 +66,9 @@ export const emailPasswordReset = async (req: Request, res: Response) => { * @returns */ export const emailPasswordResetVerify = async (req: Request, res: Response) => { - const { email, code } = req.body; + const { + body: { email, code } + } = await validateRequest(reqValidator.EmailPasswordResetVerifyV1, req); const user = await User.findOne({ email }).select("+publicKey"); if (!user || !user?.publicKey) { @@ -103,8 +109,10 @@ export const emailPasswordResetVerify = async (req: Request, res: Response) => { */ export const srp1 = async (req: Request, res: Response) => { // return salt, serverPublicKey as part of first step of SRP protocol + const { + body: { clientPublicKey } + } = await validateRequest(reqValidator.Srp1V1, req); - const { clientPublicKey } = req.body; const user = await User.findOne({ email: req.user.email }).select("+salt +verifier"); @@ -149,16 +157,18 @@ export const srp1 = async (req: Request, res: Response) => { */ export const changePassword = async (req: Request, res: Response) => { const { - clientProof, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt, - verifier - } = req.body; + body: { + clientProof, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + } + } = await validateRequest(reqValidator.ChangePasswordV1, req); const user = await User.findOne({ email: req.user.email @@ -208,10 +218,7 @@ export const changePassword = async (req: Request, res: Response) => { } ); - if ( - req.authData.actor.type === ActorType.USER && - req.authData.tokenVersionId - ) { + if (req.authData.actor.type === ActorType.USER && req.authData.tokenVersionId) { await clearTokens(req.authData.tokenVersionId); } @@ -246,8 +253,9 @@ export const createBackupPrivateKey = async (req: Request, res: Response) => { // create/change backup private key // requires verifying [clientProof] as part of second step of SRP protocol // as initiated in /srp1 - - const { clientProof, encryptedPrivateKey, iv, tag, salt, verifier } = req.body; + const { + body: { clientProof, encryptedPrivateKey, salt, verifier, iv, tag } + } = await validateRequest(reqValidator.CreateBackupPrivateKeyV1, req); const user = await User.findOne({ email: req.user.email }).select("+salt +verifier"); @@ -325,15 +333,17 @@ export const getBackupPrivateKey = async (req: Request, res: Response) => { export const resetPassword = async (req: Request, res: Response) => { const { - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt, - verifier - } = req.body; + body: { + encryptedPrivateKey, + protectedKeyTag, + protectedKey, + protectedKeyIV, + salt, + verifier, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag + } + } = await validateRequest(reqValidator.ResetPasswordV1, req); await User.findByIdAndUpdate( req.user._id.toString(), diff --git a/backend/src/controllers/v1/secretImportController.ts b/backend/src/controllers/v1/secretImportController.ts deleted file mode 100644 index 23be98096..000000000 --- a/backend/src/controllers/v1/secretImportController.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { Request, Response } from "express"; -import { isValidScope, validateMembership } from "../../helpers"; -import { Folder, SecretImport, ServiceTokenData } from "../../models"; -import { getAllImportedSecrets } from "../../services/SecretImportService"; -import { getFolderWithPathFromId } from "../../services/FolderService"; -import { BadRequestError, ResourceNotFoundError,UnauthorizedRequestError } from "../../utils/errors"; -import { ADMIN, MEMBER } from "../../variables"; -import { EEAuditLogService } from "../../ee/services"; -import { EventType } from "../../ee/models"; - -export const createSecretImport = async (req: Request, res: Response) => { - const { workspaceId, environment, folderId, secretImport } = req.body; - - const folders = await Folder.findOne({ - workspace: workspaceId, - environment - }).lean(); - - if (!folders && folderId !== "root") { - throw ResourceNotFoundError({ - message: "Failed to find folder" - }); - } - - let secretPath = "/"; - if (folders) { - const { folderPath } = getFolderWithPathFromId(folders.nodes, folderId); - secretPath = folderPath; - } - if (req.authData.authPayload instanceof ServiceTokenData) { - // root check - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - - const importSecDoc = await SecretImport.findOne({ - workspace: workspaceId, - environment, - folderId - }); - - const importToSecretPath = folders?getFolderWithPathFromId(folders.nodes, folderId).folderPath:"/"; - - if (!importSecDoc) { - const doc = new SecretImport({ - workspace: workspaceId, - environment, - folderId, - imports: [{ environment: secretImport.environment, secretPath: secretImport.secretPath }] - }); - - await doc.save(); - await EEAuditLogService.createAuditLog( - req.authData, - { - type: EventType.CREATE_SECRET_IMPORT, - metadata: { - secretImportId: doc._id.toString(), - folderId: doc.folderId.toString(), - importFromEnvironment: secretImport.environment, - importFromSecretPath: secretImport.secretPath, - importToEnvironment: environment, - importToSecretPath - } - }, - { - workspaceId: doc.workspace - } - ); - return res.status(200).json({ message: "successfully created secret import" }); - } - - const doesImportExist = importSecDoc.imports.find( - (el) => el.environment === secretImport.environment && el.secretPath === secretImport.secretPath - ); - if (doesImportExist) { - throw BadRequestError({ message: "Secret import already exist" }); - } - - importSecDoc.imports.push({ - environment: secretImport.environment, - secretPath: secretImport.secretPath - }); - await importSecDoc.save(); - - await EEAuditLogService.createAuditLog( - req.authData, - { - type: EventType.CREATE_SECRET_IMPORT, - metadata: { - secretImportId: importSecDoc._id.toString(), - folderId: importSecDoc.folderId.toString(), - importFromEnvironment: secretImport.environment, - importFromSecretPath: secretImport.secretPath, - importToEnvironment: environment, - importToSecretPath - } - }, - { - workspaceId: importSecDoc.workspace - } - ); - return res.status(200).json({ message: "successfully created secret import" }); -}; - -// to keep the ordering, you must pass all the imports in here not the only updated one -// this is because the order decide which import gets overriden -export const updateSecretImport = async (req: Request, res: Response) => { - const { id } = req.params; - const { secretImports } = req.body; - const importSecDoc = await SecretImport.findById(id); - if (!importSecDoc) { - throw BadRequestError({ message: "Import not found" }); - } - - if (!(req.authData.authPayload instanceof ServiceTokenData)) { - await validateMembership({ - userId: req.user._id.toString(), - workspaceId: importSecDoc.workspace, - acceptedRoles: [ADMIN, MEMBER] - }); - } else { - // check for service token validity - const folders = await Folder.findOne({ - workspace: importSecDoc.workspace, - environment: importSecDoc.environment - }).lean(); - - let secretPath = "/"; - if (folders) { - const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); - secretPath = folderPath; - } - - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - importSecDoc.environment, - secretPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - - const orderBefore = importSecDoc.imports; - importSecDoc.imports = secretImports; - - await importSecDoc.save(); - - const folders = await Folder.findOne({ - workspace: importSecDoc.workspace, - environment: importSecDoc.environment, - }).lean(); - - if (!folders) throw ResourceNotFoundError({ - message: "Failed to find folder" - }); - - const importToSecretPath = folders?getFolderWithPathFromId(folders.nodes, importSecDoc.folderId).folderPath:"/"; - - await EEAuditLogService.createAuditLog( - req.authData, - { - type: EventType.UPDATE_SECRET_IMPORT, - metadata: { - importToEnvironment: importSecDoc.environment, - importToSecretPath, - secretImportId: importSecDoc._id.toString(), - folderId: importSecDoc.folderId.toString(), - orderBefore, - orderAfter: secretImports - } - }, - { - workspaceId: importSecDoc.workspace - } - ); - return res.status(200).json({ message: "successfully updated secret import" }); -}; - -export const deleteSecretImport = async (req: Request, res: Response) => { - const { id } = req.params; - const { secretImportEnv, secretImportPath } = req.body; - const importSecDoc = await SecretImport.findById(id); - if (!importSecDoc) { - throw BadRequestError({ message: "Import not found" }); - } - - if (!(req.authData.authPayload instanceof ServiceTokenData)) { - await validateMembership({ - userId: req.user._id.toString(), - workspaceId: importSecDoc.workspace, - acceptedRoles: [ADMIN, MEMBER] - }); - } else { - // check for service token validity - const folders = await Folder.findOne({ - workspace: importSecDoc.workspace, - environment: importSecDoc.environment - }).lean(); - - let secretPath = "/"; - if (folders) { - const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); - secretPath = folderPath; - } - - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - importSecDoc.environment, - secretPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - importSecDoc.imports = importSecDoc.imports.filter( - ({ environment, secretPath }) => - !(environment === secretImportEnv && secretPath === secretImportPath) - ); - await importSecDoc.save(); - - const folders = await Folder.findOne({ - workspace: importSecDoc.workspace, - environment: importSecDoc.environment, - }).lean(); - - if (!folders) throw ResourceNotFoundError({ - message: "Failed to find folder" - }); - - const importToSecretPath = folders?getFolderWithPathFromId(folders.nodes, importSecDoc.folderId).folderPath:"/"; - - await EEAuditLogService.createAuditLog( - req.authData, - { - type: EventType.DELETE_SECRET_IMPORT, - metadata: { - secretImportId: importSecDoc._id.toString(), - folderId: importSecDoc.folderId.toString(), - importFromEnvironment: secretImportEnv, - importFromSecretPath: secretImportPath, - importToEnvironment: importSecDoc.environment, - importToSecretPath - } - }, - { - workspaceId: importSecDoc.workspace - } - ); - - return res.status(200).json({ message: "successfully delete secret import" }); -}; - -export const getSecretImports = async (req: Request, res: Response) => { - const { workspaceId, environment, folderId } = req.query; - const importSecDoc = await SecretImport.findOne({ - workspace: workspaceId, - environment, - folderId - }); - - if (!importSecDoc) { - return res.status(200).json({ secretImport: {} }); - } - - if (req.authData.authPayload instanceof ServiceTokenData) { - // check for service token validity - const folders = await Folder.findOne({ - workspace: importSecDoc.workspace, - environment: importSecDoc.environment - }).lean(); - - let secretPath = "/"; - if (folders) { - const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); - secretPath = folderPath; - } - - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - importSecDoc.environment, - secretPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - - return res.status(200).json({ secretImport: importSecDoc }); -}; - -export const getAllSecretsFromImport = async (req: Request, res: Response) => { - const { workspaceId, environment, folderId } = req.query as { - workspaceId: string; - environment: string; - folderId: string; - }; - const importSecDoc = await SecretImport.findOne({ - workspace: workspaceId, - environment, - folderId - }); - - if (!importSecDoc) { - return res.status(200).json({ secrets: [] }); - } - - if (req.authData.authPayload instanceof ServiceTokenData) { - // check for service token validity - const folders = await Folder.findOne({ - workspace: importSecDoc.workspace, - environment: importSecDoc.environment - }).lean(); - - let secretPath = "/"; - if (folders) { - const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); - secretPath = folderPath; - } - - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - importSecDoc.environment, - secretPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - - await EEAuditLogService.createAuditLog( - req.authData, - { - type: EventType.GET_SECRET_IMPORTS, - metadata: { - environment, - secretImportId: importSecDoc._id.toString(), - folderId, - numberOfImports: importSecDoc.imports.length - } - }, - { - workspaceId: importSecDoc.workspace - } - ); - - const secrets = await getAllImportedSecrets(workspaceId, environment, folderId); - return res.status(200).json({ secrets }); -}; diff --git a/backend/src/controllers/v1/secretImpsController.ts b/backend/src/controllers/v1/secretImpsController.ts new file mode 100644 index 000000000..a16d666c4 --- /dev/null +++ b/backend/src/controllers/v1/secretImpsController.ts @@ -0,0 +1,706 @@ +import { Request, Response } from "express"; +import { isValidScope } from "../../helpers"; +import { Folder, IServiceTokenData, SecretImport, ServiceTokenData } from "../../models"; +import { getAllImportedSecrets } from "../../services/SecretImportService"; +import { getFolderWithPathFromId } from "../../services/FolderService"; +import { + BadRequestError, + ResourceNotFoundError, + UnauthorizedRequestError +} from "../../utils/errors"; +import { EEAuditLogService } from "../../ee/services"; +import { EventType } from "../../ee/models"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/secretImports"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { ForbiddenError, subject } from "@casl/ability"; + +export const createSecretImp = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Create secret import' + #swagger.description = 'Create a new secret import for a specified workspace and environment' + + #swagger.requestBody = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "description": "ID of the workspace where the secret import will be created", + "example": "someWorkspaceId" + }, + "environment": { + "type": "string", + "description": "Environment to import to", + "example": "production" + }, + "folderId": { + "type": "string", + "description": "Folder ID. Use root for the root folder.", + "example": "my_folder" + }, + "secretImport": { + "type": "object", + "properties": { + "environment": { + "type": "string", + "description": "Import from environment", + "example": "development" + }, + "secretPath": { + "type": "string", + "description": "Import from secret path", + "example": "/user/oauth" + } + } + } + }, + "required": ["workspaceId", "environment", "folderName"] + } + } + } + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "successfully created secret import" + } + }, + "description": "Confirmation of secret import creation" + } + } + } + } + #swagger.responses[400] = { + description: "Bad Request. For example, 'Secret import already exist'" + } + #swagger.responses[401] = { + description: "Unauthorized request. For example, 'Folder Permission Denied'" + } + #swagger.responses[404] = { + description: "Resource Not Found. For example, 'Failed to find folder'" + } + */ + + const { + body: { workspaceId, environment, folderId, secretImport } + } = await validateRequest(reqValidator.CreateSecretImportV1, req); + + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }).lean(); + + if (!folders && folderId !== "root") { + throw ResourceNotFoundError({ + message: "Failed to find folder" + }); + } + + let secretPath = "/"; + if (folders) { + const { folderPath } = getFolderWithPathFromId(folders.nodes, folderId); + secretPath = folderPath; + } + + if (req.authData.authPayload instanceof ServiceTokenData) { + // root check + let isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + isValidScopeAccess = isValidScope( + req.authData.authPayload, + secretImport.environment, + secretImport.secretPath + ); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: secretImport.environment, + secretPath: secretImport.secretPath + }) + ); + } + + const importSecDoc = await SecretImport.findOne({ + workspace: workspaceId, + environment, + folderId + }); + + const importToSecretPath = folders + ? getFolderWithPathFromId(folders.nodes, folderId).folderPath + : "/"; + + if (!importSecDoc) { + const doc = new SecretImport({ + workspace: workspaceId, + environment, + folderId, + imports: [{ environment: secretImport.environment, secretPath: secretImport.secretPath }] + }); + + await doc.save(); + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.CREATE_SECRET_IMPORT, + metadata: { + secretImportId: doc._id.toString(), + folderId: doc.folderId.toString(), + importFromEnvironment: secretImport.environment, + importFromSecretPath: secretImport.secretPath, + importToEnvironment: environment, + importToSecretPath + } + }, + { + workspaceId: doc.workspace + } + ); + return res.status(200).json({ message: "successfully created secret import" }); + } + + const doesImportExist = importSecDoc.imports.find( + (el) => el.environment === secretImport.environment && el.secretPath === secretImport.secretPath + ); + if (doesImportExist) { + throw BadRequestError({ message: "Secret import already exist" }); + } + + importSecDoc.imports.push({ + environment: secretImport.environment, + secretPath: secretImport.secretPath + }); + await importSecDoc.save(); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.CREATE_SECRET_IMPORT, + metadata: { + secretImportId: importSecDoc._id.toString(), + folderId: importSecDoc.folderId.toString(), + importFromEnvironment: secretImport.environment, + importFromSecretPath: secretImport.secretPath, + importToEnvironment: environment, + importToSecretPath + } + }, + { + workspaceId: importSecDoc.workspace + } + ); + return res.status(200).json({ message: "successfully created secret import" }); +}; + +// to keep the ordering, you must pass all the imports in here not the only updated one +// this is because the order decide which import gets overriden + +/** + * Update secret import + * @param req + * @param res + * @returns + */ +export const updateSecretImport = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Update a secret import' + #swagger.description = 'Updates an existing secret import based on the provided ID and new import details' + + #swagger.parameters['id'] = { + in: 'path', + description: 'ID of the secret import to be updated', + required: true, + type: 'string', + example: 'import12345' + } + + #swagger.requestBody = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secretImports": { + "type": "array", + "description": "List of new secret imports", + "items": { + "type": "object", + "properties": { + "environment": { + "type": "string", + "description": "Environment of the secret import", + "example": "production" + }, + "secretPath": { + "type": "string", + "description": "Path of the secret import", + "example": "/path/to/secret" + } + }, + "required": ["environment", "secretPath"] + } + } + }, + "required": ["secretImports"] + } + } + } + } + + #swagger.responses[200] = { + description: 'Successfully updated the secret import', + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "successfully updated secret import" + } + } + } + } + } + } + + #swagger.responses[400] = { + description: 'Bad Request - Import not found', + } + + #swagger.responses[403] = { + description: 'Forbidden access due to insufficient permissions', + } + + #swagger.responses[401] = { + description: 'Unauthorized access due to invalid token or scope', + } + */ + const { + body: { secretImports }, + params: { id } + } = await validateRequest(reqValidator.UpdateSecretImportV1, req); + + const importSecDoc = await SecretImport.findById(id); + if (!importSecDoc) { + throw BadRequestError({ message: "Import not found" }); + } + + // check for service token validity + const folders = await Folder.findOne({ + workspace: importSecDoc.workspace, + environment: importSecDoc.environment + }).lean(); + + let secretPath = "/"; + if (folders) { + const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); + secretPath = folderPath; + } + + if (req.authData.authPayload instanceof ServiceTokenData) { + // token permission check + const isValidScopeAccess = isValidScope( + req.authData.authPayload, + importSecDoc.environment, + secretPath + ); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + // non token entry check + const { permission } = await getUserProjectPermissions( + req.user._id, + importSecDoc.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment: importSecDoc.environment, + secretPath + }) + ); + } + + const orderBefore = importSecDoc.imports; + importSecDoc.imports = secretImports; + + await importSecDoc.save(); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.UPDATE_SECRET_IMPORT, + metadata: { + importToEnvironment: importSecDoc.environment, + importToSecretPath: secretPath, + secretImportId: importSecDoc._id.toString(), + folderId: importSecDoc.folderId.toString(), + orderBefore, + orderAfter: secretImports + } + }, + { + workspaceId: importSecDoc.workspace + } + ); + return res.status(200).json({ message: "successfully updated secret import" }); +}; + +/** + * Delete secret import + * @param req + * @param res + * @returns + */ +export const deleteSecretImport = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Delete secret import' + #swagger.description = 'Delete secret import' + + #swagger.parameters['id'] = { + in: 'path', + description: 'ID of the secret import', + required: true, + type: 'string', + example: '12345abcde' + } + + #swagger.requestBody = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secretImportEnv": { + "type": "string", + "description": "Import from environment", + "example": "someWorkspaceId" + }, + "secretImportPath": { + "type": "string", + "description": "Import from secret path", + "example": "production" + } + }, + "required": ["id", "secretImportEnv", "secretImportPath"] + } + } + } + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "successfully delete secret import" + } + }, + "description": "Confirmation of secret import deletion" + } + } + } + } + */ + const { + params: { id }, + body: { secretImportEnv, secretImportPath } + } = await validateRequest(reqValidator.DeleteSecretImportV1, req); + + const importSecDoc = await SecretImport.findById(id); + if (!importSecDoc) { + throw BadRequestError({ message: "Import not found" }); + } + + // check for service token validity + const folders = await Folder.findOne({ + workspace: importSecDoc.workspace, + environment: importSecDoc.environment + }).lean(); + + let secretPath = "/"; + if (folders) { + const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); + secretPath = folderPath; + } + + if (req.authData.authPayload instanceof ServiceTokenData) { + const isValidScopeAccess = isValidScope( + req.authData.authPayload, + importSecDoc.environment, + secretPath + ); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + const { permission } = await getUserProjectPermissions( + req.user._id, + importSecDoc.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { + environment: importSecDoc.environment, + secretPath + }) + ); + } + importSecDoc.imports = importSecDoc.imports.filter( + ({ environment, secretPath }) => + !(environment === secretImportEnv && secretPath === secretImportPath) + ); + await importSecDoc.save(); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.DELETE_SECRET_IMPORT, + metadata: { + secretImportId: importSecDoc._id.toString(), + folderId: importSecDoc.folderId.toString(), + importFromEnvironment: secretImportEnv, + importFromSecretPath: secretImportPath, + importToEnvironment: importSecDoc.environment, + importToSecretPath: secretPath + } + }, + { + workspaceId: importSecDoc.workspace + } + ); + + return res.status(200).json({ message: "successfully delete secret import" }); +}; + +/** + * Get secret imports + * @param req + * @param res + * @returns + */ +export const getSecretImports = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Retrieve secret imports' + #swagger.description = 'Fetches the secret imports based on the workspaceId, environment, and folderId' + + #swagger.parameters['workspaceId'] = { + in: 'query', + description: 'ID of the workspace of secret imports to get', + required: true, + type: 'string', + example: 'workspace12345' + } + + #swagger.parameters['environment'] = { + in: 'query', + description: 'Environment of secret imports to get', + required: true, + type: 'string', + example: 'production' + } + + #swagger.parameters['folderId'] = { + in: 'query', + description: 'ID of the folder containing the secret imports. Default: root', + required: false, + type: 'string', + example: 'folder12345' + } + + #swagger.responses[200] = { + description: 'Successfully retrieved secret import', + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secretImport": { + "type": "object", + "description": "Details of a secret import" + } + } + } + } + } + } + + #swagger.responses[403] = { + description: 'Forbidden access due to insufficient permissions', + } + + #swagger.responses[401] = { + description: 'Unauthorized access due to invalid token or scope', + } + */ + const { + query: { workspaceId, environment, folderId } + } = await validateRequest(reqValidator.GetSecretImportsV1, req); + const importSecDoc = await SecretImport.findOne({ + workspace: workspaceId, + environment, + folderId + }); + + if (!importSecDoc) { + return res.status(200).json({ secretImport: {} }); + } + + // check for service token validity + const folders = await Folder.findOne({ + workspace: importSecDoc.workspace, + environment: importSecDoc.environment + }).lean(); + + let secretPath = "/"; + if (folders) { + const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); + secretPath = folderPath; + } + + if (req.authData.authPayload instanceof ServiceTokenData) { + const isValidScopeAccess = isValidScope( + req.authData.authPayload, + importSecDoc.environment, + secretPath + ); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + const { permission } = await getUserProjectPermissions( + req.user._id, + importSecDoc.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: importSecDoc.environment, + secretPath + }) + ); + } + + return res.status(200).json({ secretImport: importSecDoc }); +}; + +/** + * Get all secret imports + * @param req + * @param res + * @returns + */ +export const getAllSecretsFromImport = async (req: Request, res: Response) => { + const { + query: { workspaceId, environment, folderId } + } = await validateRequest(reqValidator.GetAllSecretsFromImportV1, req); + + const importSecDoc = await SecretImport.findOne({ + workspace: workspaceId, + environment, + folderId + }); + + if (!importSecDoc) { + return res.status(200).json({ secrets: [] }); + } + + const folders = await Folder.findOne({ + workspace: importSecDoc.workspace, + environment: importSecDoc.environment + }).lean(); + + let secretPath = "/"; + if (folders) { + const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); + secretPath = folderPath; + } + + let permissionCheckFn: (env: string, secPath: string) => boolean; // used to pass as callback function to import secret + if (req.authData.authPayload instanceof ServiceTokenData) { + // check for service token validity + const isValidScopeAccess = isValidScope( + req.authData.authPayload, + importSecDoc.environment, + secretPath + ); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + permissionCheckFn = (env: string, secPath: string) => + isValidScope(req.authData.authPayload as IServiceTokenData, env, secPath); + } else { + const { permission } = await getUserProjectPermissions( + req.user._id, + importSecDoc.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: importSecDoc.environment, + secretPath + }) + ); + permissionCheckFn = (env: string, secPath: string) => + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: env, + secretPath: secPath + }) + ); + } + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.GET_SECRET_IMPORTS, + metadata: { + environment, + secretImportId: importSecDoc._id.toString(), + folderId, + numberOfImports: importSecDoc.imports.length + } + }, + { + workspaceId: importSecDoc.workspace + } + ); + + const secrets = await getAllImportedSecrets( + workspaceId, + environment, + folderId, + permissionCheckFn + ); + return res.status(200).json({ secrets }); +}; diff --git a/backend/src/controllers/v1/secretScanningController.ts b/backend/src/controllers/v1/secretScanningController.ts index 3acd7e293..ff1e5f3f0 100644 --- a/backend/src/controllers/v1/secretScanningController.ts +++ b/backend/src/controllers/v1/secretScanningController.ts @@ -2,17 +2,49 @@ import { Request, Response } from "express"; import GitAppInstallationSession from "../../ee/models/gitAppInstallationSession"; import crypto from "crypto"; import { Types } from "mongoose"; -import { UnauthorizedRequestError } from "../../utils/errors"; +import { OrganizationNotFoundError, UnauthorizedRequestError } from "../../utils/errors"; import GitAppOrganizationInstallation from "../../ee/models/gitAppOrganizationInstallation"; -import { MembershipOrg } from "../../models"; -import GitRisks, { STATUS_RESOLVED_FALSE_POSITIVE, STATUS_RESOLVED_NOT_REVOKED, STATUS_RESOLVED_REVOKED } from "../../ee/models/gitRisks"; +import { scanGithubFullRepoForSecretLeaks } from "../../queues/secret-scanning/githubScanFullRepository"; +import { getSecretScanningGitAppId, getSecretScanningPrivateKey } from "../../config"; +import GitRisks, { + STATUS_RESOLVED_FALSE_POSITIVE, + STATUS_RESOLVED_NOT_REVOKED, + STATUS_RESOLVED_REVOKED +} from "../../ee/models/gitRisks"; +import { ProbotOctokit } from "probot"; +import { Organization } from "../../models"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/secretScanning"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + getUserOrgPermissions +} from "../../ee/services/RoleService"; +import { ForbiddenError } from "@casl/ability"; export const createInstallationSession = async (req: Request, res: Response) => { const sessionId = crypto.randomBytes(16).toString("hex"); + const { + params: { organizationId } + } = await validateRequest(reqValidator.CreateInstalLSessionv1, req); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.SecretScanning + ); + await GitAppInstallationSession.findByIdAndUpdate( - req.organization, + organization, { - organization: new Types.ObjectId(req.organization), + organization: organization.id, sessionId: sessionId, user: new Types.ObjectId(req.user._id) }, @@ -21,71 +53,128 @@ export const createInstallationSession = async (req: Request, res: Response) => res.send({ sessionId: sessionId - }) -} + }); +}; export const linkInstallationToOrganization = async (req: Request, res: Response) => { - const { installationId, sessionId } = req.body + const { + body: { sessionId, installationId } + } = await validateRequest(reqValidator.LinkInstallationToOrgv1, req); - const installationSession = await GitAppInstallationSession.findOneAndDelete({ sessionId: sessionId }) + const installationSession = await GitAppInstallationSession.findOneAndDelete({ + sessionId: sessionId + }); if (!installationSession) { - throw UnauthorizedRequestError() + throw UnauthorizedRequestError(); } - const userMembership = await MembershipOrg.find({ user: req.user._id, organization: installationSession.organization }) - if (!userMembership) { - throw UnauthorizedRequestError() + const { permission } = await getUserOrgPermissions( + req.user._id, + installationSession.organization.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.SecretScanning + ); + + const installationLink = await GitAppOrganizationInstallation.findOneAndUpdate( + { + organizationId: installationSession.organization + }, + { + installationId: installationId, + organizationId: installationSession.organization, + user: installationSession.user + }, + { + upsert: true + } + ).lean(); + + const octokit = new ProbotOctokit({ + auth: { + appId: await getSecretScanningGitAppId(), + privateKey: await getSecretScanningPrivateKey(), + installationId: installationId.toString() + } + }); + + const { + data: { repositories } + } = await octokit.apps.listReposAccessibleToInstallation(); + for (const repository of repositories) { + scanGithubFullRepoForSecretLeaks({ + organizationId: installationSession.organization.toString(), + installationId, + repository: { id: repository.id, fullName: repository.full_name } + }); } - - const installationLink = await GitAppOrganizationInstallation.findOneAndUpdate({ - organizationId: installationSession.organization, - }, { - installationId: installationId, - organizationId: installationSession.organization, - user: installationSession.user - }, { - upsert: true - }).lean() - - res.json(installationLink) -} + res.json(installationLink); +}; export const getCurrentOrganizationInstallationStatus = async (req: Request, res: Response) => { - const { organizationId } = req.params + const { organizationId } = req.params; try { - const appInstallation = await GitAppOrganizationInstallation.findOne({ organizationId: organizationId }).lean() + const appInstallation = await GitAppOrganizationInstallation.findOne({ + organizationId: organizationId + }).lean(); if (!appInstallation) { res.json({ appInstallationComplete: false - }) + }); } res.json({ appInstallationComplete: true - }) + }); } catch { res.json({ appInstallationComplete: false - }) + }); } -} +}; export const getRisksForOrganization = async (req: Request, res: Response) => { - const { organizationId } = req.params - const risks = await GitRisks.find({ organization: organizationId }).sort({ createdAt: -1 }).lean() + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgRisksv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.SecretScanning + ); + + const risks = await GitRisks.find({ organization: organizationId }) + .sort({ createdAt: -1 }) + .lean(); res.json({ risks: risks - }) -} + }); +}; export const updateRisksStatus = async (req: Request, res: Response) => { - const { riskId } = req.params - const { status } = req.body - const isRiskResolved = status == STATUS_RESOLVED_FALSE_POSITIVE || status == STATUS_RESOLVED_REVOKED || status == STATUS_RESOLVED_NOT_REVOKED ? true : false + const { + params: { organizationId, riskId }, + body: { status } + } = await validateRequest(reqValidator.UpdateRiskStatusv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.SecretScanning + ); + + const isRiskResolved = + status == STATUS_RESOLVED_FALSE_POSITIVE || + status == STATUS_RESOLVED_REVOKED || + status == STATUS_RESOLVED_NOT_REVOKED + ? true + : false; const risk = await GitRisks.findByIdAndUpdate(riskId, { status: status, isResolved: isRiskResolved - }).lean() + }).lean(); - res.json(risk) -} \ No newline at end of file + res.json(risk); +}; diff --git a/backend/src/controllers/v1/secretsFolderController.ts b/backend/src/controllers/v1/secretsFolderController.ts index cbc642592..b6f60249c 100644 --- a/backend/src/controllers/v1/secretsFolderController.ts +++ b/backend/src/controllers/v1/secretsFolderController.ts @@ -1,10 +1,12 @@ +import { ForbiddenError, subject } from "@casl/ability"; import { Request, Response } from "express"; import { Types } from "mongoose"; import { EventType, FolderVersion } from "../../ee/models"; import { EEAuditLogService, EESecretService } from "../../ee/services"; -import { validateMembership } from "../../helpers/membership"; import { isValidScope } from "../../helpers/secrets"; -import { Folder, Secret, ServiceTokenData } from "../../models"; +import { validateRequest } from "../../helpers/validation"; +import { Secret, ServiceTokenData } from "../../models"; +import { Folder } from "../../models/folder"; import { appendFolder, deleteFolderById, @@ -15,12 +17,99 @@ import { getParentFromFolderId, validateFolderName } from "../../services/FolderService"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; -import { ADMIN, MEMBER } from "../../variables"; +import * as reqValidator from "../../validation/folders"; -// verify workspace id/environment +/** + * Create folder with name [folderName] for workspace with id [workspaceId] + * and environment [environment] + * @param req + * @param res + * @returns + */ export const createFolder = async (req: Request, res: Response) => { - const { workspaceId, environment, folderName, parentFolderId } = req.body; + /* + #swagger.summary = 'Create a folder' + #swagger.description = 'Create a new folder in a specified workspace and environment' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.requestBody = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "description": "ID of the workspace where the folder will be created", + "example": "someWorkspaceId" + }, + "environment": { + "type": "string", + "description": "Environment where the folder will reside", + "example": "production" + }, + "folderName": { + "type": "string", + "description": "Name of the folder to be created", + "example": "my_folder" + }, + "parentFolderId": { + "type": "string", + "description": "ID of the parent folder under which this folder will be created. If not specified, it will be created at the root level.", + "example": "someParentFolderId" + } + }, + "required": ["workspaceId", "environment", "folderName"] + } + } + } + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "someFolderId" + }, + "name": { + "type": "string", + "example": "my_folder" + } + }, + "description": "Details of the created folder" + } + } + } + } + } + } + #swagger.responses[400] = { + description: "Bad Request. For example, 'Folder name cannot contain spaces. Only underscore and dashes'" + } + #swagger.responses[401] = { + description: "Unauthorized request. For example, 'Folder Permission Denied'" + } + */ + const { + body: { workspaceId, environment, folderName, parentFolderId } + } = await validateRequest(reqValidator.CreateFolderV1, req); + if (!validateFolderName(folderName)) { throw BadRequestError({ message: "Folder name cannot contain spaces. Only underscore and dashes" @@ -32,8 +121,20 @@ export const createFolder = async (req: Request, res: Response) => { environment }).lean(); + if (req.user) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + const secretPath = + folders && parentFolderId + ? getFolderWithPathFromId(folders.nodes, parentFolderId).folderPath + : "/"; + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } + // space has no folders initialized - + if (!folders) { if (req.authData.authPayload instanceof ServiceTokenData) { // root check @@ -62,10 +163,10 @@ export const createFolder = async (req: Request, res: Response) => { }); await folderVersion.save(); await EESecretService.takeSecretSnapshot({ - workspaceId, + workspaceId: new Types.ObjectId(workspaceId), environment }); - + await EEAuditLogService.createAuditLog( req.authData, { @@ -86,9 +187,9 @@ export const createFolder = async (req: Request, res: Response) => { } const folder = appendFolder(folders.nodes, { folderName, parentFolderId }); - + await Folder.findByIdAndUpdate(folders._id, folders); - + const { folder: parentFolder, folderPath: parentFolderPath } = getFolderWithPathFromId( folders.nodes, parentFolderId || "root" @@ -116,13 +217,13 @@ export const createFolder = async (req: Request, res: Response) => { await folderVersion.save(); await EESecretService.takeSecretSnapshot({ - workspaceId, + workspaceId: new Types.ObjectId(workspaceId), environment, folderId: parentFolderId }); - - const {folderPath} = getFolderWithPathFromId(folders.nodes, folder.id); - + + const { folderPath } = getFolderWithPathFromId(folders.nodes, folder.id); + await EEAuditLogService.createAuditLog( req.authData, { @@ -142,9 +243,99 @@ export const createFolder = async (req: Request, res: Response) => { return res.json({ folder }); }; +/** + * Update folder with id [folderId] + * @param req + * @param res + * @returns + */ export const updateFolderById = async (req: Request, res: Response) => { - const { folderId } = req.params; - const { name, workspaceId, environment } = req.body; + /* + #swagger.summary = 'Update a folder by ID' + #swagger.description = 'Update the name of a folder in a specified workspace and environment by its ID' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.parameters['folderId'] = { + "description": "ID of the folder to be updated", + "required": true, + "type": "string", + "in": "path" + } + + #swagger.requestBody = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "description": "ID of the workspace where the folder is located", + "example": "someWorkspaceId" + }, + "environment": { + "type": "string", + "description": "Environment where the folder is located", + "example": "production" + }, + "name": { + "type": "string", + "description": "New name for the folder", + "example": "updated_folder_name" + } + }, + "required": ["workspaceId", "environment", "name"] + } + } + } + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully updated folder" + }, + "folder": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "updated_folder_name" + }, + "id": { + "type": "string", + "example": "someFolderId" + } + }, + "description": "Details of the updated folder" + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "Bad Request. Reasons can include 'The folder doesn't exist' or 'Folder name cannot contain spaces. Only underscore and dashes'" + } + + #swagger.responses[401] = { + description: "Unauthorized request. For example, 'Folder Permission Denied'" + } + */ + const { + body: { workspaceId, environment, name }, + params: { folderId } + } = await validateRequest(reqValidator.UpdateFolderV1, req); + if (!validateFolderName(name)) { throw BadRequestError({ message: "Folder name cannot contain spaces. Only underscore and dashes" @@ -156,21 +347,21 @@ export const updateFolderById = async (req: Request, res: Response) => { throw BadRequestError({ message: "The folder doesn't exist" }); } - if (!(req.authData.authPayload instanceof ServiceTokenData)) { - // check that user is a member of the workspace - await validateMembership({ - userId: req.user._id.toString(), - workspaceId, - acceptedRoles: [ADMIN, MEMBER] - }); - } - const parentFolder = getParentFromFolderId(folders.nodes, folderId); if (!parentFolder) { throw BadRequestError({ message: "The folder doesn't exist" }); } + + if (req.user) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + const secretPath = getFolderWithPathFromId(folders.nodes, parentFolder.id).folderPath; + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } + const folder = parentFolder.children.find(({ id }) => id === folderId); - if (!folder) { throw BadRequestError({ message: "The folder doesn't exist" }); } @@ -197,13 +388,13 @@ export const updateFolderById = async (req: Request, res: Response) => { await folderVersion.save(); await EESecretService.takeSecretSnapshot({ - workspaceId, + workspaceId: new Types.ObjectId(workspaceId), environment, folderId: parentFolder.id }); - const {folderPath} = getFolderWithPathFromId(folders.nodes, folder.id); - + const { folderPath } = getFolderWithPathFromId(folders.nodes, folder.id); + await EEAuditLogService.createAuditLog( req.authData, { @@ -227,38 +418,121 @@ export const updateFolderById = async (req: Request, res: Response) => { }); }; +/** + * Delete folder with id [folderId] + * @param req + * @param res + * @returns + */ export const deleteFolder = async (req: Request, res: Response) => { - const { folderId } = req.params; - const { workspaceId, environment } = req.body; + /* + #swagger.summary = 'Delete a folder by ID' + #swagger.description = 'Delete the specified folder from a specified workspace and environment using its ID' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.parameters['folderId'] = { + "description": "ID of the folder to be deleted", + "required": true, + "type": "string", + "in": "path" + } + + #swagger.requestBody = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "description": "ID of the workspace where the folder is located", + "example": "someWorkspaceId" + }, + "environment": { + "type": "string", + "description": "Environment where the folder is located", + "example": "production" + } + }, + "required": ["workspaceId", "environment"] + } + } + } + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "successfully deleted folders" + }, + "folders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "someFolderId" + }, + "name": { + "type": "string", + "example": "someFolderName" + } + } + }, + "description": "List of IDs and names of the deleted folders" + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "Bad Request. Reasons can include 'The folder doesn't exist'" + } + + #swagger.responses[401] = { + description: "Unauthorized request. For example, 'Folder Permission Denied'" + } + */ + const { + params: { folderId }, + body: { environment, workspaceId } + } = await validateRequest(reqValidator.DeleteFolderV1, req); const folders = await Folder.findOne({ workspace: workspaceId, environment }); if (!folders) { throw BadRequestError({ message: "The folder doesn't exist" }); } - if (!(req.authData.authPayload instanceof ServiceTokenData)) { - // check that user is a member of the workspace - await validateMembership({ - userId: req.user._id.toString(), - workspaceId, - acceptedRoles: [ADMIN, MEMBER] - }); - } - - const {folderPath} = getFolderWithPathFromId(folders.nodes, folderId); - const delOp = deleteFolderById(folders.nodes, folderId); if (!delOp) { throw BadRequestError({ message: "The folder doesn't exist" }); } const { deletedNode: delFolder, parent: parentFolder } = delOp; + const { folderPath: secretPath } = getFolderWithPathFromId(folders.nodes, parentFolder.id); if (req.authData.authPayload instanceof ServiceTokenData) { - const { folderPath: secretPath } = getFolderWithPathFromId(folders.nodes, parentFolder.id); const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); if (!isValidScopeAccess) { throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); } + } else { + // check that user is a member of the workspace + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); } parentFolder.version += 1; @@ -280,7 +554,7 @@ export const deleteFolder = async (req: Request, res: Response) => { } await EESecretService.takeSecretSnapshot({ - workspaceId, + workspaceId: new Types.ObjectId(workspaceId), environment, folderId: parentFolder.id }); @@ -288,12 +562,12 @@ export const deleteFolder = async (req: Request, res: Response) => { await EEAuditLogService.createAuditLog( req.authData, { - type: EventType.DELETE_FOLDER , + type: EventType.DELETE_FOLDER, metadata: { environment, folderId, folderName: delFolder.name, - folderPath + folderPath: secretPath } }, { @@ -304,30 +578,117 @@ export const deleteFolder = async (req: Request, res: Response) => { res.send({ message: "successfully deleted folders", folders: delFolderIds }); }; -// TODO: validate workspace +/** + * Get folders for workspace with id [workspaceId] and environment [environment] + * considering [parentFolderId] and [parentFolderPath] + * @param req + * @param res + * @returns + */ export const getFolders = async (req: Request, res: Response) => { - const { workspaceId, environment, parentFolderId, parentFolderPath } = req.query as { - workspaceId: string; - environment: string; - parentFolderId?: string; - parentFolderPath?: string; - }; + /* + #swagger.summary = 'Retrieve folders based on specific conditions' + #swagger.description = 'Fetches folders from the specified workspace and environment, optionally providing either a parentFolderId or a parentFolderPath to narrow down results' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.parameters['workspaceId'] = { + "description": "ID of the workspace from which the folders are to be fetched", + "required": true, + "type": "string", + "in": "query" + } + + #swagger.parameters['environment'] = { + "description": "Environment where the folder is located", + "required": true, + "type": "string", + "in": "query" + } + + #swagger.parameters['parentFolderId'] = { + "description": "ID of the parent folder", + "required": false, + "type": "string", + "in": "query" + } + + #swagger.parameters['parentFolderPath'] = { + "description": "Path of the parent folder, like /folder1/folder2", + "required": false, + "type": "string", + "in": "query" + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "folders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "someFolderId" + }, + "name": { + "type": "string", + "example": "someFolderName" + } + } + }, + "description": "List of folders" + }, + "dir": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "parentFolderName" + }, + "id": { + "type": "string", + "example": "parentFolderId" + } + } + }, + "description": "List of directories" + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "Bad Request. For instance, 'The folder doesn't exist'" + } + + #swagger.responses[401] = { + description: "Unauthorized request. For example, 'Folder Permission Denied'" + } + */ + const { + query: { workspaceId, environment, parentFolderId, parentFolderPath } + } = await validateRequest(reqValidator.GetFoldersV1, req); const folders = await Folder.findOne({ workspace: workspaceId, environment }); + + if (req.user) await getUserProjectPermissions(req.user._id, workspaceId); + if (!folders) { res.send({ folders: [], dir: [] }); return; } - if (!(req.authData.authPayload instanceof ServiceTokenData)) { - // check that user is a member of the workspace - await validateMembership({ - userId: req.user._id.toString(), - workspaceId, - acceptedRoles: [ADMIN, MEMBER] - }); - } - // if instead of parentFolderId given a path like /folder1/folder2 if (parentFolderPath) { if (req.authData.authPayload instanceof ServiceTokenData) { diff --git a/backend/src/controllers/v1/signupController.ts b/backend/src/controllers/v1/signupController.ts index ce464e214..68a4e07f2 100644 --- a/backend/src/controllers/v1/signupController.ts +++ b/backend/src/controllers/v1/signupController.ts @@ -10,6 +10,8 @@ import { getSmtpConfigured } from "../../config"; import { validateUserEmail } from "../../validation"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/auth"; /** * Signup step 1: Initialize account for user under email [email] and send a verification code @@ -19,7 +21,9 @@ import { validateUserEmail } from "../../validation"; * @returns */ export const beginEmailSignup = async (req: Request, res: Response) => { - const email: string = req.body.email; + const { + body: { email } + } = await validateRequest(reqValidator.BeginEmailSignUpV1, req); // validate that email is not disposable validateUserEmail(email); @@ -50,7 +54,9 @@ export const beginEmailSignup = async (req: Request, res: Response) => { */ export const verifyEmailSignup = async (req: Request, res: Response) => { let user; - const { email, code } = req.body; + const { + body: { email, code } + } = await validateRequest(reqValidator.VerifyEmailSignUpV1, req); // initialize user account user = await User.findOne({ email }).select("+publicKey"); diff --git a/backend/src/controllers/v1/userActionController.ts b/backend/src/controllers/v1/userActionController.ts index 9e7354ce1..9a151f8dd 100644 --- a/backend/src/controllers/v1/userActionController.ts +++ b/backend/src/controllers/v1/userActionController.ts @@ -1,5 +1,7 @@ import { Request, Response } from "express"; +import { validateRequest } from "../../helpers/validation"; import { UserAction } from "../../models"; +import * as reqValidator from "../../validation/action"; /** * Add user action [action] @@ -8,26 +10,27 @@ import { UserAction } from "../../models"; * @returns */ export const addUserAction = async (req: Request, res: Response) => { - // add/record new action [action] for user with id [req.user._id] - - const { action } = req.body; + // add/record new action [action] for user with id [req.user._id] + const { + body: { action } + } = await validateRequest(reqValidator.AddUserActionV1, req); const userAction = await UserAction.findOneAndUpdate( { user: req.user._id, - action, + action }, { user: req.user._id, action }, { new: true, - upsert: true, + upsert: true } ); - return res.status(200).send({ - message: "Successfully recorded user action", - userAction, - }); + return res.status(200).send({ + message: "Successfully recorded user action", + userAction + }); }; /** @@ -37,15 +40,17 @@ export const addUserAction = async (req: Request, res: Response) => { * @returns */ export const getUserAction = async (req: Request, res: Response) => { - // get user action [action] for user with id [req.user._id] - const action: string = req.query.action as string; + // get user action [action] for user with id [req.user._id] + const { + query: { action } + } = await validateRequest(reqValidator.GetUserActionV1, req); const userAction = await UserAction.findOne({ user: req.user._id, - action, + action }); - return res.status(200).send({ - userAction, - }); + return res.status(200).send({ + userAction + }); }; diff --git a/backend/src/controllers/v1/webhookController.ts b/backend/src/controllers/v1/webhookController.ts index 7e84aae87..73d92e7dc 100644 --- a/backend/src/controllers/v1/webhookController.ts +++ b/backend/src/controllers/v1/webhookController.ts @@ -1,16 +1,32 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { client, getRootEncryptionKey } from "../../config"; -import { validateMembership } from "../../helpers"; import { Webhook } from "../../models"; import { getWebhookPayload, triggerWebhookRequest } from "../../services/WebhookService"; import { BadRequestError, ResourceNotFoundError } from "../../utils/errors"; import { EEAuditLogService } from "../../ee/services"; import { EventType } from "../../ee/models"; -import { ADMIN, ALGORITHM_AES_256_GCM, ENCODING_SCHEME_BASE64, MEMBER } from "../../variables"; +import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_BASE64 } from "../../variables"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/webhooks"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { ForbiddenError } from "@casl/ability"; export const createWebhook = async (req: Request, res: Response) => { - const { webhookUrl, webhookSecretKey, environment, workspaceId, secretPath } = req.body; + const { + body: { webhookUrl, webhookSecretKey, environment, workspaceId, secretPath } + } = await validateRequest(reqValidator.CreateWebhookV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Webhooks + ); + const webhook = new Webhook({ workspace: workspaceId, environment, @@ -29,7 +45,7 @@ export const createWebhook = async (req: Request, res: Response) => { } await webhook.save(); - + await EEAuditLogService.createAuditLog( req.authData, { @@ -43,7 +59,7 @@ export const createWebhook = async (req: Request, res: Response) => { } }, { - workspaceId + workspaceId: new Types.ObjectId(workspaceId) } ); @@ -54,19 +70,24 @@ export const createWebhook = async (req: Request, res: Response) => { }; export const updateWebhook = async (req: Request, res: Response) => { - const { webhookId } = req.params; - const { isDisabled } = req.body; + const { + body: { isDisabled }, + params: { webhookId } + } = await validateRequest(reqValidator.UpdateWebhookV1, req); + const webhook = await Webhook.findById(webhookId); if (!webhook) { throw BadRequestError({ message: "Webhook not found!!" }); } - // check that user is a member of the workspace - await validateMembership({ - userId: req.user._id.toString(), - workspaceId: webhook.workspace, - acceptedRoles: [ADMIN, MEMBER] - }); + const { permission } = await getUserProjectPermissions( + req.user._id, + webhook.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Webhooks + ); if (typeof isDisabled !== undefined) { webhook.isDisabled = isDisabled; @@ -97,19 +118,24 @@ export const updateWebhook = async (req: Request, res: Response) => { }; export const deleteWebhook = async (req: Request, res: Response) => { - const { webhookId } = req.params; + const { + params: { webhookId } + } = await validateRequest(reqValidator.DeleteWebhookV1, req); let webhook = await Webhook.findById(webhookId); if (!webhook) { throw ResourceNotFoundError({ message: "Webhook not found!!" }); } - await validateMembership({ - userId: req.user._id.toString(), - workspaceId: webhook.workspace, - acceptedRoles: [ADMIN, MEMBER] - }); - + const { permission } = await getUserProjectPermissions( + req.user._id, + webhook.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Webhooks + ); + webhook = await Webhook.findByIdAndDelete(webhookId); if (!webhook) { @@ -139,17 +165,23 @@ export const deleteWebhook = async (req: Request, res: Response) => { }; export const testWebhook = async (req: Request, res: Response) => { - const { webhookId } = req.params; + const { + params: { webhookId } + } = await validateRequest(reqValidator.TestWebhookV1, req); + const webhook = await Webhook.findById(webhookId); if (!webhook) { throw BadRequestError({ message: "Webhook not found!!" }); } - await validateMembership({ - userId: req.user._id.toString(), - workspaceId: webhook.workspace, - acceptedRoles: [ADMIN, MEMBER] - }); + const { permission } = await getUserProjectPermissions( + req.user._id, + webhook.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Webhooks + ); try { await triggerWebhookRequest( @@ -182,7 +214,15 @@ export const testWebhook = async (req: Request, res: Response) => { }; export const listWebhooks = async (req: Request, res: Response) => { - const { environment, workspaceId, secretPath } = req.query; + const { + query: { environment, workspaceId, secretPath } + } = await validateRequest(reqValidator.ListWebhooksV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Webhooks + ); const optionalFilters: Record = {}; if (environment) optionalFilters.environment = environment as string; diff --git a/backend/src/controllers/v1/workspaceController.ts b/backend/src/controllers/v1/workspaceController.ts index 1425c142c..cbefc98a7 100644 --- a/backend/src/controllers/v1/workspaceController.ts +++ b/backend/src/controllers/v1/workspaceController.ts @@ -5,17 +5,28 @@ import { Integration, IntegrationAuth, Membership, - MembershipOrg, + Organization, ServiceToken, - Workspace, + Workspace } from "../../models"; -import { - createWorkspace as create, - deleteWorkspace as deleteWork, -} from "../../helpers/workspace"; +import { createWorkspace as create, deleteWorkspace as deleteWork } from "../../helpers/workspace"; import { EELicenseService } from "../../ee/services"; import { addMemberships } from "../../helpers/membership"; import { ADMIN } from "../../variables"; +import { OrganizationNotFoundError } from "../../utils/errors"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + getUserOrgPermissions +} from "../../ee/services/RoleService"; +import { ForbiddenError } from "@casl/ability"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; /** * Return public keys of members of workspace with id [workspaceId] @@ -24,21 +35,29 @@ import { ADMIN } from "../../variables"; * @returns */ export const getWorkspacePublicKeys = async (req: Request, res: Response) => { - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspacePublicKeysV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Member + ); const publicKeys = ( await Membership.find({ - workspace: workspaceId, + workspace: workspaceId }).populate<{ user: IUser }>("user", "publicKey") ).map((member) => { return { publicKey: member.user.publicKey, - userId: member.user._id, + userId: member.user._id }; }); return res.status(200).send({ - publicKeys, + publicKeys }); }; @@ -49,14 +68,22 @@ export const getWorkspacePublicKeys = async (req: Request, res: Response) => { * @returns */ export const getWorkspaceMemberships = async (req: Request, res: Response) => { - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceMembershipsV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Member + ); const users = await Membership.find({ - workspace: workspaceId, + workspace: workspaceId }).populate("user", "+publicKey"); return res.status(200).send({ - users, + users }); }; @@ -69,12 +96,12 @@ export const getWorkspaceMemberships = async (req: Request, res: Response) => { export const getWorkspaces = async (req: Request, res: Response) => { const workspaces = ( await Membership.find({ - user: req.user._id, + user: req.user._id }).populate("workspace") ).map((m) => m.workspace); return res.status(200).send({ - workspaces, + workspaces }); }; @@ -85,14 +112,16 @@ export const getWorkspaces = async (req: Request, res: Response) => { * @returns */ export const getWorkspace = async (req: Request, res: Response) => { - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceV1, req); const workspace = await Workspace.findOne({ - _id: workspaceId, + _id: workspaceId }); return res.status(200).send({ - workspace, + workspace }); }; @@ -104,26 +133,32 @@ export const getWorkspace = async (req: Request, res: Response) => { * @returns */ export const createWorkspace = async (req: Request, res: Response) => { - const { workspaceName, organizationId } = req.body; + const { + body: { organizationId, workspaceName } + } = await validateRequest(reqValidator.CreateWorkspaceV1, req); - // validate organization membership - const membershipOrg = await MembershipOrg.findOne({ - user: req.user._id, - organization: new Types.ObjectId(organizationId), - }); - - if (!membershipOrg) { - throw new Error("Failed to validate organization membership"); + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); } + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Workspace + ); + const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); - + if (plan.workspaceLimit !== null) { // case: limit imposed on number of workspaces allowed if (plan.workspacesUsed >= plan.workspaceLimit) { // case: number of workspaces used exceeds the number of workspaces allowed return res.status(400).send({ - message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces.", + message: + "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces." }); } } @@ -135,17 +170,17 @@ export const createWorkspace = async (req: Request, res: Response) => { // create workspace and add user as member const workspace = await create({ name: workspaceName, - organizationId: new Types.ObjectId(organizationId), + organizationId: new Types.ObjectId(organizationId) }); await addMemberships({ userIds: [req.user._id], workspaceId: workspace._id.toString(), - roles: [ADMIN], + roles: [ADMIN] }); return res.status(200).send({ - workspace, + workspace }); }; @@ -156,15 +191,23 @@ export const createWorkspace = async (req: Request, res: Response) => { * @returns */ export const deleteWorkspace = async (req: Request, res: Response) => { - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(reqValidator.DeleteWorkspaceV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Workspace + ); // delete workspace await deleteWork({ - id: workspaceId, + id: workspaceId }); return res.status(200).send({ - message: "Successfully deleted workspace", + message: "Successfully deleted workspace" }); }; @@ -175,24 +218,32 @@ export const deleteWorkspace = async (req: Request, res: Response) => { * @returns */ export const changeWorkspaceName = async (req: Request, res: Response) => { - const { workspaceId } = req.params; - const { name } = req.body; + const { + params: { workspaceId }, + body: { name } + } = await validateRequest(reqValidator.ChangeWorkspaceNameV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Workspace + ); const workspace = await Workspace.findOneAndUpdate( { - _id: workspaceId, + _id: workspaceId }, { - name, + name }, { - new: true, + new: true } ); return res.status(200).send({ message: "Successfully changed workspace name", - workspace, + workspace }); }; @@ -203,14 +254,21 @@ export const changeWorkspaceName = async (req: Request, res: Response) => { * @returns */ export const getWorkspaceIntegrations = async (req: Request, res: Response) => { - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceIntegrationsV1, req); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); const integrations = await Integration.find({ - workspace: workspaceId, + workspace: workspaceId }); return res.status(200).send({ - integrations, + integrations }); }; @@ -220,18 +278,23 @@ export const getWorkspaceIntegrations = async (req: Request, res: Response) => { * @param res * @returns */ -export const getWorkspaceIntegrationAuthorizations = async ( - req: Request, - res: Response -) => { - const { workspaceId } = req.params; +export const getWorkspaceIntegrationAuthorizations = async (req: Request, res: Response) => { + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceIntegrationAuthorizationsV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); const authorizations = await IntegrationAuth.find({ - workspace: workspaceId, + workspace: workspaceId }); return res.status(200).send({ - authorizations, + authorizations }); }; @@ -241,18 +304,24 @@ export const getWorkspaceIntegrationAuthorizations = async ( * @param res * @returns */ -export const getWorkspaceServiceTokens = async ( - req: Request, - res: Response -) => { - const { workspaceId } = req.params; +export const getWorkspaceServiceTokens = async (req: Request, res: Response) => { + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceServiceTokensV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.ServiceTokens + ); + // ?? FIX. const serviceTokens = await ServiceToken.find({ user: req.user._id, - workspace: workspaceId, + workspace: workspaceId }); return res.status(200).send({ - serviceTokens, + serviceTokens }); }; diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts index 8f2f4cea0..d75206c64 100644 --- a/backend/src/controllers/v2/authController.ts +++ b/backend/src/controllers/v2/authController.ts @@ -10,16 +10,11 @@ import { sendMail } from "../../helpers/nodemailer"; import { TokenService } from "../../services"; import { EELogService } from "../../ee/services"; import { BadRequestError, InternalServerError } from "../../utils/errors"; -import { - ACTION_LOGIN, - TOKEN_EMAIL_MFA, -} from "../../variables"; +import { ACTION_LOGIN, TOKEN_EMAIL_MFA } from "../../variables"; import { getUserAgentType } from "../../utils/posthog"; // TODO: move this -import { - getHttpsEnabled, - getJwtMfaLifetime, - getJwtMfaSecret, -} from "../../config"; +import { getHttpsEnabled, getJwtMfaLifetime, getJwtMfaSecret } from "../../config"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/auth"; declare module "jsonwebtoken" { export interface UserIDJwtPayload extends jwt.JwtPayload { @@ -34,13 +29,10 @@ declare module "jsonwebtoken" { * @returns */ export const login1 = async (req: Request, res: Response) => { - const { - email, - clientPublicKey, - }: { email: string; clientPublicKey: string } = req.body; + const { email, clientPublicKey }: { email: string; clientPublicKey: string } = req.body; const user = await User.findOne({ - email, + email }).select("+salt +verifier"); if (!user) throw new Error("Failed to find user"); @@ -49,25 +41,28 @@ export const login1 = async (req: Request, res: Response) => { server.init( { salt: user.salt, - verifier: user.verifier, + verifier: user.verifier }, async () => { // generate server-side public key const serverPublicKey = server.getPublicKey(); - await LoginSRPDetail.findOneAndReplace({ email: email }, { - email: email, - clientPublicKey: clientPublicKey, - serverBInt: bigintConversion.bigintToBuf(server.bInt), - }, { upsert: true, returnNewDocument: false }); + await LoginSRPDetail.findOneAndReplace( + { email: email }, + { + email: email, + clientPublicKey: clientPublicKey, + serverBInt: bigintConversion.bigintToBuf(server.bInt) + }, + { upsert: true, returnNewDocument: false } + ); return res.status(200).send({ serverPublicKey, - salt: user.salt, + salt: user.salt }); } ); - }; /** @@ -78,19 +73,22 @@ export const login1 = async (req: Request, res: Response) => { * @returns */ export const login2 = async (req: Request, res: Response) => { - if (!req.headers["user-agent"]) throw InternalServerError({ message: "User-Agent header is required" }); + if (!req.headers["user-agent"]) + throw InternalServerError({ message: "User-Agent header is required" }); const { email, clientProof } = req.body; const user = await User.findOne({ - email, - }).select("+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag +devices"); + email + }).select( + "+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag +devices" + ); if (!user) throw new Error("Failed to find user"); - const loginSRPDetail = await LoginSRPDetail.findOneAndDelete({ email: email }) + const loginSRPDetail = await LoginSRPDetail.findOneAndDelete({ email: email }); if (!loginSRPDetail) { - return BadRequestError(Error("Failed to find login details for SRP")) + return BadRequestError(Error("Failed to find login details for SRP")); } const server = new jsrp.server(); @@ -98,7 +96,7 @@ export const login2 = async (req: Request, res: Response) => { { salt: user.salt, verifier: user.verifier, - b: loginSRPDetail.serverBInt, + b: loginSRPDetail.serverBInt }, async () => { server.setClientPublicKey(loginSRPDetail.clientPublicKey); @@ -111,15 +109,15 @@ export const login2 = async (req: Request, res: Response) => { // generate temporary MFA token const token = createToken({ payload: { - userId: user._id.toString(), + userId: user._id.toString() }, expiresIn: await getJwtMfaLifetime(), - secret: await getJwtMfaSecret(), + secret: await getJwtMfaSecret() }); const code = await TokenService.createToken({ type: TOKEN_EMAIL_MFA, - email, + email }); // send MFA code [code] to [email] @@ -128,27 +126,27 @@ export const login2 = async (req: Request, res: Response) => { subjectLine: "Infisical MFA code", recipients: [email], substitutions: { - code, - }, + code + } }); return res.status(200).send({ mfaEnabled: true, - token, + token }); } await checkUserDevice({ user, ip: req.realIP, - userAgent: req.headers["user-agent"] ?? "", + userAgent: req.headers["user-agent"] ?? "" }); // issue tokens - const tokens = await issueAuthTokens({ + const tokens = await issueAuthTokens({ userId: user._id, ip: req.realIP, - userAgent: req.headers["user-agent"] ?? "", + userAgent: req.headers["user-agent"] ?? "" }); // store (refresh) token in httpOnly cookie @@ -156,7 +154,7 @@ export const login2 = async (req: Request, res: Response) => { httpOnly: true, path: "/", sameSite: "strict", - secure: await getHttpsEnabled(), + secure: await getHttpsEnabled() }); // case: user does not have MFA enabled @@ -182,36 +180,33 @@ export const login2 = async (req: Request, res: Response) => { publicKey: user.publicKey, encryptedPrivateKey: user.encryptedPrivateKey, iv: user.iv, - tag: user.tag, - } + tag: user.tag + }; - if ( - user?.protectedKey && - user?.protectedKeyIV && - user?.protectedKeyTag - ) { + if (user?.protectedKey && user?.protectedKeyIV && user?.protectedKeyTag) { response.protectedKey = user.protectedKey; - response.protectedKeyIV = user.protectedKeyIV + response.protectedKeyIV = user.protectedKeyIV; response.protectedKeyTag = user.protectedKeyTag; } const loginAction = await EELogService.createAction({ name: ACTION_LOGIN, - userId: user._id, + userId: user._id }); - loginAction && await EELogService.createLog({ - userId: user._id, - actions: [loginAction], - channel: getUserAgentType(req.headers["user-agent"]), - ipAddress: req.ip, - }); + loginAction && + (await EELogService.createLog({ + userId: user._id, + actions: [loginAction], + channel: getUserAgentType(req.headers["user-agent"]), + ipAddress: req.ip + })); return res.status(200).send(response); } return res.status(400).send({ - message: "Failed to authenticate. Try again?", + message: "Failed to authenticate. Try again?" }); } ); @@ -219,15 +214,17 @@ export const login2 = async (req: Request, res: Response) => { /** * Send MFA token to email [email] - * @param req - * @param res + * @param req + * @param res */ export const sendMfaToken = async (req: Request, res: Response) => { - const { email } = req.body; + const { + body: { email } + } = await validateRequest(reqValidator.SendMfaTokenV2, req); const code = await TokenService.createToken({ type: TOKEN_EMAIL_MFA, - email, + email }); // send MFA code [code] to [email] @@ -236,49 +233,53 @@ export const sendMfaToken = async (req: Request, res: Response) => { subjectLine: "Infisical MFA code", recipients: [email], substitutions: { - code, - }, + code + } }); return res.status(200).send({ - message: "Successfully sent new MFA code", + message: "Successfully sent new MFA code" }); -} +}; /** * Verify MFA token [mfaToken] and issue JWT and refresh tokens if the * MFA token [mfaToken] is valid - * @param req - * @param res + * @param req + * @param res */ export const verifyMfaToken = async (req: Request, res: Response) => { - const { email, mfaToken } = req.body; + const { + body: { email, mfaToken } + } = await validateRequest(reqValidator.VerifyMfaTokenV2, req); await TokenService.validateToken({ type: TOKEN_EMAIL_MFA, email, - token: mfaToken, + token: mfaToken }); const user = await User.findOne({ - email, - }).select("+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag +devices"); + email + }).select( + "+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag +devices" + ); if (!user) throw new Error("Failed to find user"); - await LoginSRPDetail.deleteOne({ userId: user.id }) + await LoginSRPDetail.deleteOne({ userId: user.id }); await checkUserDevice({ user, ip: req.realIP, - userAgent: req.headers["user-agent"] ?? "", + userAgent: req.headers["user-agent"] ?? "" }); // issue tokens - const tokens = await issueAuthTokens({ + const tokens = await issueAuthTokens({ userId: user._id, ip: req.realIP, - userAgent: req.headers["user-agent"] ?? "", + userAgent: req.headers["user-agent"] ?? "" }); // store (refresh) token in httpOnly cookie @@ -286,7 +287,7 @@ export const verifyMfaToken = async (req: Request, res: Response) => { httpOnly: true, path: "/", sameSite: "strict", - secure: await getHttpsEnabled(), + secure: await getHttpsEnabled() }); interface VerifyMfaTokenRes { @@ -319,8 +320,8 @@ export const verifyMfaToken = async (req: Request, res: Response) => { publicKey: user.publicKey as string, encryptedPrivateKey: user.encryptedPrivateKey as string, iv: user.iv as string, - tag: user.tag as string, - } + tag: user.tag as string + }; if (user?.protectedKey && user?.protectedKeyIV && user?.protectedKeyTag) { resObj.protectedKey = user.protectedKey; @@ -330,15 +331,16 @@ export const verifyMfaToken = async (req: Request, res: Response) => { const loginAction = await EELogService.createAction({ name: ACTION_LOGIN, - userId: user._id, + userId: user._id }); - loginAction && await EELogService.createLog({ - userId: user._id, - actions: [loginAction], - channel: getUserAgentType(req.headers["user-agent"]), - ipAddress: req.realIP, - }); + loginAction && + (await EELogService.createLog({ + userId: user._id, + actions: [loginAction], + channel: getUserAgentType(req.headers["user-agent"]), + ipAddress: req.realIP + })); return res.status(200).send(resObj); -} +}; diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts index 4e7d39fab..72365d7da 100644 --- a/backend/src/controllers/v2/environmentController.ts +++ b/backend/src/controllers/v2/environmentController.ts @@ -6,27 +6,126 @@ import { Secret, ServiceToken, ServiceTokenData, - Workspace, + Workspace } from "../../models"; import { EventType, SecretVersion } from "../../ee/models"; import { EEAuditLogService, EELicenseService } from "../../ee/services"; import { BadRequestError, WorkspaceNotFoundError } from "../../utils/errors"; import _ from "lodash"; import { PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS } from "../../variables"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/environments"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { ForbiddenError } from "@casl/ability"; /** - * Create new workspace environment named [environmentName] under workspace with id + * Create new workspace environment named [environmentName] + * with slug [environmentSlug] under workspace with id * @param req * @param res * @returns */ -export const createWorkspaceEnvironment = async ( - req: Request, - res: Response -) => { +export const createWorkspaceEnvironment = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Create environment' + #swagger.description = 'Create environment' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.parameters['workspaceId'] = { + "description": "ID of project", + "required": true, + "type": "string" + } + + /* + #swagger.summary = 'Create environment' + #swagger.description = 'Create environment' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.parameters['workspaceId'] = { + "description": "ID of project", + "required": true, + "type": "string" + } + + #swagger.requestBody = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentName": { + "type": "string", + "description": "Name of the environment", + "example": "development" + }, + "environmentSlug": { + "type": "string", + "description": "Slug of the environment", + "example": "dev-environment" + } + }, + "required": ["environmentName", "environmentSlug"] + } + } + } + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully created new environment" + }, + "workspace": { + "type": "string", + "example": "someWorkspaceId" + }, + "environment": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "someEnvironmentName" + }, + "slug": { + "type": "string", + "example": "someEnvironmentSlug" + } + } + } + }, + "description": "Response after creating a new environment" + } + } + } + } + */ + const { + params: { workspaceId }, + body: { environmentName, environmentSlug } + } = await validateRequest(reqValidator.CreateWorkspaceEnvironmentV2, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Environments + ); - const { workspaceId } = req.params; - const { environmentName, environmentSlug } = req.body; const workspace = await Workspace.findById(workspaceId).exec(); if (!workspace) throw WorkspaceNotFoundError(); @@ -39,7 +138,8 @@ export const createWorkspaceEnvironment = async ( // case: number of environments used exceeds the number of environments allowed return res.status(400).send({ - message: "Failed to create environment due to environment limit reached. Upgrade plan to create more environments.", + message: + "Failed to create environment due to environment limit reached. Upgrade plan to create more environments." }); } } @@ -55,7 +155,7 @@ export const createWorkspaceEnvironment = async ( workspace?.environments.push({ name: environmentName, - slug: environmentSlug.toLowerCase(), + slug: environmentSlug.toLowerCase() }); await workspace.save(); @@ -80,8 +180,8 @@ export const createWorkspaceEnvironment = async ( workspace: workspaceId, environment: { name: environmentName, - slug: environmentSlug, - }, + slug: environmentSlug + } }); }; @@ -91,34 +191,46 @@ export const createWorkspaceEnvironment = async ( * @param res * @returns */ -export const reorderWorkspaceEnvironments = async ( - req: Request, - res: Response -) => { - const { workspaceId } = req.params; - const { environmentSlug, environmentName, otherEnvironmentSlug, otherEnvironmentName } = req.body; +export const reorderWorkspaceEnvironments = async (req: Request, res: Response) => { + const { + params: { workspaceId }, + body: { environmentName, environmentSlug, otherEnvironmentSlug, otherEnvironmentName } + } = await validateRequest(reqValidator.ReorderWorkspaceEnvironmentsV2, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Environments + ); // atomic update the env to avoid conflict const workspace = await Workspace.findById(workspaceId).exec(); if (!workspace) { - throw BadRequestError({message: "Couldn't load workspace"}); + throw BadRequestError({ message: "Couldn't load workspace" }); } - const environmentIndex = workspace.environments.findIndex((env) => env.name === environmentName && env.slug === environmentSlug) - const otherEnvironmentIndex = workspace.environments.findIndex((env) => env.name === otherEnvironmentName && env.slug === otherEnvironmentSlug) + const environmentIndex = workspace.environments.findIndex( + (env) => env.name === environmentName && env.slug === environmentSlug + ); + const otherEnvironmentIndex = workspace.environments.findIndex( + (env) => env.name === otherEnvironmentName && env.slug === otherEnvironmentSlug + ); if (environmentIndex === -1 || otherEnvironmentIndex === -1) { - throw BadRequestError({message: "environment or otherEnvironment couldn't be found"}) + throw BadRequestError({ message: "environment or otherEnvironment couldn't be found" }); } // swap the order of the environments - [workspace.environments[environmentIndex], workspace.environments[otherEnvironmentIndex]] = [workspace.environments[otherEnvironmentIndex], workspace.environments[environmentIndex]] + [workspace.environments[environmentIndex], workspace.environments[otherEnvironmentIndex]] = [ + workspace.environments[otherEnvironmentIndex], + workspace.environments[environmentIndex] + ]; - await workspace.save() + await workspace.save(); return res.status(200).send({ message: "Successfully reordered environments", - workspace: workspaceId, + workspace: workspaceId }); }; @@ -129,12 +241,91 @@ export const reorderWorkspaceEnvironments = async ( * @param res * @returns */ -export const renameWorkspaceEnvironment = async ( - req: Request, - res: Response -) => { - const { workspaceId } = req.params; - const { environmentName, environmentSlug, oldEnvironmentSlug } = req.body; +export const renameWorkspaceEnvironment = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Rename workspace environment' + #swagger.description = 'Rename a specific environment within a workspace' + + #swagger.parameters['workspaceId'] = { + "description": "ID of the workspace", + "required": true, + "type": "string", + "in": "path" + } + + #swagger.requestBody = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentName": { + "type": "string", + "description": "New name for the environment", + "example": "Staging-Renamed" + }, + "environmentSlug": { + "type": "string", + "description": "New slug for the environment", + "example": "staging-renamed" + }, + "oldEnvironmentSlug": { + "type": "string", + "description": "Current slug of the environment to rename", + "example": "staging-old" + } + }, + "required": ["environmentName", "environmentSlug", "oldEnvironmentSlug"] + } + } + } + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully update environment" + }, + "workspace": { + "type": "string", + "example": "someWorkspaceId" + }, + "environment": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Staging-Renamed" + }, + "slug": { + "type": "string", + "example": "staging-renamed" + } + } + } + }, + "description": "Details of the renamed environment" + } + } + } + } + */ + const { + params: { workspaceId }, + body: { environmentName, environmentSlug, oldEnvironmentSlug } + } = await validateRequest(reqValidator.UpdateWorkspaceEnvironmentV2, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Environments + ); + // user should pass both new slug and env name if (!environmentSlug || !environmentName) { throw new Error("Invalid environment given."); @@ -148,16 +339,13 @@ export const renameWorkspaceEnvironment = async ( const isEnvExist = workspace.environments.some( ({ name, slug }) => - slug !== oldEnvironmentSlug && - (name === environmentName || slug === environmentSlug) + slug !== oldEnvironmentSlug && (name === environmentName || slug === environmentSlug) ); if (isEnvExist) { throw new Error("Invalid environment given"); } - const envIndex = workspace?.environments.findIndex( - ({ slug }) => slug === oldEnvironmentSlug - ); + const envIndex = workspace?.environments.findIndex(({ slug }) => slug === oldEnvironmentSlug); if (envIndex === -1) { throw new Error("Invalid environment given"); } @@ -191,7 +379,7 @@ export const renameWorkspaceEnvironment = async ( await Membership.updateMany( { workspace: workspaceId, - "deniedPermissions.environmentSlug": oldEnvironmentSlug, + "deniedPermissions.environmentSlug": oldEnvironmentSlug }, { $set: { "deniedPermissions.$[element].environmentSlug": environmentSlug } }, { arrayFilters: [{ "element.environmentSlug": oldEnvironmentSlug }] } @@ -218,8 +406,8 @@ export const renameWorkspaceEnvironment = async ( workspace: workspaceId, environment: { name: environmentName, - slug: environmentSlug, - }, + slug: environmentSlug + } }); }; @@ -229,21 +417,83 @@ export const renameWorkspaceEnvironment = async ( * @param res * @returns */ -export const deleteWorkspaceEnvironment = async ( - req: Request, - res: Response -) => { - const { workspaceId } = req.params; - const { environmentSlug } = req.body; +export const deleteWorkspaceEnvironment = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Delete workspace environment' + #swagger.description = 'Delete a specific environment from a workspace' + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.parameters['workspaceId'] = { + "description": "ID of the workspace", + "required": true, + "type": "string", + "in": "path" + } + + #swagger.requestBody = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentSlug": { + "type": "string", + "description": "Slug of the environment to delete", + "example": "dev-environment" + } + }, + "required": ["environmentSlug"] + } + } + } + } + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully deleted environment" + }, + "workspace": { + "type": "string", + "example": "someWorkspaceId" + }, + "environment": { + "type": "string", + "example": "dev-environment" + } + }, + "description": "Response after deleting an environment from a workspace" + } + } + } + } +*/ + const { + params: { workspaceId }, + body: { environmentSlug } + } = await validateRequest(reqValidator.DeleteWorkspaceEnvironmentV2, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Environments + ); + // atomic update the env to avoid conflict const workspace = await Workspace.findById(workspaceId).exec(); if (!workspace) { throw new Error("Failed to create workspace environment"); } - const envIndex = workspace?.environments.findIndex( - ({ slug }) => slug === environmentSlug - ); + const envIndex = workspace?.environments.findIndex(({ slug }) => slug === environmentSlug); if (envIndex === -1) { throw new Error("Invalid environment given"); } @@ -256,11 +506,11 @@ export const deleteWorkspaceEnvironment = async ( // clean up await Secret.deleteMany({ workspace: workspaceId, - environment: environmentSlug, + environment: environmentSlug }); await SecretVersion.deleteMany({ workspace: workspaceId, - environment: environmentSlug, + environment: environmentSlug }); // await ServiceToken.deleteMany({ @@ -279,7 +529,7 @@ export const deleteWorkspaceEnvironment = async ( await Integration.deleteMany({ workspace: workspaceId, - environment: environmentSlug, + environment: environmentSlug }); await Membership.updateMany( { workspace: workspaceId }, @@ -305,46 +555,100 @@ export const deleteWorkspaceEnvironment = async ( return res.status(200).send({ message: "Successfully deleted environment", workspace: workspaceId, - environment: environmentSlug, + environment: environmentSlug }); }; +// TODO(akhilmhdh) after rbac this can be completely removed +export const getAllAccessibleEnvironmentsOfWorkspace = async (req: Request, res: Response) => { + /* + #swagger.summary = 'Get all accessible environments of a workspace' + #swagger.description = 'Fetch all environments that the user has access to in a specified workspace' + + #swagger.security = [{ + "apiKeyAuth": [] + }] -export const getAllAccessibleEnvironmentsOfWorkspace = async ( - req: Request, - res: Response -) => { - const { workspaceId } = req.params; - const workspacesUserIsMemberOf = await Membership.findOne({ - workspace: workspaceId, - user: req.user, - }) + #swagger.parameters['workspaceId'] = { + "description": "ID of the workspace", + "required": true, + "type": "string", + "in": "path" + } - if (!workspacesUserIsMemberOf) { - throw BadRequestError() - } + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accessibleEnvironments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Development" + }, + "slug": { + "type": "string", + "example": "development" + }, + "isWriteDenied": { + "type": "boolean", + "example": false + }, + "isReadDenied": { + "type": "boolean", + "example": false + } + } + } + } + }, + "description": "List of environments the user has access to in the specified workspace" + } + } + } + } + */ + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetAllAccessibileEnvironmentsOfWorkspaceV2, req); - const accessibleEnvironments: any = [] - const deniedPermission = workspacesUserIsMemberOf.deniedPermissions + const { membership: workspacesUserIsMemberOf } = await getUserProjectPermissions( + req.user._id, + workspaceId + ); - const relatedWorkspace = await Workspace.findById(workspaceId) + const accessibleEnvironments: any = []; + const deniedPermission = workspacesUserIsMemberOf.deniedPermissions; + + const relatedWorkspace = await Workspace.findById(workspaceId); if (!relatedWorkspace) { - throw BadRequestError() + throw BadRequestError(); } - relatedWorkspace.environments.forEach(environment => { - const isReadBlocked = _.some(deniedPermission, { environmentSlug: environment.slug, ability: PERMISSION_READ_SECRETS }) - const isWriteBlocked = _.some(deniedPermission, { environmentSlug: environment.slug, ability: PERMISSION_WRITE_SECRETS }) + relatedWorkspace.environments.forEach((environment) => { + const isReadBlocked = _.some(deniedPermission, { + environmentSlug: environment.slug, + ability: PERMISSION_READ_SECRETS + }); + const isWriteBlocked = _.some(deniedPermission, { + environmentSlug: environment.slug, + ability: PERMISSION_WRITE_SECRETS + }); if (isReadBlocked && isWriteBlocked) { - return + return; } else { accessibleEnvironments.push({ name: environment.name, slug: environment.slug, isWriteDenied: isWriteBlocked, - isReadDenied: isReadBlocked, - }) + isReadDenied: isReadBlocked + }); } - }) + }); - res.json({ accessibleEnvironments }) + res.json({ accessibleEnvironments }); }; diff --git a/backend/src/controllers/v2/organizationsController.ts b/backend/src/controllers/v2/organizationsController.ts index 301cac9d0..ad776864c 100644 --- a/backend/src/controllers/v2/organizationsController.ts +++ b/backend/src/controllers/v2/organizationsController.ts @@ -1,21 +1,27 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; -import { - Membership, - MembershipOrg, - ServiceAccount, - Workspace, -} from "../../models"; +import { Membership, MembershipOrg, ServiceAccount, Workspace } from "../../models"; import { deleteMembershipOrg } from "../../helpers/membershipOrg"; import { updateSubscriptionOrgQuantity } from "../../helpers/organization"; +import Role from "../../ee/models/role"; +import { BadRequestError } from "../../utils/errors"; +import { CUSTOM } from "../../variables"; +import * as reqValidator from "../../validation/organization"; +import { validateRequest } from "../../helpers/validation"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + getUserOrgPermissions +} from "../../ee/services/RoleService"; +import { ForbiddenError } from "@casl/ability"; /** * Return memberships for organization with id [organizationId] - * @param req - * @param res + * @param req + * @param res */ export const getOrganizationMemberships = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Return organization memberships' #swagger.description = 'Return organization memberships' @@ -48,24 +54,32 @@ export const getOrganizationMemberships = async (req: Request, res: Response) => } } */ - const { organizationId } = req.params; + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgMembersv2, req); - const memberships = await MembershipOrg.find({ - organization: organizationId, - }).populate("user", "+publicKey"); - - return res.status(200).send({ - memberships, - }); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Member + ); + + const memberships = await MembershipOrg.find({ + organization: organizationId + }).populate("user", "+publicKey"); + + return res.status(200).send({ + memberships + }); +}; /** * Update role of membership with id [membershipId] to role [role] - * @param req - * @param res + * @param req + * @param res */ export const updateOrganizationMembership = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Update organization membership' #swagger.description = 'Update organization membership' @@ -118,31 +132,58 @@ export const updateOrganizationMembership = async (req: Request, res: Response) } } */ - const { membershipId } = req.params; - const { role } = req.body; - - const membership = await MembershipOrg.findByIdAndUpdate( - membershipId, - { - role, - }, { - new: true, - } - ); - - return res.status(200).send({ - membership, + const { + params: { organizationId, membershipId }, + body: { role } + } = await validateRequest(reqValidator.UpdateOrgMemberv2, req); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Member + ); + + const isCustomRole = !["admin", "member", "owner"].includes(role); + if (isCustomRole) { + const orgRole = await Role.findOne({ slug: role, isOrgRole: true }); + if (!orgRole) throw BadRequestError({ message: "Role not found" }); + + const membership = await MembershipOrg.findByIdAndUpdate(membershipId, { + role: CUSTOM, + customRole: orgRole }); -} + return res.status(200).send({ + membership + }); + } + + const membership = await MembershipOrg.findByIdAndUpdate( + membershipId, + { + $set: { + role + }, + $unset: { + customRole: 1 + } + }, + { + new: true + } + ); + + return res.status(200).send({ + membership + }); +}; /** * Delete organization membership with id [membershipId] - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const deleteOrganizationMembership = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Delete organization membership' #swagger.description = 'Delete organization membership' @@ -178,30 +219,37 @@ export const deleteOrganizationMembership = async (req: Request, res: Response) } } */ - const { membershipId } = req.params; - - // delete organization membership - const membership = await deleteMembershipOrg({ - membershipOrgId: membershipId, - }); + const { + params: { organizationId, membershipId } + } = await validateRequest(reqValidator.DeleteOrgMemberv2, req); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.Member + ); - await updateSubscriptionOrgQuantity({ - organizationId: membership.organization.toString(), - }); + // delete organization membership + const membership = await deleteMembershipOrg({ + membershipOrgId: membershipId + }); - return res.status(200).send({ - membership, - }); -} + await updateSubscriptionOrgQuantity({ + organizationId: membership.organization.toString() + }); + + return res.status(200).send({ + membership + }); +}; /** * Return workspaces for organization with id [organizationId] that user has * access to - * @param req - * @param res + * @param req + * @param res */ export const getOrganizationWorkspaces = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Return projects in organization that user is part of' #swagger.description = 'Return projects in organization that user is part of' @@ -234,45 +282,53 @@ export const getOrganizationWorkspaces = async (req: Request, res: Response) => } } */ - const { organizationId } = req.params; + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgWorkspacesv2, req); - const workspacesSet = new Set( - ( - await Workspace.find( - { - organization: organizationId, - }, - "_id" - ) - ).map((w) => w._id.toString()) - ); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Workspace + ); - const workspaces = ( - await Membership.find({ - user: req.user._id, - }).populate("workspace") - ) + const workspacesSet = new Set( + ( + await Workspace.find( + { + organization: organizationId + }, + "_id" + ) + ).map((w) => w._id.toString()) + ); + + const workspaces = ( + await Membership.find({ + user: req.user._id + }).populate("workspace") + ) .filter((m) => workspacesSet.has(m.workspace._id.toString())) .map((m) => m.workspace); -return res.status(200).send({ - workspaces, - }); -} + return res.status(200).send({ + workspaces + }); +}; /** * Return service accounts for organization with id [organizationId] - * @param req - * @param res + * @param req + * @param res */ export const getOrganizationServiceAccounts = async (req: Request, res: Response) => { - const { organizationId } = req.params; - - const serviceAccounts = await ServiceAccount.find({ - organization: new Types.ObjectId(organizationId), - }); - - return res.status(200).send({ - serviceAccounts, - }); -} + const { organizationId } = req.params; + + const serviceAccounts = await ServiceAccount.find({ + organization: new Types.ObjectId(organizationId) + }); + + return res.status(200).send({ + serviceAccounts + }); +}; diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 6c9b29203..91319de05 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -25,7 +25,6 @@ import { userHasWriteOnlyAbility } from "../../ee/helpers/checkMembershipPermissions"; import _ from "lodash"; -import { BatchSecret, BatchSecretRequest } from "../../types/secret"; import { getFolderByPath, getFolderIdFromServiceToken, @@ -35,6 +34,18 @@ import { import { isValidScope } from "../../helpers/secrets"; import path from "path"; import { getAllImportedSecrets } from "../../services/SecretImportService"; +import { validateRequest } from "../../helpers/validation"; +import { + BatchSecretsV2, + GetSecretsV2, + validateServiceTokenDataClientForWorkspace +} from "../../validation"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { ForbiddenError, subject } from "@casl/ability"; /** * Peform a batch of any specified CUD secret operations @@ -46,22 +57,31 @@ export const batchSecrets = async (req: Request, res: Response) => { const channel = getUserAgentType(req.headers["user-agent"]); const postHogClient = await TelemetryService.getPostHogClient(); + const validatedData = await validateRequest(BatchSecretsV2, req); const { - workspaceId, - environment, - requests - }: { - workspaceId: string; - environment: string; - requests: BatchSecretRequest[]; - } = req.body; + body: { workspaceId, environment, requests } + } = validatedData; + let { + body: { secretPath, folderId } + } = validatedData; - let secretPath = req.body.secretPath as string; - let folderId = req.body.folderId as string; + const secretIds = requests + .filter(({ method }) => method !== "POST") + // akhilmhdh: ts is dumb + .map((el) => new Types.ObjectId((el.secret as any)._id)); - const createSecrets: BatchSecret[] = []; - const updateSecrets: BatchSecret[] = []; - const deleteSecrets: { _id: Types.ObjectId, secretName: string; }[] = []; + const oldSecrets = await Secret.find({ + _id: { + $in: secretIds + } + }); + if (oldSecrets.length != secretIds.length) { + throw BadRequestError({ message: "Failed to validate non-existent secrets" }); + } + + const createSecrets: any[] = []; + const updateSecrets: any[] = []; + const deleteSecrets: { _id: Types.ObjectId; secretName: string }[] = []; const actions: IAction[] = []; // get secret blind index salt @@ -69,31 +89,33 @@ export const batchSecrets = async (req: Request, res: Response) => { workspaceId: new Types.ObjectId(workspaceId) }); - const folders = await Folder.findOne({ workspace: workspaceId, environment }); - - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); - - // in service token when not giving secretpath folderid must be root - // this is to avoid giving folderid when service tokens are used - if ((!secretPath && folderId !== "root") || (secretPath && !isValidScopeAccess)) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - - if (secretPath) { + if (secretPath !== "/") { folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); } - if (folders && folderId !== "root") { + if (folderId !== "root") { + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders) throw BadRequestError({ message: "Folder not found" }); + const folder = searchByFolderIdWithDir(folders.nodes, folderId as string); if (!folder?.folder) throw BadRequestError({ message: "Folder not found" }); + secretPath = path.join( "/", ...folder.dir.map(({ name }) => name).filter((name) => name !== "root") ); } + if (req.authData.authPayload instanceof ServiceTokenData) { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } + for await (const request of requests) { // do a validation @@ -110,7 +132,7 @@ export const batchSecrets = async (req: Request, res: Response) => { version: 1, user: request.secret.type === SECRET_PERSONAL ? req.user : undefined, environment, - workspace: new Types.ObjectId(workspaceId), + workspace: workspaceId, folder: folderId, secretBlindIndex, algorithm: ALGORITHM_AES_256_GCM, @@ -125,7 +147,7 @@ export const batchSecrets = async (req: Request, res: Response) => { updateSecrets.push({ ...request.secret, - _id: new Types.ObjectId(request.secret._id), + _id: request.secret._id, secretBlindIndex, folder: folderId, algorithm: ALGORITHM_AES_256_GCM, @@ -133,15 +155,39 @@ export const batchSecrets = async (req: Request, res: Response) => { }); break; case "DELETE": - deleteSecrets.push({ _id: new Types.ObjectId(request.secret._id), secretName: request.secret.secretName }); + deleteSecrets.push({ + _id: new Types.ObjectId(request.secret._id), + secretName: request.secret.secretName + }); break; } } + // not using service token using auth + if (!(req.authData.authPayload instanceof ServiceTokenData)) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + if (createSecrets.length) + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + if (updateSecrets.length) + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + if (deleteSecrets.length) + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } // handle create secrets let createdSecrets: ISecret[] = []; if (createSecrets.length > 0) { - createdSecrets = await Secret.insertMany(createSecrets); + createdSecrets = (await Secret.insertMany(createSecrets)) as any; // (EE) add secret versions for new secrets await EESecretService.addSecretVersions({ secretVersions: createdSecrets.map((n: any) => { @@ -206,7 +252,7 @@ export const batchSecrets = async (req: Request, res: Response) => { // handle update secrets let updatedSecrets: ISecret[] = []; - if (updateSecrets.length > 0 && req.secrets) { + if (updateSecrets.length > 0 && oldSecrets) { // construct object containing all secrets let listedSecretsObj: { [key: string]: { @@ -215,7 +261,7 @@ export const batchSecrets = async (req: Request, res: Response) => { }; } = {}; - listedSecretsObj = req.secrets.reduce( + listedSecretsObj = oldSecrets.reduce( (obj: any, secret: ISecret) => ({ ...obj, [secret._id.toString()]: secret @@ -227,7 +273,8 @@ export const batchSecrets = async (req: Request, res: Response) => { updateOne: { filter: { _id: new Types.ObjectId(u._id), - workspace: new Types.ObjectId(workspaceId) + workspace: new Types.ObjectId(workspaceId), + environment }, update: { $inc: { @@ -241,7 +288,6 @@ export const batchSecrets = async (req: Request, res: Response) => { } } })); - await Secret.bulkWrite(updateOperations); const secretVersions = updateSecrets.map( @@ -332,23 +378,26 @@ export const batchSecrets = async (req: Request, res: Response) => { if (deleteSecrets.length > 0) { const deleteSecretIds: Types.ObjectId[] = deleteSecrets.map((s) => s._id); - const deletedSecretsObj = (await Secret.find({ - _id: { - $in: deleteSecretIds - } - })) - .reduce( - (obj: any, secret: ISecret) => ({ - ...obj, - [secret._id.toString()]: secret - }), - {} - ); + const deletedSecretsObj = ( + await Secret.find({ + _id: { + $in: deleteSecretIds + } + }) + ).reduce( + (obj: any, secret: ISecret) => ({ + ...obj, + [secret._id.toString()]: secret + }), + {} + ); await Secret.deleteMany({ _id: { $in: deleteSecretIds - } + }, + workspace: new Types.ObjectId(workspaceId), + environment }); await EESecretService.markDeletedSecretVersions({ @@ -781,10 +830,13 @@ export const getSecrets = async (req: Request, res: Response) => { } */ - const { tagSlugs, secretPath, include_imports } = req.query; - let { folderId } = req.query; - const workspaceId = req.query.workspaceId as string; - const environment = req.query.environment as string; + const validatedData = await validateRequest(GetSecretsV2, req); + const { + query: { tagSlugs, secretPath, include_imports, workspaceId, environment } + } = validatedData; + let { + query: { folderId } + } = validatedData; const folders = await Folder.findOne({ workspace: workspaceId, environment }); @@ -926,8 +978,14 @@ export const getSecrets = async (req: Request, res: Response) => { // TODO(akhilmhdh) - secret-imp change this to org type let importedSecrets: any[] = []; - if (include_imports === "true") { - importedSecrets = await getAllImportedSecrets(workspaceId, environment, folderId as string); + if (include_imports) { + // depreciated + importedSecrets = await getAllImportedSecrets( + workspaceId, + environment, + folderId as string, + () => false + ); } const channel = getUserAgentType(req.headers["user-agent"]); @@ -970,17 +1028,17 @@ export const getSecrets = async (req: Request, res: Response) => { const postHogClient = await TelemetryService.getPostHogClient(); // reduce the number of events captured - let shouldRecordK8Event = false + let shouldRecordK8Event = false; if (req.authData.userAgent == K8_USER_AGENT_NAME) { const randomNumber = Math.random(); if (randomNumber > 0.9) { - shouldRecordK8Event = true + shouldRecordK8Event = true; } } if (postHogClient) { const shouldCapture = req.authData.userAgent !== K8_USER_AGENT_NAME || shouldRecordK8Event; - const approximateForNoneCapturedEvents = secrets.length * 10 + const approximateForNoneCapturedEvents = secrets.length * 10; if (shouldCapture) { postHogClient.capture({ @@ -1104,10 +1162,10 @@ export const updateSecrets = async (req: Request, res: Response) => { tags, ...(secretCommentCiphertext !== undefined && secretCommentIV && secretCommentTag ? { - secretCommentCiphertext, - secretCommentIV, - secretCommentTag - } + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + } : {}) } } diff --git a/backend/src/controllers/v2/serviceTokenDataController.ts b/backend/src/controllers/v2/serviceTokenDataController.ts index c918e6953..b8f1b8f43 100644 --- a/backend/src/controllers/v2/serviceTokenDataController.ts +++ b/backend/src/controllers/v2/serviceTokenDataController.ts @@ -6,6 +6,15 @@ import { getSaltRounds } from "../../config"; import { BadRequestError } from "../../utils/errors"; import { ActorType, EventType } from "../../ee/models"; import { EEAuditLogService } from "../../ee/services"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/serviceTokenData"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { ForbiddenError } from "@casl/ability"; +import { Types } from "mongoose"; /** * Return service token data associated with service token on request @@ -63,7 +72,14 @@ export const getServiceTokenData = async (req: Request, res: Response) => { export const createServiceTokenData = async (req: Request, res: Response) => { let serviceTokenData; - const { name, workspaceId, encryptedKey, iv, tag, expiresIn, permissions, scopes } = req.body; + const { + body: { workspaceId, permissions, tag, encryptedKey, scopes, name, expiresIn, iv } + } = await validateRequest(reqValidator.CreateServiceTokenV2, req); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.ServiceTokens + ); const secret = crypto.randomBytes(16).toString("hex"); const secretHash = await bcrypt.hash(secret, await getSaltRounds()); @@ -75,7 +91,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => { } let user; - + if (req.authData.actor.type === ActorType.USER) { user = req.authData.authPayload._id; } @@ -100,7 +116,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => { if (!serviceTokenData) throw new Error("Failed to find service token data"); const serviceToken = `st.${serviceTokenData._id.toString()}.${secret}`; - + await EEAuditLogService.createAuditLog( req.authData, { @@ -111,7 +127,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => { } }, { - workspaceId + workspaceId: new Types.ObjectId(workspaceId) } ); @@ -128,14 +144,29 @@ export const createServiceTokenData = async (req: Request, res: Response) => { * @returns */ export const deleteServiceTokenData = async (req: Request, res: Response) => { - const { serviceTokenDataId } = req.params; + const { + params: { serviceTokenDataId } + } = await validateRequest(reqValidator.DeleteServiceTokenV2, req); + + let serviceTokenData = await ServiceTokenData.findById(serviceTokenDataId); + if (!serviceTokenData) throw BadRequestError({ message: "Service token not found" }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + serviceTokenData.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.ServiceTokens + ); + + serviceTokenData = await ServiceTokenData.findByIdAndDelete(serviceTokenDataId); + + if (!serviceTokenData) + return res.status(200).send({ + message: "Failed to delete service token" + }); - const serviceTokenData = await ServiceTokenData.findByIdAndDelete(serviceTokenDataId); - - if (!serviceTokenData) return res.status(200).send({ - message: "Failed to delete service token" - }); - await EEAuditLogService.createAuditLog( req.authData, { diff --git a/backend/src/controllers/v2/tagController.ts b/backend/src/controllers/v2/tagController.ts index 4a7e68bb7..668903222 100644 --- a/backend/src/controllers/v2/tagController.ts +++ b/backend/src/controllers/v2/tagController.ts @@ -1,42 +1,58 @@ +import { ForbiddenError } from "@casl/ability"; import { Request, Response } from "express"; import { Types } from "mongoose"; -import { Membership, Secret, Tag } from "../../models"; -import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; +import { Secret, Tag } from "../../models"; +import { BadRequestError } from "../../utils/errors"; +import { validateRequest } from "../../helpers/validation"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import * as reqValidator from "../../validation/tags"; export const createWorkspaceTag = async (req: Request, res: Response) => { - const { workspaceId } = req.params; - const { name, slug, tagColor } = req.body; - + const { + body: { name, slug }, + params: { workspaceId } + } = await validateRequest(reqValidator.CreateWorkspaceTagsV2, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Tags + ); + const tagToCreate = { - name, - tagColor, - workspace: new Types.ObjectId(workspaceId), - slug, - user: new Types.ObjectId(req.user._id), - }; - + name, + workspace: new Types.ObjectId(workspaceId), + slug, + user: new Types.ObjectId(req.user._id) + }; + const createdTag = await new Tag(tagToCreate).save(); - + res.json(createdTag); }; export const deleteWorkspaceTag = async (req: Request, res: Response) => { - const { tagId } = req.params; + const { + params: { tagId } + } = await validateRequest(reqValidator.DeleteWorkspaceTagsV2, req); const tagFromDB = await Tag.findById(tagId); if (!tagFromDB) { throw BadRequestError(); } - // can only delete if the request user is one that belongs to the same workspace as the tag - const membership = await Membership.findOne({ - user: req.user, - workspace: tagFromDB.workspace - }); - - if (!membership) { - UnauthorizedRequestError({ message: "Failed to validate membership" }); - } + const { permission } = await getUserProjectPermissions( + req.user._id, + tagFromDB.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Tags + ); const result = await Tag.findByIdAndDelete(tagId); @@ -47,12 +63,19 @@ export const deleteWorkspaceTag = async (req: Request, res: Response) => { }; export const getWorkspaceTags = async (req: Request, res: Response) => { - const { workspaceId } = req.params; - - const workspaceTags = await Tag.find({ - workspace: new Types.ObjectId(workspaceId) + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceTagsV2, req); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Tags + ); + + const workspaceTags = await Tag.find({ + workspace: new Types.ObjectId(workspaceId) }); - + return res.json({ workspaceTags }); diff --git a/backend/src/controllers/v2/usersController.ts b/backend/src/controllers/v2/usersController.ts index 9e23ab8ac..0b859f749 100644 --- a/backend/src/controllers/v2/usersController.ts +++ b/backend/src/controllers/v2/usersController.ts @@ -2,23 +2,19 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import crypto from "crypto"; import bcrypt from "bcrypt"; -import { - APIKeyData, - AuthMethod, - MembershipOrg, - TokenVersion, - User -} from "../../models"; +import { APIKeyData, AuthMethod, MembershipOrg, TokenVersion, User } from "../../models"; import { getSaltRounds } from "../../config"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation"; /** * Return the current user. - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getMe = async (req: Request, res: Response) => { - /* + /* #swagger.summary = "Retrieve the current user on the request" #swagger.description = "Retrieve the current user on the request" @@ -43,124 +39,117 @@ export const getMe = async (req: Request, res: Response) => { } } */ - const user = await User - .findById(req.user._id) - .select("+salt +publicKey +encryptedPrivateKey +iv +tag +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag"); - - return res.status(200).send({ - user, - }); -} + const user = await User.findById(req.user._id).select( + "+salt +publicKey +encryptedPrivateKey +iv +tag +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag" + ); + + return res.status(200).send({ + user + }); +}; /** * Update the current user's MFA-enabled status [isMfaEnabled]. * Note: Infisical currently only supports email-based 2FA only; this will expand to * include SMS and authenticator app modes of authentication in the future. - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const updateMyMfaEnabled = async (req: Request, res: Response) => { - const { isMfaEnabled }: { isMfaEnabled: boolean } = req.body; - req.user.isMfaEnabled = isMfaEnabled; - - if (isMfaEnabled) { - // TODO: adapt this route/controller - // to work for different forms of MFA - req.user.mfaMethods = ["email"]; - } else { - req.user.mfaMethods = []; - } + const { + body: { isMfaEnabled } + } = await validateRequest(reqValidator.UpdateMyMfaEnabledV2, req); - await req.user.save(); - - const user = req.user; - - return res.status(200).send({ - user, - }); -} + req.user.isMfaEnabled = isMfaEnabled; + + if (isMfaEnabled) { + // TODO: adapt this route/controller + // to work for different forms of MFA + req.user.mfaMethods = ["email"]; + } else { + req.user.mfaMethods = []; + } + + await req.user.save(); + + const user = req.user; + + return res.status(200).send({ + user + }); +}; /** * Update name of the current user to [firstName, lastName]. - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const updateName = async (req: Request, res: Response) => { - const { - firstName, - lastName - }: { - firstName: string; - lastName: string; - } = req.body; - - const user = await User.findByIdAndUpdate( - req.user._id.toString(), - { - firstName, - lastName: lastName ?? "" - }, - { - new: true - } - ); - - return res.status(200).send({ - user, - }); -} + const { + body: { lastName, firstName } + } = await validateRequest(reqValidator.UpdateNameV2, req); + + const user = await User.findByIdAndUpdate( + req.user._id.toString(), + { + firstName, + lastName: lastName ?? "" + }, + { + new: true + } + ); + + return res.status(200).send({ + user + }); +}; /** * Update auth method of the current user to [authMethods] - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ - export const updateAuthMethods = async (req: Request, res: Response) => { - const { - authMethods - } = req.body; - - const hasSamlEnabled = req.user.authMethods - .some( - (authMethod: AuthMethod) => [ - AuthMethod.OKTA_SAML, - AuthMethod.AZURE_SAML, - AuthMethod.JUMPCLOUD_SAML - ].includes(authMethod) - ); +export const updateAuthMethods = async (req: Request, res: Response) => { + const { + body: { authMethods } + } = await validateRequest(reqValidator.UpdateAuthMethodsV2, req); - if (hasSamlEnabled) { - return res.status(400).send({ - message: "Failed to update user authentication method because SAML SSO is enforced" - }); - } + const hasSamlEnabled = req.user.authMethods.some((authMethod: AuthMethod) => + [AuthMethod.OKTA_SAML, AuthMethod.AZURE_SAML, AuthMethod.JUMPCLOUD_SAML].includes(authMethod) + ); - const user = await User.findByIdAndUpdate( - req.user._id.toString(), - { - authMethods - }, - { - new: true - } - ); - - return res.status(200).send({ - user + if (hasSamlEnabled) { + return res.status(400).send({ + message: "Failed to update user authentication method because SAML SSO is enforced" }); -} + } + const user = await User.findByIdAndUpdate( + req.user._id.toString(), + { + authMethods + }, + { + new: true + } + ); + + return res.status(200).send({ + user + }); +}; /** * Return organizations that the current user is part of. - * @param req - * @param res + * @param req + * @param res */ export const getMyOrganizations = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Return organizations that current user is part of' #swagger.description = 'Return organizations that current user is part of' @@ -189,114 +178,121 @@ export const getMyOrganizations = async (req: Request, res: Response) => { */ const organizations = ( await MembershipOrg.find({ - user: req.user._id, + user: req.user._id }).populate("organization") ).map((m) => m.organization); - return res.status(200).send({ - organizations, - }); -} + return res.status(200).send({ + organizations + }); +}; /** * Return API keys belonging to current user. - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getMyAPIKeys = async (req: Request, res: Response) => { - const apiKeyData = await APIKeyData.find({ - user: req.user._id, - }); + const apiKeyData = await APIKeyData.find({ + user: req.user._id + }); - return res.status(200).send(apiKeyData); -} + return res.status(200).send(apiKeyData); +}; /** * Create new API key for current user. - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const createAPIKey = async (req: Request, res: Response) => { - const { name, expiresIn } = req.body; + const { + body: { name, expiresIn } + } = await validateRequest(reqValidator.CreateApiKeyV2, req); - const secret = crypto.randomBytes(16).toString("hex"); - const secretHash = await bcrypt.hash(secret, await getSaltRounds()); + const secret = crypto.randomBytes(16).toString("hex"); + const secretHash = await bcrypt.hash(secret, await getSaltRounds()); - const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); + const expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); - let apiKeyData = await new APIKeyData({ - name, - lastUsed: new Date(), - expiresAt, - user: req.user._id, - secretHash, - }).save(); + let apiKeyData = await new APIKeyData({ + name, + lastUsed: new Date(), + expiresAt, + user: req.user._id, + secretHash + }).save(); - // return api key data without sensitive data - apiKeyData = (await APIKeyData.findById(apiKeyData._id)) as any; + // return api key data without sensitive data + apiKeyData = (await APIKeyData.findById(apiKeyData._id)) as any; - if (!apiKeyData) throw new Error("Failed to find API key data"); + if (!apiKeyData) throw new Error("Failed to find API key data"); - const apiKey = `ak.${apiKeyData._id.toString()}.${secret}`; + const apiKey = `ak.${apiKeyData._id.toString()}.${secret}`; - return res.status(200).send({ - apiKey, - apiKeyData, - }); -} + return res.status(200).send({ + apiKey, + apiKeyData + }); +}; /** * Delete API key with id [apiKeyDataId] belonging to current user - * @param req - * @param res + * @param req + * @param res */ export const deleteAPIKey = async (req: Request, res: Response) => { - const { apiKeyDataId } = req.params; + const { + params: { apiKeyDataId } + } = await validateRequest(reqValidator.DeleteApiKeyV2, req); - const apiKeyData = await APIKeyData.findOneAndDelete({ - _id: new Types.ObjectId(apiKeyDataId), - user: req.user._id - }); + const apiKeyData = await APIKeyData.findOneAndDelete({ + _id: new Types.ObjectId(apiKeyDataId), + user: req.user._id + }); - return res.status(200).send({ - apiKeyData - }); -} + return res.status(200).send({ + apiKeyData + }); +}; /** * Return active sessions (TokenVersion) belonging to user - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getMySessions = async (req: Request, res: Response) => { - const tokenVersions = await TokenVersion.find({ - user: req.user._id - }); - - return res.status(200).send(tokenVersions); -} + const tokenVersions = await TokenVersion.find({ + user: req.user._id + }); + + return res.status(200).send(tokenVersions); +}; /** * Revoke all active sessions belong to user - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const deleteMySessions = async (req: Request, res: Response) => { - await TokenVersion.updateMany({ - user: req.user._id, - }, { - $inc: { - refreshVersion: 1, - accessVersion: 1, - }, - }); + await TokenVersion.updateMany( + { + user: req.user._id + }, + { + $inc: { + refreshVersion: 1, + accessVersion: 1 + } + } + ); - return res.status(200).send({ - message: "Successfully revoked all sessions" - }); -} \ No newline at end of file + return res.status(200).send({ + message: "Successfully revoked all sessions" + }); +}; diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index f057298be..040c68f31 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -11,6 +11,14 @@ import { EventService, TelemetryService } from "../../services"; import { eventPushSecrets } from "../../events"; import { EEAuditLogService } from "../../ee/services"; import { EventType } from "../../ee/models"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { ForbiddenError } from "@casl/ability"; interface V2PushSecret { type: string; // personal or shared @@ -181,15 +189,17 @@ export const getWorkspaceKey = async (req: Request, res: Response) => { } } */ - const { workspaceId } = req.params; - + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceKeyV2, req); + const key = await Key.findOne({ workspace: workspaceId, receiver: req.user._id }).populate("sender", "+publicKey"); if (!key) throw new Error("Failed to find workspace key"); - + await EEAuditLogService.createAuditLog( req.authData, { @@ -258,7 +268,15 @@ export const getWorkspaceMemberships = async (req: Request, res: Response) => { } } */ - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceMembershipsV2, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Member + ); const memberships = await Membership.find({ workspace: workspaceId @@ -329,8 +347,16 @@ export const updateWorkspaceMembership = async (req: Request, res: Response) => } } */ - const { membershipId } = req.params; - const { role } = req.body; + const { + params: { workspaceId, membershipId }, + body: { role } + } = await validateRequest(reqValidator.UpdateWorkspaceMembershipsV2, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); const membership = await Membership.findByIdAndUpdate( membershipId, @@ -390,7 +416,15 @@ export const deleteWorkspaceMembership = async (req: Request, res: Response) => } } */ - const { membershipId } = req.params; + const { + params: { workspaceId, membershipId } + } = await validateRequest(reqValidator.DeleteWorkspaceMembershipsV2, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Member + ); const membership = await Membership.findByIdAndDelete(membershipId); @@ -413,8 +447,16 @@ export const deleteWorkspaceMembership = async (req: Request, res: Response) => * @returns */ export const toggleAutoCapitalization = async (req: Request, res: Response) => { - const { workspaceId } = req.params; - const { autoCapitalization } = req.body; + const { + params: { workspaceId }, + body: { autoCapitalization } + } = await validateRequest(reqValidator.ToggleAutoCapitalizationV2, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Settings + ); const workspace = await Workspace.findOneAndUpdate( { diff --git a/backend/src/controllers/v3/authController.ts b/backend/src/controllers/v3/authController.ts index 413bcefa0..1b228a39b 100644 --- a/backend/src/controllers/v3/authController.ts +++ b/backend/src/controllers/v3/authController.ts @@ -10,25 +10,20 @@ import { sendMail } from "../../helpers/nodemailer"; import { TokenService } from "../../services"; import { EELogService } from "../../ee/services"; import { BadRequestError, InternalServerError } from "../../utils/errors"; -import { - ACTION_LOGIN, - TOKEN_EMAIL_MFA, -} from "../../variables"; +import { ACTION_LOGIN, TOKEN_EMAIL_MFA } from "../../variables"; import { getUserAgentType } from "../../utils/posthog"; // TODO: move this -import { - getHttpsEnabled, - getJwtMfaLifetime, - getJwtMfaSecret, -} from "../../config"; +import { getHttpsEnabled, getJwtMfaLifetime, getJwtMfaSecret } from "../../config"; import { AuthMethod } from "../../models/user"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/auth"; declare module "jsonwebtoken" { - export interface ProviderAuthJwtPayload extends jwt.JwtPayload { - userId: string; - email: string; - authProvider: AuthMethod; - isUserCompleted: boolean, - } + export interface ProviderAuthJwtPayload extends jwt.JwtPayload { + userId: string; + email: string; + authProvider: AuthMethod; + isUserCompleted: boolean; + } } /** @@ -38,53 +33,51 @@ declare module "jsonwebtoken" { * @returns */ export const login1 = async (req: Request, res: Response) => { - const { - email, - providerAuthToken, - clientPublicKey, - }: { - email: string; - clientPublicKey: string, - providerAuthToken?: string; - } = req.body; - - const user = await User.findOne({ - email, - }).select("+salt +verifier"); + const { + body: { email, clientPublicKey, providerAuthToken } + } = await validateRequest(reqValidator.Login1V3, req); - if (!user) throw new Error("Failed to find user"); - - if (!user.authMethods.includes(AuthMethod.EMAIL)) { - await validateProviderAuthToken({ - email, - providerAuthToken, - }); - } + const user = await User.findOne({ + email + }).select("+salt +verifier"); - const server = new jsrp.server(); - server.init( + if (!user) throw new Error("Failed to find user"); + + if (!user.authMethods.includes(AuthMethod.EMAIL)) { + await validateProviderAuthToken({ + email, + providerAuthToken + }); + } + + const server = new jsrp.server(); + server.init( + { + salt: user.salt, + verifier: user.verifier + }, + async () => { + // generate server-side public key + const serverPublicKey = server.getPublicKey(); + await LoginSRPDetail.findOneAndReplace( { - salt: user.salt, - verifier: user.verifier, + email: email }, - async () => { - // generate server-side public key - const serverPublicKey = server.getPublicKey(); - await LoginSRPDetail.findOneAndReplace({ - email: email, - }, { - email, - userId: user.id, - clientPublicKey: clientPublicKey, - serverBInt: bigintConversion.bigintToBuf(server.bInt), - }, { upsert: true, returnNewDocument: false }); + { + email, + userId: user.id, + clientPublicKey: clientPublicKey, + serverBInt: bigintConversion.bigintToBuf(server.bInt) + }, + { upsert: true, returnNewDocument: false } + ); - return res.status(200).send({ - serverPublicKey, - salt: user.salt, - }); - } - ); + return res.status(200).send({ + serverPublicKey, + salt: user.salt + }); + } + ); }; /** @@ -95,150 +88,151 @@ export const login1 = async (req: Request, res: Response) => { * @returns */ export const login2 = async (req: Request, res: Response) => { - if (!req.headers["user-agent"]) throw InternalServerError({ message: "User-Agent header is required" }); + if (!req.headers["user-agent"]) + throw InternalServerError({ message: "User-Agent header is required" }); - const { email, clientProof, providerAuthToken } = req.body; + const { + body: { email, providerAuthToken, clientProof } + } = await validateRequest(reqValidator.Login2V3, req); - const user = await User.findOne({ - email, - }).select("+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag +devices"); + const user = await User.findOne({ + email + }).select( + "+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag +devices" + ); - if (!user) throw new Error("Failed to find user"); - - if (!user.authMethods.includes(AuthMethod.EMAIL)) { - await validateProviderAuthToken({ - email, - providerAuthToken, - }) - } + if (!user) throw new Error("Failed to find user"); - const loginSRPDetail = await LoginSRPDetail.findOneAndDelete({ email: email }) + if (!user.authMethods.includes(AuthMethod.EMAIL)) { + await validateProviderAuthToken({ + email, + providerAuthToken + }); + } - if (!loginSRPDetail) { - return BadRequestError(Error("Failed to find login details for SRP")) - } + const loginSRPDetail = await LoginSRPDetail.findOneAndDelete({ email: email }); - const server = new jsrp.server(); - server.init( - { - salt: user.salt, - verifier: user.verifier, - b: loginSRPDetail.serverBInt, - }, - async () => { - server.setClientPublicKey(loginSRPDetail.clientPublicKey); + if (!loginSRPDetail) { + return BadRequestError(Error("Failed to find login details for SRP")); + } - // compare server and client shared keys - if (server.checkClientProof(clientProof)) { + const server = new jsrp.server(); + server.init( + { + salt: user.salt, + verifier: user.verifier, + b: loginSRPDetail.serverBInt + }, + async () => { + server.setClientPublicKey(loginSRPDetail.clientPublicKey); - if (user.isMfaEnabled) { - // case: user has MFA enabled + // compare server and client shared keys + if (server.checkClientProof(clientProof)) { + if (user.isMfaEnabled) { + // case: user has MFA enabled - // generate temporary MFA token - const token = createToken({ - payload: { - userId: user._id.toString(), - }, - expiresIn: await getJwtMfaLifetime(), - secret: await getJwtMfaSecret(), - }); + // generate temporary MFA token + const token = createToken({ + payload: { + userId: user._id.toString() + }, + expiresIn: await getJwtMfaLifetime(), + secret: await getJwtMfaSecret() + }); - const code = await TokenService.createToken({ - type: TOKEN_EMAIL_MFA, - email, - }); + const code = await TokenService.createToken({ + type: TOKEN_EMAIL_MFA, + email + }); - // send MFA code [code] to [email] - await sendMail({ - template: "emailMfa.handlebars", - subjectLine: "Infisical MFA code", - recipients: [user.email], - substitutions: { - code, - }, - }); - - return res.status(200).send({ - mfaEnabled: true, - token, - }); - } - - await checkUserDevice({ - user, - ip: req.realIP, - userAgent: req.headers["user-agent"] ?? "", - }); - - // issue tokens - const tokens = await issueAuthTokens({ - userId: user._id, - ip: req.realIP, - userAgent: req.headers["user-agent"] ?? "", - }); - - // store (refresh) token in httpOnly cookie - res.cookie("jid", tokens.refreshToken, { - httpOnly: true, - path: "/", - sameSite: "strict", - secure: await getHttpsEnabled(), - }); - - // case: user does not have MFA enablgged - // return (access) token in response - - interface ResponseData { - mfaEnabled: boolean; - encryptionVersion: any; - protectedKey?: string; - protectedKeyIV?: string; - protectedKeyTag?: string; - token: string; - publicKey?: string; - encryptedPrivateKey?: string; - iv?: string; - tag?: string; - } - - const response: ResponseData = { - mfaEnabled: false, - encryptionVersion: user.encryptionVersion, - token: tokens.token, - publicKey: user.publicKey, - encryptedPrivateKey: user.encryptedPrivateKey, - iv: user.iv, - tag: user.tag, - } - - if ( - user?.protectedKey && - user?.protectedKeyIV && - user?.protectedKeyTag - ) { - response.protectedKey = user.protectedKey; - response.protectedKeyIV = user.protectedKeyIV - response.protectedKeyTag = user.protectedKeyTag; - } - - const loginAction = await EELogService.createAction({ - name: ACTION_LOGIN, - userId: user._id, - }); - - loginAction && await EELogService.createLog({ - userId: user._id, - actions: [loginAction], - channel: getUserAgentType(req.headers["user-agent"]), - ipAddress: req.realIP, - }); - - return res.status(200).send(response); + // send MFA code [code] to [email] + await sendMail({ + template: "emailMfa.handlebars", + subjectLine: "Infisical MFA code", + recipients: [user.email], + substitutions: { + code } + }); - return res.status(400).send({ - message: "Failed to authenticate. Try again?", - }); + return res.status(200).send({ + mfaEnabled: true, + token + }); } - ); + + await checkUserDevice({ + user, + ip: req.realIP, + userAgent: req.headers["user-agent"] ?? "" + }); + + // issue tokens + const tokens = await issueAuthTokens({ + userId: user._id, + ip: req.realIP, + userAgent: req.headers["user-agent"] ?? "" + }); + + // store (refresh) token in httpOnly cookie + res.cookie("jid", tokens.refreshToken, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: await getHttpsEnabled() + }); + + // case: user does not have MFA enablgged + // return (access) token in response + + interface ResponseData { + mfaEnabled: boolean; + encryptionVersion: any; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; + token: string; + publicKey?: string; + encryptedPrivateKey?: string; + iv?: string; + tag?: string; + } + + const response: ResponseData = { + mfaEnabled: false, + encryptionVersion: user.encryptionVersion, + token: tokens.token, + publicKey: user.publicKey, + encryptedPrivateKey: user.encryptedPrivateKey, + iv: user.iv, + tag: user.tag + }; + + if (user?.protectedKey && user?.protectedKeyIV && user?.protectedKeyTag) { + response.protectedKey = user.protectedKey; + response.protectedKeyIV = user.protectedKeyIV; + response.protectedKeyTag = user.protectedKeyTag; + } + + const loginAction = await EELogService.createAction({ + name: ACTION_LOGIN, + userId: user._id + }); + + loginAction && + (await EELogService.createLog({ + userId: user._id, + actions: [loginAction], + channel: getUserAgentType(req.headers["user-agent"]), + ipAddress: req.realIP + })); + + return res.status(200).send(response); + } + + return res.status(400).send({ + message: "Failed to authenticate. Try again?" + }); + } + ); }; diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index 4a57beba3..a553315bd 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -3,14 +3,22 @@ import { Types } from "mongoose"; import { EventService, SecretService } from "../../services"; import { eventPushSecrets } from "../../events"; import { BotService } from "../../services"; -import { containsGlobPatterns, repackageSecretToRaw } from "../../helpers/secrets"; +import { containsGlobPatterns, isValidScope, repackageSecretToRaw } from "../../helpers/secrets"; import { encryptSymmetric128BitHexKeyUTF8 } from "../../utils/crypto"; import { getAllImportedSecrets } from "../../services/SecretImportService"; import { Folder, IServiceTokenData } from "../../models"; -import { getFolderByPath } from "../../services/FolderService"; +import { getFolderByPath, getFolderWithPathFromId } from "../../services/FolderService"; import { BadRequestError } from "../../utils/errors"; -import { requireWorkspaceAuth } from "../../middleware"; -import { ADMIN, MEMBER, PERMISSION_READ_SECRETS } from "../../variables"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/secrets"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import { ForbiddenError, subject } from "@casl/ability"; +import { validateServiceTokenDataClientForWorkspace } from "../../validation"; +import { PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS } from "../../variables"; /** * Return secrets for workspace with id [workspaceId] and environment @@ -19,30 +27,63 @@ import { ADMIN, MEMBER, PERMISSION_READ_SECRETS } from "../../variables"; * @param res */ export const getSecretsRaw = async (req: Request, res: Response) => { - let workspaceId = req.query.workspaceId as string; - let environment = req.query.environment as string; - let secretPath = req.query.secretPath as string; - const folderId = req.query.folderId as string | undefined; - const includeImports = req.query.include_imports as string; + const validatedData = await validateRequest(reqValidator.GetSecretsRawV3, req); + let { + query: { secretPath, environment, workspaceId } + } = validatedData; + const { + query: { folderId, include_imports: includeImports } + } = validatedData; // if the service token has single scope, it will get all secrets for that scope by default const serviceTokenDetails: IServiceTokenData = req?.serviceTokenData; - if (serviceTokenDetails && serviceTokenDetails.scopes.length == 1 && !containsGlobPatterns(serviceTokenDetails.scopes[0].secretPath)) { + if ( + serviceTokenDetails && + serviceTokenDetails.scopes.length == 1 && + !containsGlobPatterns(serviceTokenDetails.scopes[0].secretPath) + ) { const scope = serviceTokenDetails.scopes[0]; secretPath = scope.secretPath; environment = scope.environment; workspaceId = serviceTokenDetails.workspace.toString(); - } else { - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "query", - locationEnvironment: "query", - requiredPermissions: [PERMISSION_READ_SECRETS], - requireBlindIndicesEnabled: true, - requireE2EEOff: true - }); } + if (folderId && folderId !== "root") { + const folder = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + + secretPath = getFolderWithPathFromId(folder.nodes, folderId).folderPath; + } + + if (!environment || !workspaceId) + throw BadRequestError({ message: "Missing environment or workspace id" }); + + let permissionCheckFn: (env: string, secPath: string) => boolean; // used to pass as callback function to import secret + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + permissionCheckFn = (env: string, secPath: string) => + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: env, + secretPath: secPath + }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_READ_SECRETS] + }); + permissionCheckFn = (env: string, secPath: string) => + isValidScope(req.authData.authPayload as IServiceTokenData, env, secPath); + } const secrets = await SecretService.getSecrets({ workspaceId: new Types.ObjectId(workspaceId), @@ -56,7 +97,7 @@ export const getSecretsRaw = async (req: Request, res: Response) => { workspaceId: new Types.ObjectId(workspaceId) }); - if (includeImports === "true") { + if (includeImports) { const folders = await Folder.findOne({ workspace: workspaceId, environment }); let folderId = "root"; // if folder exist get it and replace folderid with new one @@ -67,7 +108,12 @@ export const getSecretsRaw = async (req: Request, res: Response) => { } folderId = folder.id; } - const importedSecrets = await getAllImportedSecrets(workspaceId, environment, folderId); + const importedSecrets = await getAllImportedSecrets( + workspaceId, + environment, + folderId, + permissionCheckFn + ); return res.status(200).send({ secrets: secrets.map((secret) => repackageSecretToRaw({ @@ -99,11 +145,26 @@ export const getSecretsRaw = async (req: Request, res: Response) => { * @param res */ export const getSecretByNameRaw = async (req: Request, res: Response) => { - const { secretName } = req.params; - const workspaceId = req.query.workspaceId as string; - const environment = req.query.environment as string; - const secretPath = req.query.secretPath as string; - const type = req.query.type as "shared" | "personal" | undefined; + const { + query: { secretPath, environment, workspaceId, type, include_imports }, + params: { secretName } + } = await validateRequest(reqValidator.GetSecretByNameRawV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_READ_SECRETS] + }); + } const secret = await SecretService.getSecret({ secretName, @@ -111,7 +172,8 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => { environment, type, secretPath, - authData: req.authData + authData: req.authData, + include_imports }); const key = await BotService.getWorkspaceKeyWithBot({ @@ -132,8 +194,26 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => { * @param res */ export const createSecretRaw = async (req: Request, res: Response) => { - const { secretName } = req.params; - const { workspaceId, environment, type, secretValue, secretComment, secretPath = "/" } = req.body; + const { + params: { secretName }, + body: { secretPath, environment, workspaceId, type, secretValue, secretComment } + } = await validateRequest(reqValidator.CreateSecretRawV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } const key = await BotService.getWorkspaceKeyWithBot({ workspaceId: new Types.ObjectId(workspaceId) @@ -197,8 +277,26 @@ export const createSecretRaw = async (req: Request, res: Response) => { * @param res */ export const updateSecretByNameRaw = async (req: Request, res: Response) => { - const { secretName } = req.params; - const { workspaceId, environment, type, secretValue, secretPath = "/" } = req.body; + const { + params: { secretName }, + body: { secretValue, environment, secretPath, type, workspaceId } + } = await validateRequest(reqValidator.UpdateSecretByNameRawV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } const key = await BotService.getWorkspaceKeyWithBot({ workspaceId: new Types.ObjectId(workspaceId) @@ -211,7 +309,7 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => { const secret = await SecretService.updateSecret({ secretName, - workspaceId, + workspaceId: new Types.ObjectId(workspaceId), environment, type, authData: req.authData, @@ -243,12 +341,30 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => { * @param res */ export const deleteSecretByNameRaw = async (req: Request, res: Response) => { - const { secretName } = req.params; - const { workspaceId, environment, type, secretPath = "/" } = req.body; + const { + params: { secretName }, + body: { environment, secretPath, type, workspaceId } + } = await validateRequest(reqValidator.DeleteSecretByNameRawV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } const { secret } = await SecretService.deleteSecret({ secretName, - workspaceId, + workspaceId: new Types.ObjectId(workspaceId), environment, type, authData: req.authData, @@ -282,11 +398,48 @@ export const deleteSecretByNameRaw = async (req: Request, res: Response) => { * @param res */ export const getSecrets = async (req: Request, res: Response) => { - const workspaceId = req.query.workspaceId as string; - const environment = req.query.environment as string; - const secretPath = req.query.secretPath as string; - const folderId = req.query.folderId as string | undefined; - const includeImports = req.query.include_imports as string; + const validatedData = await validateRequest(reqValidator.GetSecretsV3, req); + const { + query: { environment, workspaceId, include_imports: includeImports, folderId } + } = validatedData; + + let { + query: { secretPath } + } = validatedData; + + if (folderId && folderId !== "root") { + const folder = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + + secretPath = getFolderWithPathFromId(folder.nodes, folderId).folderPath; + } + + let permissionCheckFn: (env: string, secPath: string) => boolean; // used to pass as callback function to import secret + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + permissionCheckFn = (env: string, secPath: string) => + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: env, + secretPath: secPath + }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_READ_SECRETS] + }); + permissionCheckFn = (env: string, secPath: string) => + isValidScope(req.authData.authPayload as IServiceTokenData, env, secPath); + } const secrets = await SecretService.getSecrets({ workspaceId: new Types.ObjectId(workspaceId), @@ -296,7 +449,7 @@ export const getSecrets = async (req: Request, res: Response) => { authData: req.authData }); - if (includeImports === "true") { + if (includeImports) { const folders = await Folder.findOne({ workspace: workspaceId, environment }); let folderId = "root"; // if folder exist get it and replace folderid with new one @@ -307,7 +460,12 @@ export const getSecrets = async (req: Request, res: Response) => { } folderId = folder.id; } - const importedSecrets = await getAllImportedSecrets(workspaceId, environment, folderId); + const importedSecrets = await getAllImportedSecrets( + workspaceId, + environment, + folderId, + permissionCheckFn + ); return res.status(200).send({ secrets, imports: importedSecrets @@ -325,11 +483,26 @@ export const getSecrets = async (req: Request, res: Response) => { * @param res */ export const getSecretByName = async (req: Request, res: Response) => { - const { secretName } = req.params; - const workspaceId = req.query.workspaceId as string; - const environment = req.query.environment as string; - const secretPath = req.query.secretPath as string; - const type = req.query.type as "shared" | "personal" | undefined; + const { + query: { secretPath, environment, workspaceId, type, include_imports }, + params: { secretName } + } = await validateRequest(reqValidator.GetSecretByNameV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_READ_SECRETS] + }); + } const secret = await SecretService.getSecret({ secretName, @@ -337,7 +510,8 @@ export const getSecretByName = async (req: Request, res: Response) => { environment, type, secretPath, - authData: req.authData + authData: req.authData, + include_imports }); return res.status(200).send({ @@ -351,23 +525,41 @@ export const getSecretByName = async (req: Request, res: Response) => { * @param res */ export const createSecret = async (req: Request, res: Response) => { - const { secretName } = req.params; const { - workspaceId, - environment, - type, - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag, - secretPath = "/", - metadata - } = req.body; + body: { + workspaceId, + secretPath, + environment, + metadata, + type, + secretKeyIV, + secretKeyTag, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretKeyCiphertext, + secretValueCiphertext, + secretCommentCiphertext + }, + params: { secretName } + } = await validateRequest(reqValidator.CreateSecretV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } const secret = await SecretService.createSecret({ secretName, @@ -410,20 +602,38 @@ export const createSecret = async (req: Request, res: Response) => { * @param res */ export const updateSecretByName = async (req: Request, res: Response) => { - const { secretName } = req.params; const { - workspaceId, - environment, - type, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretPath = "/" - } = req.body; + body: { + secretValueCiphertext, + secretValueTag, + secretValueIV, + type, + environment, + secretPath, + workspaceId + }, + params: { secretName } + } = await validateRequest(reqValidator.UpdateSecretByNameV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } const secret = await SecretService.updateSecret({ secretName, - workspaceId, + workspaceId: new Types.ObjectId(workspaceId), environment, type, authData: req.authData, @@ -452,12 +662,30 @@ export const updateSecretByName = async (req: Request, res: Response) => { * @param res */ export const deleteSecretByName = async (req: Request, res: Response) => { - const { secretName } = req.params; - const { workspaceId, environment, type, secretPath = "/" } = req.body; + const { + body: { type, environment, secretPath, workspaceId }, + params: { secretName } + } = await validateRequest(reqValidator.DeleteSecretByNameV3, req); + + if (req.user?._id) { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + } else { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: req.authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: [PERMISSION_WRITE_SECRETS] + }); + } const { secret } = await SecretService.deleteSecret({ secretName, - workspaceId, + workspaceId: new Types.ObjectId(workspaceId), environment, type, authData: req.authData, diff --git a/backend/src/controllers/v3/signupController.ts b/backend/src/controllers/v3/signupController.ts index 449df4e11..79d661b58 100644 --- a/backend/src/controllers/v3/signupController.ts +++ b/backend/src/controllers/v3/signupController.ts @@ -3,9 +3,7 @@ import { Request, Response } from "express"; import * as Sentry from "@sentry/node"; import { MembershipOrg, User } from "../../models"; import { completeAccount } from "../../helpers/user"; -import { - initializeDefaultOrg, -} from "../../helpers/signup"; +import { initializeDefaultOrg } from "../../helpers/signup"; import { issueAuthTokens, validateProviderAuthToken } from "../../helpers/auth"; import { ACCEPTED, INVITED } from "../../variables"; import { standardRequest } from "../../config/request"; @@ -13,6 +11,8 @@ import { getHttpsEnabled, getJwtSignupSecret, getLoopsApiKey } from "../../confi import { BadRequestError } from "../../utils/errors"; import { TelemetryService } from "../../services"; import { AuthMethod } from "../../models"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/auth"; /** * Complete setting up user by adding their personal and auth information as part of the @@ -22,177 +22,173 @@ import { AuthMethod } from "../../models"; * @returns */ export const completeAccountSignup = async (req: Request, res: Response) => { - let user, token, refreshToken; - try { - const { - email, - firstName, - lastName, - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt, - verifier, - organizationName, - providerAuthToken, - attributionSource, - }: { - email: string; - firstName: string; - lastName: string; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - publicKey: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; - salt: string; - verifier: string; - organizationName: string; - providerAuthToken?: string; - attributionSource?: string; - } = req.body; + let user, token; + try { + const { + body: { + email, + publicKey, + salt, + lastName, + verifier, + firstName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + organizationName, + providerAuthToken, + attributionSource, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag + } + } = await validateRequest(reqValidator.CompletedAccountSignupV3, req); - user = await User.findOne({ email }); + user = await User.findOne({ email }); - if (!user || (user && user?.publicKey)) { - // case 1: user doesn't exist. - // case 2: user has already completed account - return res.status(403).send({ - error: "Failed to complete account for complete user", - }); - } + if (!user || (user && user?.publicKey)) { + // case 1: user doesn't exist. + // case 2: user has already completed account + return res.status(403).send({ + error: "Failed to complete account for complete user" + }); + } - if (providerAuthToken) { - await validateProviderAuthToken({ - email, - providerAuthToken - }); - } else { - const [AUTH_TOKEN_TYPE, AUTH_TOKEN_VALUE] = <[string, string]>req.headers["authorization"]?.split(" ", 2) ?? [null, null] - if (AUTH_TOKEN_TYPE === null) { - throw BadRequestError({ message: "Missing Authorization Header in the request header." }); - } - if (AUTH_TOKEN_TYPE.toLowerCase() !== "bearer") { - throw BadRequestError({ message: `The provided authentication type '${AUTH_TOKEN_TYPE}' is not supported.` }) - } - if (AUTH_TOKEN_VALUE === null) { - throw BadRequestError({ - message: "Missing Authorization Body in the request header", - }) - } + if (providerAuthToken) { + await validateProviderAuthToken({ + email, + providerAuthToken + }); + } else { + const [AUTH_TOKEN_TYPE, AUTH_TOKEN_VALUE] = <[string, string]>( + req.headers["authorization"]?.split(" ", 2) + ) ?? [null, null]; + if (AUTH_TOKEN_TYPE === null) { + throw BadRequestError({ message: "Missing Authorization Header in the request header." }); + } + if (AUTH_TOKEN_TYPE.toLowerCase() !== "bearer") { + throw BadRequestError({ + message: `The provided authentication type '${AUTH_TOKEN_TYPE}' is not supported.` + }); + } + if (AUTH_TOKEN_VALUE === null) { + throw BadRequestError({ + message: "Missing Authorization Body in the request header" + }); + } - const decodedToken = ( - jwt.verify(AUTH_TOKEN_VALUE, await getJwtSignupSecret()) - ); + const decodedToken = ( + jwt.verify(AUTH_TOKEN_VALUE, await getJwtSignupSecret()) + ); - if (decodedToken.userId !== user.id) { - throw BadRequestError(); - } - } + if (decodedToken.userId !== user.id) { + throw BadRequestError(); + } + } - // complete setting up user's account - user = await completeAccount({ - userId: user._id.toString(), - firstName, - lastName, - encryptionVersion: 2, - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt, - verifier, - }); + // complete setting up user's account + user = await completeAccount({ + userId: user._id.toString(), + firstName, + lastName, + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + }); - if (!user) - throw new Error("Failed to complete account for non-existent user"); // ensure user is non-null + if (!user) throw new Error("Failed to complete account for non-existent user"); // ensure user is non-null - const hasSamlEnabled = user.authMethods.some((authMethod: AuthMethod) => [AuthMethod.OKTA_SAML, AuthMethod.AZURE_SAML, AuthMethod.JUMPCLOUD_SAML].includes(authMethod)); - - if (!hasSamlEnabled) { // TODO: modify this part - // initialize default organization and workspace - await initializeDefaultOrg({ - organizationName, - user, - }); - } + const hasSamlEnabled = user.authMethods.some((authMethod: AuthMethod) => + [AuthMethod.OKTA_SAML, AuthMethod.AZURE_SAML, AuthMethod.JUMPCLOUD_SAML].includes(authMethod) + ); - // update organization membership statuses that are - // invited to completed with user attached - await MembershipOrg.updateMany( - { - inviteEmail: email, - status: INVITED, - }, - { - user, - status: ACCEPTED, - } - ); + if (!hasSamlEnabled) { + // TODO: modify this part + // initialize default organization and workspace + await initializeDefaultOrg({ + organizationName, + user + }); + } - // issue tokens - const tokens = await issueAuthTokens({ - userId: user._id, - ip: req.realIP, - userAgent: req.headers["user-agent"] ?? "", - }); + // update organization membership statuses that are + // invited to completed with user attached + await MembershipOrg.updateMany( + { + inviteEmail: email, + status: INVITED + }, + { + user, + status: ACCEPTED + } + ); - token = tokens.token; + // issue tokens + const tokens = await issueAuthTokens({ + userId: user._id, + ip: req.realIP, + userAgent: req.headers["user-agent"] ?? "" + }); - // sending a welcome email to new users - if (await getLoopsApiKey()) { - await standardRequest.post("https://app.loops.so/api/v1/events/send", { - "email": email, - "eventName": "Sign Up", - "firstName": firstName, - "lastName": lastName, - }, { - headers: { - "Accept": "application/json", - "Authorization": "Bearer " + (await getLoopsApiKey()), - }, - }); - } + token = tokens.token; - // store (refresh) token in httpOnly cookie - res.cookie("jid", tokens.refreshToken, { - httpOnly: true, - path: "/", - sameSite: "strict", - secure: await getHttpsEnabled(), - }); + // sending a welcome email to new users + if (await getLoopsApiKey()) { + await standardRequest.post( + "https://app.loops.so/api/v1/events/send", + { + email: email, + eventName: "Sign Up", + firstName: firstName, + lastName: lastName + }, + { + headers: { + Accept: "application/json", + Authorization: "Bearer " + (await getLoopsApiKey()) + } + } + ); + } - const postHogClient = await TelemetryService.getPostHogClient(); - if (postHogClient) { - postHogClient.capture({ - event: "User Signed Up", - distinctId: email, - properties: { - email, - ...(attributionSource ? { attributionSource } : {}) - }, - }); - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: "Failed to complete account setup", - }); - } + // store (refresh) token in httpOnly cookie + res.cookie("jid", tokens.refreshToken, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: await getHttpsEnabled() + }); - return res.status(200).send({ - message: "Successfully set up account", - user, - token, - }); + const postHogClient = await TelemetryService.getPostHogClient(); + if (postHogClient) { + postHogClient.capture({ + event: "User Signed Up", + distinctId: email, + properties: { + email, + ...(attributionSource ? { attributionSource } : {}) + } + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to complete account setup" + }); + } + + return res.status(200).send({ + message: "Successfully set up account", + user, + token + }); }; diff --git a/backend/src/controllers/v3/workspacesController.ts b/backend/src/controllers/v3/workspacesController.ts index 4298adba2..8469cdb5e 100644 --- a/backend/src/controllers/v3/workspacesController.ts +++ b/backend/src/controllers/v3/workspacesController.ts @@ -1,90 +1,103 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; +import { validateRequest } from "../../helpers/validation"; import { Secret } from "../../models"; -import { SecretService } from"../../services"; +import { SecretService } from "../../services"; +import { getUserProjectPermissions } from "../../ee/services/ProjectRoleService"; +import { UnauthorizedRequestError } from "../../utils/errors"; +import * as reqValidator from "../../validation/workspace"; /** * Return whether or not all secrets in workspace with id [workspaceId] * are blind-indexed - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getWorkspaceBlindIndexStatus = async (req: Request, res: Response) => { - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceBlinkIndexStatusV3, req); - const secretsWithoutBlindIndex = await Secret.countDocuments({ - workspace: new Types.ObjectId(workspaceId), - secretBlindIndex: { - $exists: false, - }, - }); + const { membership } = await getUserProjectPermissions(req.user._id, workspaceId); + if (membership.role !== "admin") + throw UnauthorizedRequestError({ message: "User must be an admin" }); - return res.status(200).send(secretsWithoutBlindIndex === 0); -} + const secretsWithoutBlindIndex = await Secret.countDocuments({ + workspace: new Types.ObjectId(workspaceId), + secretBlindIndex: { + $exists: false + } + }); + + return res.status(200).send(secretsWithoutBlindIndex === 0); +}; /** * Get all secrets for workspace with id [workspaceId] */ export const getWorkspaceSecrets = async (req: Request, res: Response) => { - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceSecretsV3, req); - const secrets = await Secret.find({ - workspace: new Types.ObjectId (workspaceId), - }); - - return res.status(200).send({ - secrets, - }); -} + const { membership } = await getUserProjectPermissions(req.user._id, workspaceId); + if (membership.role !== "admin") + throw UnauthorizedRequestError({ message: "User must be an admin" }); + + const secrets = await Secret.find({ + workspace: new Types.ObjectId(workspaceId) + }); + + return res.status(200).send({ + secrets + }); +}; /** * Update blind indices for secrets in workspace with id [workspaceId] - * @param req - * @param res + * @param req + * @param res */ export const nameWorkspaceSecrets = async (req: Request, res: Response) => { - interface SecretToUpdate { - secretName: string; - _id: string; - } + const { + params: { workspaceId }, + body: { secretsToUpdate } + } = await validateRequest(reqValidator.NameWorkspaceSecretsV3, req); - const { workspaceId } = req.params; - const { - secretsToUpdate, - }: { - secretsToUpdate: SecretToUpdate[]; - } = req.body; + const { membership } = await getUserProjectPermissions(req.user._id, workspaceId); + if (membership.role !== "admin") + throw UnauthorizedRequestError({ message: "User must be an admin" }); - // get secret blind index salt - const salt = await SecretService.getSecretBlindIndexSalt({ - workspaceId: new Types.ObjectId(workspaceId), - }); + // get secret blind index salt + const salt = await SecretService.getSecretBlindIndexSalt({ + workspaceId: new Types.ObjectId(workspaceId) + }); - // update secret blind indices - const operations = await Promise.all( - secretsToUpdate.map(async (secretToUpdate: SecretToUpdate) => { - const secretBlindIndex = await SecretService.generateSecretBlindIndexWithSalt({ - secretName: secretToUpdate.secretName, - salt, - }); - - return ({ - updateOne: { - filter: { - _id: new Types.ObjectId(secretToUpdate._id), - }, - update: { - secretBlindIndex, - }, - }, - }); - }) - ); + // update secret blind indices + const operations = await Promise.all( + secretsToUpdate.map(async (secretToUpdate) => { + const secretBlindIndex = await SecretService.generateSecretBlindIndexWithSalt({ + secretName: secretToUpdate.secretName, + salt + }); - await Secret.bulkWrite(operations); + return { + updateOne: { + filter: { + _id: new Types.ObjectId(secretToUpdate._id) + }, + update: { + secretBlindIndex + } + } + }; + }) + ); - return res.status(200).send({ - message: "Successfully named workspace secrets", - }); -} \ No newline at end of file + await Secret.bulkWrite(operations); + + return res.status(200).send({ + message: "Successfully named workspace secrets" + }); +}; diff --git a/backend/src/ee/controllers/v1/actionController.ts b/backend/src/ee/controllers/v1/actionController.ts index 484c25351..56b076026 100644 --- a/backend/src/ee/controllers/v1/actionController.ts +++ b/backend/src/ee/controllers/v1/actionController.ts @@ -1,30 +1,32 @@ import { Request, Response } from "express"; import { Action } from "../../models"; import { ActionNotFoundError } from "../../../utils/errors"; +import { validateRequest } from "../../../helpers/validation"; +import * as reqValidator from "../../../validation/action"; export const getAction = async (req: Request, res: Response) => { - let action; - try { - const { actionId } = req.params; - - action = await Action - .findById(actionId) - .populate([ - "payload.secretVersions.oldSecretVersion", - "payload.secretVersions.newSecretVersion", - ]); - - if (!action) throw ActionNotFoundError({ - message: "Failed to find action", - }); + let action; + try { + const { + params: { actionId } + } = await validateRequest(reqValidator.GetActionV1, req); - } catch (err) { - throw ActionNotFoundError({ - message: "Failed to find action", - }); - } - - return res.status(200).send({ - action, + action = await Action.findById(actionId).populate([ + "payload.secretVersions.oldSecretVersion", + "payload.secretVersions.newSecretVersion" + ]); + + if (!action) + throw ActionNotFoundError({ + message: "Failed to find action" + }); + } catch (err) { + throw ActionNotFoundError({ + message: "Failed to find action" }); -} + } + + return res.status(200).send({ + action + }); +}; diff --git a/backend/src/ee/controllers/v1/cloudProductsController.ts b/backend/src/ee/controllers/v1/cloudProductsController.ts index e66fc66e5..0cc6ab372 100644 --- a/backend/src/ee/controllers/v1/cloudProductsController.ts +++ b/backend/src/ee/controllers/v1/cloudProductsController.ts @@ -2,27 +2,31 @@ import { Request, Response } from "express"; import { EELicenseService } from "../../services"; import { getLicenseServerUrl } from "../../../config"; import { licenseServerKeyRequest } from "../../../config/request"; +import { validateRequest } from "../../../helpers/validation"; +import * as reqValidator from "../../../validation/cloudProducts"; /** * Return available cloud product information. * Note: Nicely formatted to easily construct a table from - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getCloudProducts = async (req: Request, res: Response) => { - const billingCycle = req.query["billing-cycle"] as string; + const { + query: { "billing-cycle": billingCycle } + } = await validateRequest(reqValidator.GetCloudProductsV1, req); - if (EELicenseService.instanceType === "cloud") { - const { data } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` - ); + if (EELicenseService.instanceType === "cloud") { + const { data } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` + ); - return res.status(200).send(data); - } - - return res.status(200).send({ - head: [], - rows: [], - }); -} + return res.status(200).send(data); + } + + return res.status(200).send({ + head: [], + rows: [] + }); +}; diff --git a/backend/src/ee/controllers/v1/index.ts b/backend/src/ee/controllers/v1/index.ts index 0c63b41c0..b57a07c9d 100644 --- a/backend/src/ee/controllers/v1/index.ts +++ b/backend/src/ee/controllers/v1/index.ts @@ -7,15 +7,17 @@ import * as workspaceController from "./workspaceController"; import * as actionController from "./actionController"; import * as membershipController from "./membershipController"; import * as cloudProductsController from "./cloudProductsController"; +import * as roleController from "./roleController"; export { - secretController, - secretSnapshotController, - organizationsController, - ssoController, - usersController, - workspaceController, - actionController, - membershipController, - cloudProductsController, -} \ No newline at end of file + secretController, + secretSnapshotController, + organizationsController, + ssoController, + usersController, + workspaceController, + actionController, + membershipController, + cloudProductsController, + roleController +}; diff --git a/backend/src/ee/controllers/v1/organizationsController.ts b/backend/src/ee/controllers/v1/organizationsController.ts index b641de985..f64603e3b 100644 --- a/backend/src/ee/controllers/v1/organizationsController.ts +++ b/backend/src/ee/controllers/v1/organizationsController.ts @@ -3,228 +3,503 @@ import { Request, Response } from "express"; import { getLicenseServerUrl } from "../../../config"; import { licenseServerKeyRequest } from "../../../config/request"; import { EELicenseService } from "../../services"; +import { validateRequest } from "../../../helpers/validation"; +import * as reqValidator from "../../../validation/organization"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + getUserOrgPermissions +} from "../../services/RoleService"; +import { ForbiddenError } from "@casl/ability"; +import { Organization } from "../../../models"; +import { OrganizationNotFoundError } from "../../../utils/errors"; export const getOrganizationPlansTable = async (req: Request, res: Response) => { - const billingCycle = req.query.billingCycle as string; - - const { data } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` - ); + const { + query: { billingCycle }, + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgPlansTablev1, req); - return res.status(200).send(data); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const { data } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` + ); + + return res.status(200).send(data); +}; /** * Return the organization current plan's feature set */ export const getOrganizationPlan = async (req: Request, res: Response) => { - const { organizationId } = req.params; - const workspaceId = req.query.workspaceId as string; + const { + query: { workspaceId }, + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgPlanv1, req); - const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId), new Types.ObjectId(workspaceId)); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); - return res.status(200).send({ - plan, - }); -} + const plan = await EELicenseService.getPlan( + new Types.ObjectId(organizationId), + new Types.ObjectId(workspaceId) + ); + + return res.status(200).send({ + plan + }); +}; /** * Return checkout url for pro trial - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const startOrganizationTrial = async (req: Request, res: Response) => { - const { organizationId } = req.params; - const { success_url } = req.body; + const { + params: { organizationId }, + body: { success_url } + } = await validateRequest(reqValidator.StartOrgTrailv1, req); - const { data: { url } } = await licenseServerKeyRequest.post( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/session/trial`, - { - success_url - } - ); - - EELicenseService.delPlan(new Types.ObjectId(organizationId)); - - return res.status(200).send({ - url + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Billing + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" }); -} + } + + const { + data: { url } + } = await licenseServerKeyRequest.post( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/session/trial`, + { + success_url + } + ); + + EELicenseService.delPlan(new Types.ObjectId(organizationId)); + + return res.status(200).send({ + url + }); +}; /** * Return the organization's current plan's billing info - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getOrganizationPlanBillingInfo = async (req: Request, res: Response) => { - const { data } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/cloud-plan/billing` - ); - - return res.status(200).send(data); -} + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgPlanBillingInfov1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/cloud-plan/billing` + ); + + return res.status(200).send(data); +}; /** * Return the organization's current plan's feature table - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getOrganizationPlanTable = async (req: Request, res: Response) => { - const { data } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/cloud-plan/table` - ); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgPlanTablev1, req); - return res.status(200).send(data); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/cloud-plan/table` + ); + + return res.status(200).send(data); +}; export const getOrganizationBillingDetails = async (req: Request, res: Response) => { - const { data } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details` - ); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgBillingDetailsv1, req); - return res.status(200).send(data); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details` + ); + + return res.status(200).send(data); +}; export const updateOrganizationBillingDetails = async (req: Request, res: Response) => { - const { - name, - email - } = req.body; + const { + params: { organizationId }, + body: { name, email } + } = await validateRequest(reqValidator.UpdateOrgBillingDetailsv1, req); - const { data } = await licenseServerKeyRequest.patch( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details`, - { - ...(name ? { name } : {}), - ...(email ? { email } : {}) - } - ); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Billing + ); - return res.status(200).send(data); -} + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.patch( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details`, + { + ...(name ? { name } : {}), + ...(email ? { email } : {}) + } + ); + + return res.status(200).send(data); +}; /** * Return the organization's payment methods on file */ export const getOrganizationPmtMethods = async (req: Request, res: Response) => { - const { data: { pmtMethods } } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods` - ); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgPmtMethodsv1, req); - return res.status(200).send(pmtMethods); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { + data: { pmtMethods } + } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/payment-methods` + ); + + return res.status(200).send(pmtMethods); +}; /** * Return URL to add payment method for organization */ export const addOrganizationPmtMethod = async (req: Request, res: Response) => { - const { - success_url, - cancel_url, - } = req.body; - - const { data: { url } } = await licenseServerKeyRequest.post( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods`, - { - success_url, - cancel_url, - } - ); - - return res.status(200).send({ - url, - }); -} + const { + params: { organizationId }, + body: { success_url, cancel_url } + } = await validateRequest(reqValidator.CreateOrgPmtMethodv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { + data: { url } + } = await licenseServerKeyRequest.post( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/payment-methods`, + { + success_url, + cancel_url + } + ); + + return res.status(200).send({ + url + }); +}; /** * Delete payment method with id [pmtMethodId] for organization - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const deleteOrganizationPmtMethod = async (req: Request, res: Response) => { - const { pmtMethodId } = req.params; + const { + params: { organizationId, pmtMethodId } + } = await validateRequest(reqValidator.DelOrgPmtMethodv1, req); - const { data } = await licenseServerKeyRequest.delete( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods/${pmtMethodId}`, - ); - - return res.status(200).send(data); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.delete( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/payment-methods/${pmtMethodId}` + ); + + return res.status(200).send(data); +}; /** * Return the organization's tax ids on file */ export const getOrganizationTaxIds = async (req: Request, res: Response) => { - const { data: { tax_ids } } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/tax-ids` - ); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgTaxIdsv1, req); - return res.status(200).send(tax_ids); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { + data: { tax_ids } + } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/tax-ids` + ); + + return res.status(200).send(tax_ids); +}; /** * Add tax id to organization */ export const addOrganizationTaxId = async (req: Request, res: Response) => { - const { - type, - value - } = req.body; + const { + params: { organizationId }, + body: { type, value } + } = await validateRequest(reqValidator.CreateOrgTaxId, req); - const { data } = await licenseServerKeyRequest.post( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/tax-ids`, - { - type, - value - } - ); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Billing + ); - return res.status(200).send(data); -} + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.post( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/tax-ids`, + { + type, + value + } + ); + + return res.status(200).send(data); +}; /** * Delete tax id with id [taxId] from organization tax ids on file - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const deleteOrganizationTaxId = async (req: Request, res: Response) => { - const { taxId } = req.params; + const { + params: { organizationId, taxId } + } = await validateRequest(reqValidator.DelOrgTaxIdv1, req); - const { data } = await licenseServerKeyRequest.delete( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/tax-ids/${taxId}`, - ); - - return res.status(200).send(data); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.delete( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/tax-ids/${taxId}` + ); + + return res.status(200).send(data); +}; /** * Return organization's invoices on file - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getOrganizationInvoices = async (req: Request, res: Response) => { - const { data: { invoices } } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/invoices` - ); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgInvoicesv1, req); - return res.status(200).send(invoices); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { + data: { invoices } + } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/invoices` + ); + + return res.status(200).send(invoices); +}; /** * Return organization's licenses on file - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getOrganizationLicenses = async (req: Request, res: Response) => { - const { data: { licenses } } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/licenses` - ); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgLicencesv1, req); - return res.status(200).send(licenses); -} \ No newline at end of file + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { + data: { licenses } + } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/licenses` + ); + + return res.status(200).send(licenses); +}; diff --git a/backend/src/ee/controllers/v1/roleController.ts b/backend/src/ee/controllers/v1/roleController.ts new file mode 100644 index 000000000..0d5f105ea --- /dev/null +++ b/backend/src/ee/controllers/v1/roleController.ts @@ -0,0 +1,235 @@ +import { Request, Response } from "express"; +import { + CreateRoleSchema, + DeleteRoleSchema, + GetRoleSchema, + GetUserPermission, + GetUserProjectPermission, + UpdateRoleSchema +} from "../../validation/role"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + adminProjectPermissions, + getUserProjectPermissions, + memberProjectPermissions, + viewerProjectPermission +} from "../../services/ProjectRoleService"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + adminPermissions, + getUserOrgPermissions, + memberPermissions +} from "../../services/RoleService"; +import { BadRequestError } from "../../../utils/errors"; +import Role from "../../models/role"; +import { validateRequest } from "../../../helpers/validation"; +import { packRules } from "@casl/ability/extra"; + +export const createRole = async (req: Request, res: Response) => { + const { + body: { workspaceId, name, description, slug, permissions, orgId } + } = await validateRequest(CreateRoleSchema, req); + + const isOrgRole = !workspaceId; // if workspaceid is provided then its a workspace rule + if (isOrgRole) { + const { permission } = await getUserOrgPermissions(req.user.id, orgId); + if (permission.cannot(OrgPermissionActions.Create, OrgPermissionSubjects.Role)) { + throw BadRequestError({ message: "user doesn't have the permission." }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user.id, workspaceId); + if (permission.cannot(ProjectPermissionActions.Create, ProjectPermissionSub.Role)) { + throw BadRequestError({ message: "User doesn't have the permission." }); + } + } + + const existingRole = await Role.findOne({ organization: orgId, workspace: workspaceId, slug }); + if (existingRole) { + throw BadRequestError({ message: "Role already exist" }); + } + + const role = new Role({ + organization: orgId, + workspace: workspaceId, + isOrgRole, + name, + slug, + permissions, + description + }); + await role.save(); + + res.status(200).json({ + message: "Successfully created role", + data: { + role + } + }); +}; + +export const updateRole = async (req: Request, res: Response) => { + const { + params: { id }, + body: { name, description, slug, permissions, workspaceId, orgId } + } = await validateRequest(UpdateRoleSchema, req); + const isOrgRole = !workspaceId; // if workspaceid is provided then its a workspace rule + + if (isOrgRole) { + const { permission } = await getUserOrgPermissions(req.user.id, orgId); + if (permission.cannot(OrgPermissionActions.Edit, OrgPermissionSubjects.Role)) { + throw BadRequestError({ message: "User doesn't have the org permission." }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user.id, workspaceId); + if (permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Role)) { + throw BadRequestError({ message: "User doesn't have the workspace permission." }); + } + } + + if (slug) { + const existingRole = await Role.findOne({ + organization: orgId, + slug, + isOrgRole, + workspace: workspaceId + }); + if (existingRole && existingRole.id !== id) { + throw BadRequestError({ message: "Role already exist" }); + } + } + + const role = await Role.findByIdAndUpdate( + id, + { name, description, slug, permissions }, + { returnDocument: "after" } + ); + + if (!role) { + throw BadRequestError({ message: "Role not found" }); + } + res.status(200).json({ + message: "Successfully updated role", + data: { + role + } + }); +}; + +export const deleteRole = async (req: Request, res: Response) => { + const { + params: { id } + } = await validateRequest(DeleteRoleSchema, req); + + const role = await Role.findById(id); + if (!role) { + throw BadRequestError({ message: "Role not found" }); + } + + const isOrgRole = !role.workspace; + if (isOrgRole) { + const { permission } = await getUserOrgPermissions(req.user.id, role.organization.toString()); + if (permission.cannot(OrgPermissionActions.Delete, OrgPermissionSubjects.Role)) { + throw BadRequestError({ message: "User doesn't have the org permission." }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user.id, role.workspace.toString()); + if (permission.cannot(ProjectPermissionActions.Delete, ProjectPermissionSub.Role)) { + throw BadRequestError({ message: "User doesn't have the workspace permission." }); + } + } + + await Role.findByIdAndDelete(role.id); + + res.status(200).json({ + message: "Successfully deleted role", + data: { + role + } + }); +}; + +export const getRoles = async (req: Request, res: Response) => { + const { + query: { workspaceId, orgId } + } = await validateRequest(GetRoleSchema, req); + + const isOrgRole = !workspaceId; + if (isOrgRole) { + const { permission } = await getUserOrgPermissions(req.user.id, orgId); + if (permission.cannot(OrgPermissionActions.Read, OrgPermissionSubjects.Role)) { + throw BadRequestError({ message: "User doesn't have the org permission." }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user.id, workspaceId); + if (permission.cannot(ProjectPermissionActions.Read, ProjectPermissionSub.Role)) { + throw BadRequestError({ message: "User doesn't have the workspace permission." }); + } + } + + const customRoles = await Role.find({ organization: orgId, isOrgRole, workspace: workspaceId }); + // as this is shared between org and workspace switch the rule set based on it + const roles = [ + { + _id: "admin", + name: "Admin", + slug: "admin", + description: "Complete administration access over the organization", + permissions: isOrgRole ? adminPermissions.rules : adminProjectPermissions.rules + }, + { + _id: "member", + name: isOrgRole ? "Member" : "Developer", + slug: "member", + description: "Non-administrative role in an organization", + permissions: isOrgRole ? memberPermissions.rules : memberProjectPermissions.rules + }, + // viewer role only for project level + ...(isOrgRole + ? [] + : [ + { + _id: "viewer", + name: "Viewer", + slug: "viewer", + description: "Non-administrative role in an organization", + permissions: viewerProjectPermission.rules + } + ]), + ...customRoles + ]; + + res.status(200).json({ + message: "Successfully fetched role list", + data: { + roles + } + }); +}; + +export const getUserPermissions = async (req: Request, res: Response) => { + const { + params: { orgId } + } = await validateRequest(GetUserPermission, req); + const { permission } = await getUserOrgPermissions(req.user._id, orgId); + + res.status(200).json({ + data: { + permissions: packRules(permission.rules) + } + }); +}; + +export const getUserWorkspacePermissions = async (req: Request, res: Response) => { + const { + params: { workspaceId } + } = await validateRequest(GetUserProjectPermission, req); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + + res.status(200).json({ + data: { + permissions: packRules(permission.rules) + } + }); +}; diff --git a/backend/src/ee/controllers/v1/secretController.ts b/backend/src/ee/controllers/v1/secretController.ts index 2dcc3c2c7..42aa07f3d 100644 --- a/backend/src/ee/controllers/v1/secretController.ts +++ b/backend/src/ee/controllers/v1/secretController.ts @@ -1,7 +1,17 @@ +import { ForbiddenError, subject } from "@casl/ability"; import { Request, Response } from "express"; -import { Secret } from "../../../models"; +import { validateRequest } from "../../../helpers/validation"; +import { Folder, Secret } from "../../../models"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../services/ProjectRoleService"; +import { BadRequestError } from "../../../utils/errors"; +import * as reqValidator from "../../../validation"; import { SecretVersion } from "../../models"; import { EESecretService } from "../../services"; +import { getFolderWithPathFromId } from "../../../services/FolderService"; /** * Return secret versions for secret with id [secretId] @@ -54,10 +64,21 @@ export const getSecretVersions = async (req: Request, res: Response) => { } } */ - const { secretId } = req.params; + const { + params: { secretId }, + query: { offset, limit } + } = await validateRequest(reqValidator.GetSecretVersionsV1, req); - const offset: number = parseInt(req.query.offset as string); - const limit: number = parseInt(req.query.limit as string); + const secret = await Secret.findById(secretId); + if (!secret) { + throw BadRequestError({ message: "Failed to find secret" }); + } + + const { permission } = await getUserProjectPermissions(req.user._id, secret.workspace.toString()); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SecretRollback + ); const secretVersions = await SecretVersion.find({ secret: secretId @@ -126,8 +147,24 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { } } */ - const { secretId } = req.params; - const { version } = req.body; + + const { + params: { secretId }, + body: { version } + } = await validateRequest(reqValidator.RollbackSecretVersionV1, req); + + const toBeUpdatedSec = await Secret.findById(secretId); + if (!toBeUpdatedSec) { + throw BadRequestError({ message: "Failed to find secret" }); + } + const { permission } = await getUserProjectPermissions( + req.user._id, + toBeUpdatedSec.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SecretRollback + ); // validate secret version const oldSecretVersion = await SecretVersion.findOne({ @@ -154,6 +191,15 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { keyEncoding } = oldSecretVersion; + let secretPath = "/"; + const folders = await Folder.findOne({ workspace, environment }); + if (folders) + secretPath = getFolderWithPathFromId(folders.nodes, folder || "root")?.folderPath || "/"; + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: toBeUpdatedSec.environment, secretPath }) + ); + // update secret const secret = await Secret.findByIdAndUpdate( secretId, diff --git a/backend/src/ee/controllers/v1/secretSnapshotController.ts b/backend/src/ee/controllers/v1/secretSnapshotController.ts index c10188b10..e39555929 100644 --- a/backend/src/ee/controllers/v1/secretSnapshotController.ts +++ b/backend/src/ee/controllers/v1/secretSnapshotController.ts @@ -1,9 +1,13 @@ +import { ForbiddenError } from "@casl/ability"; import { Request, Response } from "express"; +import { validateRequest } from "../../../helpers/validation"; import { - ISecretVersion, - SecretSnapshot, - TFolderRootVersionSchema, -} from "../../models"; + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../services/ProjectRoleService"; +import * as reqValidator from "../../../validation/secretSnapshot"; +import { ISecretVersion, SecretSnapshot, TFolderRootVersionSchema } from "../../models"; /** * Return secret snapshot with id [secretSnapshotId] @@ -12,7 +16,9 @@ import { * @returns */ export const getSecretSnapshot = async (req: Request, res: Response) => { - const { secretSnapshotId } = req.params; + const { + params: { secretSnapshotId } + } = await validateRequest(reqValidator.GetSecretSnapshotV1, req); const secretSnapshot = await SecretSnapshot.findById(secretSnapshotId) .lean() @@ -20,26 +26,36 @@ export const getSecretSnapshot = async (req: Request, res: Response) => { path: "secretVersions", populate: { path: "tags", - model: "Tag", - }, + model: "Tag" + } }) .populate<{ folderVersion: TFolderRootVersionSchema }>("folderVersion"); - + if (!secretSnapshot) throw new Error("Failed to find secret snapshot"); - + + const { permission } = await getUserProjectPermissions( + req.user._id, + secretSnapshot.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SecretRollback + ); + const folderId = secretSnapshot.folderId; // to show only the folder required secrets secretSnapshot.secretVersions = secretSnapshot.secretVersions.filter( ({ folder }) => folder === folderId ); - secretSnapshot.folderVersion = - secretSnapshot?.folderVersion?.nodes?.children?.map(({ id, name }) => ({ + secretSnapshot.folderVersion = secretSnapshot?.folderVersion?.nodes?.children?.map( + ({ id, name }) => ({ id, - name, - })) as any; + name + }) + ) as any; return res.status(200).send({ - secretSnapshot, + secretSnapshot }); }; diff --git a/backend/src/ee/controllers/v1/ssoController.ts b/backend/src/ee/controllers/v1/ssoController.ts index d75cf25d2..b7ae793ab 100644 --- a/backend/src/ee/controllers/v1/ssoController.ts +++ b/backend/src/ee/controllers/v1/ssoController.ts @@ -2,239 +2,258 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { BotOrgService } from "../../../services"; import { SSOConfig } from "../../models"; -import { - AuthMethod, - MembershipOrg, - User -} from "../../../models"; +import { AuthMethod, MembershipOrg, User } from "../../../models"; import { getSSOConfigHelper } from "../../helpers/organizations"; import { client } from "../../../config"; import { ResourceNotFoundError } from "../../../utils/errors"; import { getSiteURL } from "../../../config"; import { EELicenseService } from "../../services"; +import * as reqValidator from "../../../validation/sso"; +import { validateRequest } from "../../../helpers/validation"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + getUserOrgPermissions +} from "../../services/RoleService"; +import { ForbiddenError } from "@casl/ability"; /** * Redirect user to appropriate SSO endpoint after successful authentication * to finish inputting their master key for logging in or signing up - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const redirectSSO = async (req: Request, res: Response) => { - if (req.isUserCompleted) { - return res.redirect(`${await getSiteURL()}/login/sso?token=${encodeURIComponent(req.providerAuthToken)}`); - } - - return res.redirect(`${await getSiteURL()}/signup/sso?token=${encodeURIComponent(req.providerAuthToken)}`); -} + if (req.isUserCompleted) { + return res.redirect( + `${await getSiteURL()}/login/sso?token=${encodeURIComponent(req.providerAuthToken)}` + ); + } + + return res.redirect( + `${await getSiteURL()}/signup/sso?token=${encodeURIComponent(req.providerAuthToken)}` + ); +}; /** * Return organization SAML SSO configuration - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getSSOConfig = async (req: Request, res: Response) => { - const organizationId = req.query.organizationId as string; - - const data = await getSSOConfigHelper({ - organizationId: new Types.ObjectId(organizationId) - }); + const { + query: { organizationId } + } = await validateRequest(reqValidator.GetSsoConfigv1, req); - return res.status(200).send(data); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Sso + ); + + const data = await getSSOConfigHelper({ + organizationId: new Types.ObjectId(organizationId) + }); + + return res.status(200).send(data); +}; /** * Update organization SAML SSO configuration - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const updateSSOConfig = async (req: Request, res: Response) => { + const { + body: { organizationId, authProvider, isActive, entryPoint, issuer, cert } + } = await validateRequest(reqValidator.UpdateSsoConfigv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Sso + ); + + const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); + + if (!plan.samlSSO) + return res.status(400).send({ + message: + "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + }); + + interface PatchUpdate { + authProvider?: string; + isActive?: boolean; + encryptedEntryPoint?: string; + entryPointIV?: string; + entryPointTag?: string; + encryptedIssuer?: string; + issuerIV?: string; + issuerTag?: string; + encryptedCert?: string; + certIV?: string; + certTag?: string; + } + + const update: PatchUpdate = {}; + + if (authProvider) { + update.authProvider = authProvider; + } + + if (isActive !== undefined) { + update.isActive = isActive; + } + + const key = await BotOrgService.getSymmetricKey(new Types.ObjectId(organizationId)); + + if (entryPoint) { const { - organizationId, - authProvider, - isActive, - entryPoint, - issuer, - cert, - } = req.body; + ciphertext: encryptedEntryPoint, + iv: entryPointIV, + tag: entryPointTag + } = client.encryptSymmetric(entryPoint, key); - const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); - - if (!plan.samlSSO) return res.status(400).send({ - message: "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + update.encryptedEntryPoint = encryptedEntryPoint; + update.entryPointIV = entryPointIV; + update.entryPointTag = entryPointTag; + } + + if (issuer) { + const { + ciphertext: encryptedIssuer, + iv: issuerIV, + tag: issuerTag + } = client.encryptSymmetric(issuer, key); + + update.encryptedIssuer = encryptedIssuer; + update.issuerIV = issuerIV; + update.issuerTag = issuerTag; + } + + if (cert) { + const { + ciphertext: encryptedCert, + iv: certIV, + tag: certTag + } = client.encryptSymmetric(cert, key); + + update.encryptedCert = encryptedCert; + update.certIV = certIV; + update.certTag = certTag; + } + + const ssoConfig = await SSOConfig.findOneAndUpdate( + { + organization: new Types.ObjectId(organizationId) + }, + update, + { + new: true + } + ); + + if (!ssoConfig) + throw ResourceNotFoundError({ + message: "Failed to find SSO config to update" }); - - interface PatchUpdate { - authProvider?: string; - isActive?: boolean; - encryptedEntryPoint?: string; - entryPointIV?: string; - entryPointTag?: string; - encryptedIssuer?: string; - issuerIV?: string; - issuerTag?: string; - encryptedCert?: string; - certIV?: string; - certTag?: string; - } - - const update: PatchUpdate = {}; - - if (authProvider) { - update.authProvider = authProvider; - } - - if (isActive !== undefined) { - update.isActive = isActive; - } - - const key = await BotOrgService.getSymmetricKey( - new Types.ObjectId(organizationId) - ); - - if (entryPoint) { - const { - ciphertext: encryptedEntryPoint, - iv: entryPointIV, - tag: entryPointTag - } = client.encryptSymmetric(entryPoint, key); - - update.encryptedEntryPoint = encryptedEntryPoint; - update.entryPointIV = entryPointIV; - update.entryPointTag = entryPointTag; - } - if (issuer) { - const { - ciphertext: encryptedIssuer, - iv: issuerIV, - tag: issuerTag - } = client.encryptSymmetric(issuer, key); - - update.encryptedIssuer = encryptedIssuer; - update.issuerIV = issuerIV; - update.issuerTag = issuerTag; - } + if (update.isActive !== undefined) { + const membershipOrgs = await MembershipOrg.find({ + organization: new Types.ObjectId(organizationId) + }).select("user"); - if (cert) { - const { - ciphertext: encryptedCert, - iv: certIV, - tag: certTag - } = client.encryptSymmetric(cert, key); - - update.encryptedCert = encryptedCert; - update.certIV = certIV; - update.certTag = certTag; - } - - const ssoConfig = await SSOConfig.findOneAndUpdate( + if (update.isActive) { + await User.updateMany( { - organization: new Types.ObjectId(organizationId) + _id: { + $in: membershipOrgs.map((membershipOrg) => membershipOrg.user) + } }, - update, { - new: true + authMethods: [ssoConfig.authProvider] } - ); - - if (!ssoConfig) throw ResourceNotFoundError({ - message: "Failed to find SSO config to update" - }); - - if (update.isActive !== undefined) { - const membershipOrgs = await MembershipOrg.find({ - organization: new Types.ObjectId(organizationId) - }).select("user"); - - if (update.isActive) { - await User.updateMany( - { - _id: { - $in: membershipOrgs.map((membershipOrg) => membershipOrg.user) - } - }, - { - authMethods: [ssoConfig.authProvider], - } - ); - } else { - await User.updateMany( - { - _id: { - $in: membershipOrgs.map((membershipOrg) => membershipOrg.user) - } - }, - { - authMethods: [AuthMethod.EMAIL], - } - ); + ); + } else { + await User.updateMany( + { + _id: { + $in: membershipOrgs.map((membershipOrg) => membershipOrg.user) + } + }, + { + authMethods: [AuthMethod.EMAIL] } + ); } - - return res.status(200).send(ssoConfig); -} + } + + return res.status(200).send(ssoConfig); +}; /** * Create organization SAML SSO configuration - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const createSSOConfig = async (req: Request, res: Response) => { - const { - organizationId, - authProvider, - isActive, - entryPoint, - issuer, - cert - } = req.body; + const { + body: { organizationId, authProvider, isActive, entryPoint, issuer, cert } + } = await validateRequest(reqValidator.CreateSsoConfigv1, req); - const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); - - if (!plan.samlSSO) return res.status(400).send({ - message: "Failed to create SAML SSO configuration due to plan restriction. Upgrade plan to add SSO configuration." + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Sso + ); + + const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); + + if (!plan.samlSSO) + return res.status(400).send({ + message: + "Failed to create SAML SSO configuration due to plan restriction. Upgrade plan to add SSO configuration." }); - - const key = await BotOrgService.getSymmetricKey( - new Types.ObjectId(organizationId) - ); - const { - ciphertext: encryptedEntryPoint, - iv: entryPointIV, - tag: entryPointTag - } = client.encryptSymmetric(entryPoint, key); + const key = await BotOrgService.getSymmetricKey(new Types.ObjectId(organizationId)); - const { - ciphertext: encryptedIssuer, - iv: issuerIV, - tag: issuerTag - } = client.encryptSymmetric(issuer, key); + const { + ciphertext: encryptedEntryPoint, + iv: entryPointIV, + tag: entryPointTag + } = client.encryptSymmetric(entryPoint, key); - const { - ciphertext: encryptedCert, - iv: certIV, - tag: certTag - } = client.encryptSymmetric(cert, key); - - const ssoConfig = await new SSOConfig({ - organization: new Types.ObjectId(organizationId), - authProvider, - isActive, - encryptedEntryPoint, - entryPointIV, - entryPointTag, - encryptedIssuer, - issuerIV, - issuerTag, - encryptedCert, - certIV, - certTag - }).save(); + const { + ciphertext: encryptedIssuer, + iv: issuerIV, + tag: issuerTag + } = client.encryptSymmetric(issuer, key); - return res.status(200).send(ssoConfig); -} \ No newline at end of file + const { + ciphertext: encryptedCert, + iv: certIV, + tag: certTag + } = client.encryptSymmetric(cert, key); + + const ssoConfig = await new SSOConfig({ + organization: new Types.ObjectId(organizationId), + authProvider, + isActive, + encryptedEntryPoint, + entryPointIV, + entryPointTag, + encryptedIssuer, + issuerIV, + issuerTag, + encryptedCert, + certIV, + certTag + }).save(); + + return res.status(200).send(ssoConfig); +}; diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 1e21dd591..7c9502656 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -1,6 +1,14 @@ import { Request, Response } from "express"; import { PipelineStage, Types } from "mongoose"; -import { Folder, Membership, Secret, ServiceTokenData, TFolderSchema, User } from "../../../models"; +import { + Folder, + Membership, + Secret, + ServiceTokenData, + TFolderSchema, + User, + Workspace +} from "../../../models"; import { ActorType, AuditLog, @@ -22,16 +30,33 @@ import { getLatestSecretVersionIds } from "../../helpers/secretVersion"; import { searchByFolderId } from "../../../services/FolderService"; import { EEAuditLogService, EELicenseService } from "../../services"; import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip"; +import { validateRequest } from "../../../helpers/validation"; +import { + AddWorkspaceTrustedIpV1, + DeleteWorkspaceTrustedIpV1, + GetWorkspaceAuditLogActorFilterOptsV1, + GetWorkspaceAuditLogsV1, + GetWorkspaceLogsV1, + GetWorkspaceSecretSnapshotsCountV1, + GetWorkspaceSecretSnapshotsV1, + GetWorkspaceTrustedIpsV1, + RollbackWorkspaceSecretSnapshotV1, + UpdateWorkspaceTrustedIpV1 +} from "../../../validation"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../services/ProjectRoleService"; +import { ForbiddenError } from "@casl/ability"; +import { BadRequestError } from "../../../utils/errors"; /** * Return secret snapshots for workspace with id [workspaceId] * @param req * @param res */ -export const getWorkspaceSecretSnapshots = async ( - req: Request, - res: Response -) => { +export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) => { /* #swagger.summary = 'Return project secret snapshot ids' #swagger.description = 'Return project secret snapshots ids' @@ -77,23 +102,28 @@ export const getWorkspaceSecretSnapshots = async ( } } */ - const { workspaceId } = req.params; - const { environment, folderId } = req.query; + const { + params: { workspaceId }, + query: { environment, folderId, offset, limit } + } = await validateRequest(GetWorkspaceSecretSnapshotsV1, req); - const offset: number = parseInt(req.query.offset as string); - const limit: number = parseInt(req.query.limit as string); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SecretRollback + ); const secretSnapshots = await SecretSnapshot.find({ workspace: workspaceId, environment, - folderId: folderId || "root", + folderId: folderId || "root" }) .sort({ createdAt: -1 }) .skip(offset) .limit(limit); return res.status(200).send({ - secretSnapshots, + secretSnapshots }); }; @@ -102,21 +132,26 @@ export const getWorkspaceSecretSnapshots = async ( * @param req * @param res */ -export const getWorkspaceSecretSnapshotsCount = async ( - req: Request, - res: Response -) => { - const { workspaceId } = req.params; - const { environment, folderId } = req.query; +export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Response) => { + const { + params: { workspaceId }, + query: { environment, folderId } + } = await validateRequest(GetWorkspaceSecretSnapshotsCountV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SecretRollback + ); const count = await SecretSnapshot.countDocuments({ workspace: workspaceId, environment, - folderId: folderId || "root", + folderId: folderId || "root" }); return res.status(200).send({ - count, + count }); }; @@ -126,10 +161,7 @@ export const getWorkspaceSecretSnapshotsCount = async ( * @param res * @returns */ -export const rollbackWorkspaceSecretSnapshot = async ( - req: Request, - res: Response -) => { +export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Response) => { /* #swagger.summary = 'Roll back project secrets to those captured in a secret snapshot version.' #swagger.description = 'Roll back project secrets to those captured in a secret snapshot version.' @@ -181,19 +213,27 @@ export const rollbackWorkspaceSecretSnapshot = async ( } */ - const { workspaceId } = req.params; - const { version, environment, folderId = "root" } = req.body; + const { + params: { workspaceId }, + body: { folderId, environment, version } + } = await validateRequest(RollbackWorkspaceSecretSnapshotV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SecretRollback + ); // validate secret snapshot const secretSnapshot = await SecretSnapshot.findOne({ workspace: workspaceId, version, environment, - folderId: folderId, + folderId: folderId }) .populate<{ secretVersions: ISecretVersion[] }>({ path: "secretVersions", - select: "+secretBlindIndex", + select: "+secretBlindIndex" }) .populate<{ folderVersion: TFolderRootVersionSchema }>("folderVersion"); @@ -202,13 +242,13 @@ export const rollbackWorkspaceSecretSnapshot = async ( const snapshotFolderTree = secretSnapshot.folderVersion; const latestFolderTree = await Folder.findOne({ workspace: workspaceId, - environment, + environment }); const latestFolderVersion = await FolderVersion.findOne({ environment, workspace: workspaceId, - "nodes.id": folderId, + "nodes.id": folderId }).sort({ "nodes.version": -1 }); const oldSecretVersionsObj: Record = {}; @@ -222,8 +262,7 @@ export const rollbackWorkspaceSecretSnapshot = async ( // the parent node from current latest one // this will be modified according to the snapshot and latest snapshots - const newFolderTree = - latestFolderTree && searchByFolderId(latestFolderTree.nodes, folderId); + const newFolderTree = latestFolderTree && searchByFolderId(latestFolderTree.nodes, folderId); if (newFolderTree) { newFolderTree.children = snapshotFolderTree?.nodes?.children || []; @@ -252,43 +291,43 @@ export const rollbackWorkspaceSecretSnapshot = async ( workspace: new Types.ObjectId(workspaceId), environment, folderId: { - $in: Object.keys(groupByFolderId), - }, - }, + $in: Object.keys(groupByFolderId) + } + } }; const sortByFolderIdAndVersion: PipelineStage = { - $sort: { folderId: 1, version: -1 }, + $sort: { folderId: 1, version: -1 } }; const pickLatestVersionOfEachFolder = { $group: { _id: "$folderId", latestVersion: { $first: "$version" }, doc: { - $first: "$$ROOT", - }, - }, + $first: "$$ROOT" + } + } }; const populateSecVersion = { $lookup: { from: SecretVersion.collection.name, localField: "doc.secretVersions", foreignField: "_id", - as: "doc.secretVersions", - }, + as: "doc.secretVersions" + } }; const populateFolderVersion = { $lookup: { from: FolderVersion.collection.name, localField: "doc.folderVersion", foreignField: "_id", - as: "doc.folderVersion", - }, + as: "doc.folderVersion" + } }; const unwindFolderVerField = { $unwind: { path: "$doc.folderVersion", - preserveNullAndEmptyArrays: true, - }, + preserveNullAndEmptyArrays: true + } }; const latestSnapshotsByFolders: Array<{ doc: typeof secretSnapshot }> = await SecretSnapshot.aggregate([ @@ -297,7 +336,7 @@ export const rollbackWorkspaceSecretSnapshot = async ( pickLatestVersionOfEachFolder, populateSecVersion, populateFolderVersion, - unwindFolderVerField, + unwindFolderVerField ]); // recursive snapshotting each level @@ -327,7 +366,7 @@ export const rollbackWorkspaceSecretSnapshot = async ( // TODO: fix any const latestSecretVersionIds = await getLatestSecretVersionIds({ - secretIds, + secretIds }); // TODO: fix any @@ -335,32 +374,31 @@ export const rollbackWorkspaceSecretSnapshot = async ( await SecretVersion.find( { _id: { - $in: latestSecretVersionIds.map((s) => s.versionId), - }, + $in: latestSecretVersionIds.map((s) => s.versionId) + } }, "secret version" ) ).reduce( (accumulator, s) => ({ ...accumulator, - [`${s.secret.toString()}`]: s, + [`${s.secret.toString()}`]: s }), {} ); const secDelQuery: Record = { workspace: workspaceId, - environment, + environment // undefined means root thus collect all secrets }; - if (folderId !== "root" && folderIds.length) - secDelQuery.folder = { $in: folderIds }; + if (folderId !== "root" && folderIds.length) secDelQuery.folder = { $in: folderIds }; // delete existing secrets await Secret.deleteMany(secDelQuery); await Folder.deleteOne({ workspace: workspaceId, - environment, + environment }); // add secrets @@ -382,7 +420,7 @@ export const rollbackWorkspaceSecretSnapshot = async ( createdAt, algorithm, keyEncoding, - folder: secFolderId, + folder: secFolderId } = oldSecretVersionsObj[sv]; return { @@ -405,7 +443,7 @@ export const rollbackWorkspaceSecretSnapshot = async ( createdAt, algorithm, keyEncoding, - folder: secFolderId, + folder: secFolderId }; }) ); @@ -429,7 +467,7 @@ export const rollbackWorkspaceSecretSnapshot = async ( secretValueTag, algorithm, keyEncoding, - folder: secFolderId, + folder: secFolderId }) => ({ _id: new Types.ObjectId(), secret: _id, @@ -448,7 +486,7 @@ export const rollbackWorkspaceSecretSnapshot = async ( secretValueTag, algorithm, keyEncoding, - folder: secFolderId, + folder: secFolderId }) ) ); @@ -464,7 +502,7 @@ export const rollbackWorkspaceSecretSnapshot = async ( const newFolderVersion = new FolderVersion({ workspace: workspaceId, environment, - nodes: newFolderTree, + nodes: newFolderTree }); await newFolderVersion.save(); } @@ -473,13 +511,11 @@ export const rollbackWorkspaceSecretSnapshot = async ( await SecretVersion.updateMany( { secret: { - $in: Object.keys(oldSecretVersionsObj).map( - (sv) => oldSecretVersionsObj[sv].secret - ), - }, + $in: Object.keys(oldSecretVersionsObj).map((sv) => oldSecretVersionsObj[sv].secret) + } }, { - isDeleted: false, + isDeleted: false } ); @@ -487,11 +523,11 @@ export const rollbackWorkspaceSecretSnapshot = async ( await EESecretService.takeSecretSnapshot({ workspaceId: new Types.ObjectId(workspaceId), environment, - folderId, + folderId }); return res.status(200).send({ - secrets, + secrets }); }; @@ -568,13 +604,16 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { } } */ - const { workspaceId } = req.params; + const { + query: { limit, offset, userId, sortBy, actionNames }, + params: { workspaceId } + } = await validateRequest(GetWorkspaceLogsV1, req); - const offset: number = parseInt(req.query.offset as string); - const limit: number = parseInt(req.query.limit as string); - const sortBy: string = req.query.sortBy as string; - const userId: string = req.query.userId as string; - const actionNames: string = req.query.actionNames as string; + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.AuditLogs + ); const logs = await Log.find({ workspace: workspaceId, @@ -582,10 +621,10 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { ...(actionNames ? { actionNames: { - $in: actionNames.split(","), - }, + $in: actionNames.split(",") + } } - : {}), + : {}) }) .sort({ createdAt: sortBy === "recent" ? -1 : 1 }) .skip(offset) @@ -594,93 +633,109 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { .populate("user serviceAccount serviceTokenData"); return res.status(200).send({ - logs, + logs }); }; /** * Return audit logs for workspace with id [workspaceId] * @param req - * @param res + * @param res */ export const getWorkspaceAuditLogs = async (req: Request, res: Response) => { - const { workspaceId } = req.params; - const eventType = req.query.eventType; - const userAgentType = req.query.userAgentType; - const actor = req.query.actor as string | undefined; - const offset: number = parseInt(req.query.offset as string); - const limit: number = parseInt(req.query.limit as string); - - const startDate = req.query.startDate as string; - const endDate = req.query.endDate as string; - + const { + query: { limit, offset, endDate, eventType, startDate, userAgentType, actor }, + params: { workspaceId } + } = await validateRequest(GetWorkspaceAuditLogsV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.AuditLogs + ); + const query = { workspace: new Types.ObjectId(workspaceId), - ...(eventType ? { - "event.type": eventType - } : {}), - ...(userAgentType ? { - userAgentType - } : {}), - ...(actor ? { - "actor.type": actor.split("-", 2)[0], - ...(actor.split("-", 2)[0] === ActorType.USER ? { - "actor.metadata.userId": actor.split("-", 2)[1] - } : { - "actor.metadata.serviceId": actor.split("-", 2)[1] - }) - } : {}), - ...(startDate || endDate ? { - createdAt: { - ...(startDate && { $gte: new Date(startDate) }), - ...(endDate && { $lte: new Date(endDate) }) - } - } : {}) - } - - const auditLogs = await AuditLog.find(query) - .sort({ createdAt: -1 }) - .skip(offset) - .limit(limit); + ...(eventType + ? { + "event.type": eventType + } + : {}), + ...(userAgentType + ? { + userAgentType + } + : {}), + ...(actor + ? { + "actor.type": actor.split("-", 2)[0], + ...(actor.split("-", 2)[0] === ActorType.USER + ? { + "actor.metadata.userId": actor.split("-", 2)[1] + } + : { + "actor.metadata.serviceId": actor.split("-", 2)[1] + }) + } + : {}), + ...(startDate || endDate + ? { + createdAt: { + ...(startDate && { $gte: new Date(startDate) }), + ...(endDate && { $lte: new Date(endDate) }) + } + } + : {}) + }; + + const auditLogs = await AuditLog.find(query).sort({ createdAt: -1 }).skip(offset).limit(limit); const totalCount = await AuditLog.countDocuments(query); - + return res.status(200).send({ auditLogs, totalCount }); -} +}; /** * Return audit log actor filter options for workspace with id [workspaceId] * @param req - * @param res + * @param res */ export const getWorkspaceAuditLogActorFilterOpts = async (req: Request, res: Response) => { - const { workspaceId } = req.params; - + const { + params: { workspaceId } + } = await validateRequest(GetWorkspaceAuditLogActorFilterOptsV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.AuditLogs + ); + const userIds = await Membership.distinct("user", { workspace: new Types.ObjectId(workspaceId) }); - const userActors: UserActor[] = (await User.find({ - _id: { - $in: userIds - } - }) - .select("email")) - .map((user) => ({ + const userActors: UserActor[] = ( + await User.find({ + _id: { + $in: userIds + } + }).select("email") + ).map((user) => ({ type: ActorType.USER, metadata: { userId: user._id.toString(), email: user.email } })); - - const serviceActors: ServiceActor[] = (await ServiceTokenData.find({ - workspace: new Types.ObjectId(workspaceId) - }) - .select("name")) - .map((serviceTokenData) => ({ + + const serviceActors: ServiceActor[] = ( + await ServiceTokenData.find({ + workspace: new Types.ObjectId(workspaceId) + }).select("name") + ).map((serviceTokenData) => ({ type: ActorType.SERVICE, metadata: { serviceId: serviceTokenData._id.toString(), @@ -691,50 +746,68 @@ export const getWorkspaceAuditLogActorFilterOpts = async (req: Request, res: Res return res.status(200).send({ actors: [...userActors, ...serviceActors] }); -} +}; /** * Return trusted ips for workspace with id [workspaceId] * @param req - * @param res + * @param res */ export const getWorkspaceTrustedIps = async (req: Request, res: Response) => { - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(GetWorkspaceTrustedIpsV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.IpAllowList + ); const trustedIps = await TrustedIP.find({ workspace: new Types.ObjectId(workspaceId) }); - + return res.status(200).send({ trustedIps }); -} +}; /** * Add a trusted ip to workspace with id [workspaceId] - * @param req - * @param res + * @param req + * @param res */ export const addWorkspaceTrustedIp = async (req: Request, res: Response) => { - const { workspaceId } = req.params; const { - ipAddress: ip, - comment, - isActive - } = req.body; - - const plan = await EELicenseService.getPlan(req.workspace.organization); - - if (!plan.ipAllowlisting) return res.status(400).send({ - message: "Failed to add IP access range due to plan restriction. Upgrade plan to add IP access range." - }); - + params: { workspaceId }, + body: { comment, isActive, ipAddress: ip } + } = await validateRequest(AddWorkspaceTrustedIpV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.IpAllowList + ); + + const workspace = await Workspace.findById(workspaceId); + if (!workspace) throw BadRequestError({ message: "Workspace not found" }); + + const plan = await EELicenseService.getPlan(workspace.organization); + + if (!plan.ipAllowlisting) + return res.status(400).send({ + message: + "Failed to add IP access range due to plan restriction. Upgrade plan to add IP access range." + }); + const isValidIPOrCidr = isValidIpOrCidr(ip); - - if (!isValidIPOrCidr) return res.status(400).send({ - message: "The IP is not a valid IPv4, IPv6, or CIDR block" - }); - + + if (!isValidIPOrCidr) + return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + const { ipAddress, type, prefix } = extractIPDetails(ip); const trustedIp = await new TrustedIP({ @@ -743,9 +816,9 @@ export const addWorkspaceTrustedIp = async (req: Request, res: Response) => { type, prefix, isActive, - comment, + comment }).save(); - + await EEAuditLogService.createAuditLog( req.authData, { @@ -764,32 +837,43 @@ export const addWorkspaceTrustedIp = async (req: Request, res: Response) => { return res.status(200).send({ trustedIp }); -} +}; /** * Update trusted ip with id [trustedIpId] workspace with id [workspaceId] - * @param req - * @param res + * @param req + * @param res */ export const updateWorkspaceTrustedIp = async (req: Request, res: Response) => { - const { workspaceId, trustedIpId } = req.params; const { - ipAddress: ip, - comment - } = req.body; + params: { workspaceId, trustedIpId }, + body: { ipAddress: ip, comment } + } = await validateRequest(UpdateWorkspaceTrustedIpV1, req); - const plan = await EELicenseService.getPlan(req.workspace.organization); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.IpAllowList + ); - if (!plan.ipAllowlisting) return res.status(400).send({ - message: "Failed to update IP access range due to plan restriction. Upgrade plan to update IP access range." - }); + const workspace = await Workspace.findById(workspaceId); + if (!workspace) throw BadRequestError({ message: "Workspace not found" }); + + const plan = await EELicenseService.getPlan(workspace.organization); + + if (!plan.ipAllowlisting) + return res.status(400).send({ + message: + "Failed to update IP access range due to plan restriction. Upgrade plan to update IP access range." + }); const isValidIPOrCidr = isValidIpOrCidr(ip); - - if (!isValidIPOrCidr) return res.status(400).send({ - message: "The IP is not a valid IPv4, IPv6, or CIDR block" - }); - + + if (!isValidIPOrCidr) + return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + const { ipAddress, type, prefix } = extractIPDetails(ip); const updateObject: { @@ -799,33 +883,34 @@ export const updateWorkspaceTrustedIp = async (req: Request, res: Response) => { prefix?: number; $unset?: { prefix: number; - } + }; } = { ipAddress, type, comment }; - + if (prefix !== undefined) { updateObject.prefix = prefix; } else { updateObject.$unset = { prefix: 1 }; } - + const trustedIp = await TrustedIP.findOneAndUpdate( { _id: new Types.ObjectId(trustedIpId), - workspace: new Types.ObjectId(workspaceId), + workspace: new Types.ObjectId(workspaceId) }, updateObject, { new: true } ); - - if (!trustedIp) return res.status(400).send({ - message: "Failed to update trusted IP" - }); + + if (!trustedIp) + return res.status(400).send({ + message: "Failed to update trusted IP" + }); await EEAuditLogService.createAuditLog( req.authData, @@ -841,34 +926,48 @@ export const updateWorkspaceTrustedIp = async (req: Request, res: Response) => { workspaceId: trustedIp.workspace } ); - + return res.status(200).send({ trustedIp }); -} +}; /** * Delete IP access range from workspace with id [workspaceId] - * @param req - * @param res + * @param req + * @param res */ export const deleteWorkspaceTrustedIp = async (req: Request, res: Response) => { - const { workspaceId, trustedIpId } = req.params; + const { + params: { workspaceId, trustedIpId } + } = await validateRequest(DeleteWorkspaceTrustedIpV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.IpAllowList + ); + + const workspace = await Workspace.findById(workspaceId); + if (!workspace) throw BadRequestError({ message: "Workspace not found" }); + + const plan = await EELicenseService.getPlan(workspace.organization); + + if (!plan.ipAllowlisting) + return res.status(400).send({ + message: + "Failed to delete IP access range due to plan restriction. Upgrade plan to delete IP access range." + }); - const plan = await EELicenseService.getPlan(req.workspace.organization); - - if (!plan.ipAllowlisting) return res.status(400).send({ - message: "Failed to delete IP access range due to plan restriction. Upgrade plan to delete IP access range." - }); - const trustedIp = await TrustedIP.findOneAndDelete({ _id: new Types.ObjectId(trustedIpId), workspace: new Types.ObjectId(workspaceId) }); - - if (!trustedIp) return res.status(400).send({ - message: "Failed to delete trusted IP" - }); + + if (!trustedIp) + return res.status(400).send({ + message: "Failed to delete trusted IP" + }); await EEAuditLogService.createAuditLog( req.authData, @@ -888,4 +987,4 @@ export const deleteWorkspaceTrustedIp = async (req: Request, res: Response) => { return res.status(200).send({ trustedIp }); -} \ No newline at end of file +}; diff --git a/backend/src/ee/models/role.ts b/backend/src/ee/models/role.ts new file mode 100644 index 000000000..643acedcd --- /dev/null +++ b/backend/src/ee/models/role.ts @@ -0,0 +1,55 @@ +import { Schema, Types, model } from "mongoose"; + +export interface IRole { + _id: Types.ObjectId; + name: string; + description: string; + slug: string; + permissions: Array; + workspace: Types.ObjectId; + organization: Types.ObjectId; + isOrgRole: boolean; +} + +const roleSchema = new Schema( + { + name: { + type: String, + required: true + }, + organization: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true + }, + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace" + }, + isOrgRole: { + type: Boolean, + required: true, + select: false + }, + description: { + type: String + }, + slug: { + type: String, + required: true + }, + permissions: { + type: Array, + required: true + } + }, + { + timestamps: true + } +); + +roleSchema.index({ organization: 1, workspace: 1 }); + +const Role = model("Role", roleSchema); + +export default Role; diff --git a/backend/src/ee/routes/v1/action.ts b/backend/src/ee/routes/v1/action.ts index b77a84188..4700c5a59 100644 --- a/backend/src/ee/routes/v1/action.ts +++ b/backend/src/ee/routes/v1/action.ts @@ -1,17 +1,8 @@ import express from "express"; const router = express.Router(); -import { - validateRequest, -} from "../../../middleware"; -import { param } from "express-validator"; import { actionController } from "../../controllers/v1"; // TODO: put into action controller -router.get( - "/:actionId", - param("actionId").exists().trim(), - validateRequest, - actionController.getAction -); +router.get("/:actionId", actionController.getAction); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/ee/routes/v1/cloudProducts.ts b/backend/src/ee/routes/v1/cloudProducts.ts index 81256f378..23912222b 100644 --- a/backend/src/ee/routes/v1/cloudProducts.ts +++ b/backend/src/ee/routes/v1/cloudProducts.ts @@ -1,21 +1,16 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - validateRequest, -} from "../../../middleware"; -import { query } from "express-validator"; +import { requireAuth, validateRequest } from "../../../middleware"; import { cloudProductsController } from "../../controllers/v1"; import { AuthMode } from "../../../variables"; router.get( - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - query("billing-cycle").exists().isIn(["monthly", "yearly"]), - validateRequest, - cloudProductsController.getCloudProducts + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + validateRequest, + cloudProductsController.getCloudProducts ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index be4847cef..9b8d1bea3 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -7,15 +7,17 @@ import workspace from "./workspace"; import action from "./action"; import cloudProducts from "./cloudProducts"; import secretScanning from "./secretScanning"; +import roles from "./role"; export { - secret, - secretSnapshot, - organizations, - sso, - users, - workspace, - action, - cloudProducts, - secretScanning -} \ No newline at end of file + secret, + secretSnapshot, + organizations, + sso, + users, + workspace, + action, + cloudProducts, + secretScanning, + roles +}; diff --git a/backend/src/ee/routes/v1/organizations.ts b/backend/src/ee/routes/v1/organizations.ts index 6506d3afa..bbe5019b4 100644 --- a/backend/src/ee/routes/v1/organizations.ts +++ b/backend/src/ee/routes/v1/organizations.ts @@ -1,237 +1,127 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - requireOrganizationAuth, - validateRequest, -} from "../../../middleware"; -import { body, param, query } from "express-validator"; +import { requireAuth } from "../../../middleware"; import { organizationsController } from "../../controllers/v1"; -import { - ACCEPTED, ADMIN, AuthMode, MEMBER, OWNER -} from "../../../variables"; +import { AuthMode } from "../../../variables"; router.get( - "/:organizationId/plans/table", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - query("billingCycle").exists().isString().isIn(["monthly", "yearly"]), - validateRequest, - organizationsController.getOrganizationPlansTable + "/:organizationId/plans/table", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationPlansTable ); router.get( - "/:organizationId/plan", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - query("workspaceId").optional().isString(), - validateRequest, - organizationsController.getOrganizationPlan + "/:organizationId/plan", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationPlan ); router.post( - "/:organizationId/session/trial", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("success_url").exists().trim(), - validateRequest, - organizationsController.startOrganizationTrial + "/:organizationId/session/trial", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.startOrganizationTrial ); router.get( - "/:organizationId/plan/billing", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - query("workspaceId").optional().isString(), - validateRequest, - organizationsController.getOrganizationPlanBillingInfo + "/:organizationId/plan/billing", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationPlanBillingInfo ); router.get( - "/:organizationId/plan/table", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - query("workspaceId").optional().isString(), - validateRequest, - organizationsController.getOrganizationPlanTable + "/:organizationId/plan/table", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationPlanTable ); router.get( - "/:organizationId/billing-details", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationsController.getOrganizationBillingDetails + "/:organizationId/billing-details", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationBillingDetails ); router.patch( - "/:organizationId/billing-details", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("email").optional().isString().trim(), - body("name").optional().isString().trim(), - validateRequest, - organizationsController.updateOrganizationBillingDetails + "/:organizationId/billing-details", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.updateOrganizationBillingDetails ); router.get( - "/:organizationId/billing-details/payment-methods", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationsController.getOrganizationPmtMethods + "/:organizationId/billing-details/payment-methods", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationPmtMethods ); router.post( - "/:organizationId/billing-details/payment-methods", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("success_url").exists().isString(), - body("cancel_url").exists().isString(), - validateRequest, - organizationsController.addOrganizationPmtMethod + "/:organizationId/billing-details/payment-methods", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.addOrganizationPmtMethod ); router.delete( - "/:organizationId/billing-details/payment-methods/:pmtMethodId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - param("pmtMethodId").exists().trim(), - validateRequest, - organizationsController.deleteOrganizationPmtMethod + "/:organizationId/billing-details/payment-methods/:pmtMethodId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.deleteOrganizationPmtMethod ); router.get( - "/:organizationId/billing-details/tax-ids", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationsController.getOrganizationTaxIds + "/:organizationId/billing-details/tax-ids", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationTaxIds ); router.post( - "/:organizationId/billing-details/tax-ids", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("type").exists().isString(), - body("value").exists().isString(), - validateRequest, - organizationsController.addOrganizationTaxId + "/:organizationId/billing-details/tax-ids", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.addOrganizationTaxId ); router.delete( - "/:organizationId/billing-details/tax-ids/:taxId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - param("taxId").exists().trim(), - validateRequest, - organizationsController.deleteOrganizationTaxId + "/:organizationId/billing-details/tax-ids/:taxId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.deleteOrganizationTaxId ); router.get( - "/:organizationId/invoices", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationsController.getOrganizationInvoices + "/:organizationId/invoices", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationInvoices ); router.get( - "/:organizationId/licenses", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationsController.getOrganizationLicenses + "/:organizationId/licenses", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationLicenses ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/ee/routes/v1/role.ts b/backend/src/ee/routes/v1/role.ts new file mode 100644 index 000000000..0794b6b19 --- /dev/null +++ b/backend/src/ee/routes/v1/role.ts @@ -0,0 +1,33 @@ +import express from "express"; +import { roleController } from "../../controllers/v1"; +import { requireAuth } from "../../../middleware"; +import { AuthMode } from "../../../variables"; + +const router = express.Router(); + +router.post("/", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), roleController.createRole); + +router.patch("/:id", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), roleController.updateRole); + +router.delete( + "/:id", + requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), + roleController.deleteRole +); + +router.get("/", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), roleController.getRoles); + +// get a user permissions in an org +router.get( + "/organization/:orgId/permissions", + requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), + roleController.getUserPermissions +); + +router.get( + "/workspace/:workspaceId/permissions", + requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), + roleController.getUserWorkspacePermissions +); + +export default router; diff --git a/backend/src/ee/routes/v1/secret.ts b/backend/src/ee/routes/v1/secret.ts index 0eb23ee80..bdbdec965 100644 --- a/backend/src/ee/routes/v1/secret.ts +++ b/backend/src/ee/routes/v1/secret.ts @@ -1,48 +1,25 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - requireSecretAuth, - validateRequest, -} from "../../../middleware"; -import { body, param, query } from "express-validator"; +import { requireAuth } from "../../../middleware"; import { secretController } from "../../controllers/v1"; import { - ADMIN, - AuthMode, - MEMBER, - PERMISSION_READ_SECRETS, - PERMISSION_WRITE_SECRETS + AuthMode } from "../../../variables"; router.get( - "/:secretId/secret-versions", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireSecretAuth({ - acceptedRoles: [ADMIN, MEMBER], - requiredPermissions: [PERMISSION_READ_SECRETS], - }), - param("secretId").exists().trim(), - query("offset").exists().isInt(), - query("limit").exists().isInt(), - validateRequest, - secretController.getSecretVersions + "/:secretId/secret-versions", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + secretController.getSecretVersions ); router.post( - "/:secretId/secret-versions/rollback", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireSecretAuth({ - acceptedRoles: [ADMIN, MEMBER], - requiredPermissions: [PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS], - }), - param("secretId").exists().trim(), - body("version").exists().isInt(), - secretController.rollbackSecretVersion + "/:secretId/secret-versions/rollback", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + secretController.rollbackSecretVersion ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/ee/routes/v1/secretScanning.ts b/backend/src/ee/routes/v1/secretScanning.ts index 1cc3adc46..0afdf0545 100644 --- a/backend/src/ee/routes/v1/secretScanning.ts +++ b/backend/src/ee/routes/v1/secretScanning.ts @@ -1,81 +1,53 @@ import express from "express"; const router = express.Router(); +import { requireAuth } from "../../../middleware"; import { - requireAuth, - requireOrganizationAuth, - validateRequest, -} from "../../../middleware"; -import { body, param } from "express-validator"; -import { createInstallationSession, getCurrentOrganizationInstallationStatus, getRisksForOrganization, linkInstallationToOrganization, updateRisksStatus } from "../../../controllers/v1/secretScanningController"; -import { ACCEPTED, ADMIN, AuthMode, MEMBER, OWNER } from "../../../variables"; + createInstallationSession, + getCurrentOrganizationInstallationStatus, + getRisksForOrganization, + linkInstallationToOrganization, + updateRisksStatus +} from "../../../controllers/v1/secretScanningController"; +import { AuthMode } from "../../../variables"; router.post( "/create-installation-session/organization/:organizationId", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - param("organizationId").exists().trim(), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - validateRequest, createInstallationSession ); router.post( "/link-installation", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - body("installationId").exists().trim(), - body("sessionId").exists().trim(), - validateRequest, linkInstallationToOrganization ); router.get( "/installation-status/organization/:organizationId", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - param("organizationId").exists().trim(), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - validateRequest, getCurrentOrganizationInstallationStatus ); router.get( "/organization/:organizationId/risks", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - param("organizationId").exists().trim(), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - validateRequest, getRisksForOrganization ); router.post( "/organization/:organizationId/risks/:riskId/status", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - param("organizationId").exists().trim(), - param("riskId").exists().trim(), - body("status").exists(), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - validateRequest, updateRisksStatus ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/ee/routes/v1/secretSnapshot.ts b/backend/src/ee/routes/v1/secretSnapshot.ts index ecfe47ca5..f8c643e60 100644 --- a/backend/src/ee/routes/v1/secretSnapshot.ts +++ b/backend/src/ee/routes/v1/secretSnapshot.ts @@ -1,27 +1,15 @@ import express from "express"; const router = express.Router(); -import { - requireSecretSnapshotAuth, -} from "../../middleware"; -import { - requireAuth, - validateRequest, -} from "../../../middleware"; -import { param } from "express-validator"; -import { ADMIN, AuthMode, MEMBER } from "../../../variables"; +import { requireAuth } from "../../../middleware"; +import { AuthMode } from "../../../variables"; import { secretSnapshotController } from "../../controllers/v1"; router.get( - "/:secretSnapshotId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireSecretSnapshotAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("secretSnapshotId").exists().trim(), - validateRequest, - secretSnapshotController.getSecretSnapshot + "/:secretSnapshotId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretSnapshotController.getSecretSnapshot ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/ee/routes/v1/sso.ts b/backend/src/ee/routes/v1/sso.ts index df4242cc8..66733a124 100644 --- a/backend/src/ee/routes/v1/sso.ts +++ b/backend/src/ee/routes/v1/sso.ts @@ -1,66 +1,49 @@ import express from "express"; const router = express.Router(); import passport from "passport"; -import { - AuthProvider -} from "../../models"; -import { - requireAuth, - requireOrganizationAuth, - validateRequest, -} from "../../../middleware"; -import { body, query } from "express-validator"; +import { requireAuth } from "../../../middleware"; import { ssoController } from "../../controllers/v1"; import { authLimiter } from "../../../helpers/rateLimiter"; -import { - ACCEPTED, - ADMIN, - AuthMode, - OWNER -} from "../../../variables"; +import { AuthMode } from "../../../variables"; -router.get( - "/redirect/google", - authLimiter, - (req, res, next) => { - passport.authenticate("google", { - scope: ["profile", "email"], - session: false, - ...(req.query.callback_port ? { - state: req.query.callback_port as string - } : {}) - })(req, res, next); - } -); +router.get("/redirect/google", authLimiter, (req, res, next) => { + passport.authenticate("google", { + scope: ["profile", "email"], + session: false, + ...(req.query.callback_port + ? { + state: req.query.callback_port as string + } + : {}) + })(req, res, next); +}); router.get( "/google", - passport.authenticate("google", { - failureRedirect: "/login/provider/error", - session: false + passport.authenticate("google", { + failureRedirect: "/login/provider/error", + session: false }), ssoController.redirectSSO ); -router.get( - "/redirect/github", - authLimiter, - (req, res, next) => { - passport.authenticate("github", { - session: false, - ...(req.query.callback_port ? { - state: req.query.callback_port as string - } : {}) - })(req, res, next); - } -); +router.get("/redirect/github", authLimiter, (req, res, next) => { + passport.authenticate("github", { + session: false, + ...(req.query.callback_port + ? { + state: req.query.callback_port as string + } + : {}) + })(req, res, next); +}); router.get( "/github", authLimiter, - passport.authenticate("github", { - failureRedirect: "/login/provider/error", - session: false + passport.authenticate("github", { + failureRedirect: "/login/provider/error", + session: false }), ssoController.redirectSSO ); @@ -102,68 +85,38 @@ router.get( } ); -router.post("/saml2/:ssoIdentifier", - passport.authenticate("saml", { - failureRedirect: "/login/provider/error", - failureFlash: true, +router.post( + "/saml2/:ssoIdentifier", + passport.authenticate("saml", { + failureRedirect: "/login/provider/error", + failureFlash: true, session: false }), ssoController.redirectSSO ); router.get( - "/config", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - locationOrganizationId: "query" - }), - query("organizationId").exists().trim(), - validateRequest, - ssoController.getSSOConfig + "/config", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + ssoController.getSSOConfig ); router.post( - "/config", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - locationOrganizationId: "body" - }), - body("organizationId").exists().trim(), - body("authProvider").exists().isString().isIn([AuthProvider.OKTA_SAML]), - body("isActive").exists().isBoolean(), - body("entryPoint").exists().isString(), - body("issuer").exists().isString(), - body("cert").exists().isString(), - validateRequest, - ssoController.createSSOConfig + "/config", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + ssoController.createSSOConfig ); router.patch( - "/config", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - locationOrganizationId: "body" - }), - body("organizationId").exists().trim(), - body("authProvider").optional().isString(), - body("isActive").optional().isBoolean(), - body("entryPoint").optional().isString(), - body("issuer").optional().isString(), - body("cert").optional().isString(), - validateRequest, - ssoController.updateSSOConfig + "/config", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + ssoController.updateSSOConfig ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/ee/routes/v1/workspace.ts b/backend/src/ee/routes/v1/workspace.ts index 529ba0550..9a7054ce6 100644 --- a/backend/src/ee/routes/v1/workspace.ts +++ b/backend/src/ee/routes/v1/workspace.ts @@ -1,182 +1,85 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - requireWorkspaceAuth, - validateRequest, -} from "../../../middleware"; -import { body, param, query } from "express-validator"; -import { - ADMIN, - AuthMode, - MEMBER -} from "../../../variables"; +import { requireAuth } from "../../../middleware"; +import { AuthMode } from "../../../variables"; import { workspaceController } from "../../controllers/v1"; -import { EventType, UserAgentType } from "../../models"; router.get( "/:workspaceId/secret-snapshots", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - query("environment").isString().exists().trim(), - query("folderId").default("root").isString().trim(), - query("offset").exists().isInt(), - query("limit").exists().isInt(), - validateRequest, workspaceController.getWorkspaceSecretSnapshots ); router.get( "/:workspaceId/secret-snapshots/count", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - query("environment").isString().exists().trim(), - query("folderId").default("root").isString().trim(), - validateRequest, workspaceController.getWorkspaceSecretSnapshotsCount ); router.post( "/:workspaceId/secret-snapshots/rollback", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - body("environment").isString().exists().trim(), - query("folderId").default("root").isString().exists().trim(), - body("version").exists().isInt(), - validateRequest, workspaceController.rollbackWorkspaceSecretSnapshot ); router.get( "/:workspaceId/logs", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - query("offset").exists().isInt(), - query("limit").exists().isInt(), - query("sortBy"), - query("userId"), - query("actionNames"), - validateRequest, workspaceController.getWorkspaceLogs ); router.get( "/:workspaceId/audit-logs", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - query("eventType").isString().isIn(Object.values(EventType)).optional({ nullable: true }), - query("userAgentType").isString().isIn(Object.values(UserAgentType)).optional({ nullable: true }), - query("actor").optional({ nullable: true }), - query("startDate").isISO8601().withMessage("Invalid start date format").optional({ nullable: true }), - query("endDate").isISO8601().withMessage("Invalid end date format").optional({ nullable: true }), - query("offset"), - query("limit"), - validateRequest, workspaceController.getWorkspaceAuditLogs ); router.get( "/:workspaceId/audit-logs/filters/actors", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - validateRequest, workspaceController.getWorkspaceAuditLogActorFilterOpts ); router.get( "/:workspaceId/trusted-ips", - param("workspaceId").exists().isString().trim(), requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", + acceptedAuthModes: [AuthMode.JWT] }), workspaceController.getWorkspaceTrustedIps ); router.post( "/:workspaceId/trusted-ips", - param("workspaceId").exists().isString().trim(), - body("ipAddress").exists().isString().trim(), - body("comment").default("").isString().trim(), - body("isActive").exists().isBoolean(), - validateRequest, requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN], - locationWorkspaceId: "params", + acceptedAuthModes: [AuthMode.JWT] }), workspaceController.addWorkspaceTrustedIp ); router.patch( "/:workspaceId/trusted-ips/:trustedIpId", - param("workspaceId").exists().isString().trim(), - param("trustedIpId").exists().isString().trim(), - body("ipAddress").isString().trim().default(""), - body("comment").default("").isString().trim(), - validateRequest, requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN], - locationWorkspaceId: "params", + acceptedAuthModes: [AuthMode.JWT] }), workspaceController.updateWorkspaceTrustedIp ); router.delete( "/:workspaceId/trusted-ips/:trustedIpId", - param("workspaceId").exists().isString().trim(), - param("trustedIpId").exists().isString().trim(), - validateRequest, requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN], - locationWorkspaceId: "params", + acceptedAuthModes: [AuthMode.JWT] }), workspaceController.deleteWorkspaceTrustedIp ); diff --git a/backend/src/ee/services/GithubSecretScanning/helper.ts b/backend/src/ee/services/GithubSecretScanning/helper.ts index aea025410..3dc46d835 100644 --- a/backend/src/ee/services/GithubSecretScanning/helper.ts +++ b/backend/src/ee/services/GithubSecretScanning/helper.ts @@ -3,7 +3,21 @@ import { mkdir, readFile, rm, writeFile } from "fs"; import { tmpdir } from "os"; import { join } from "path" import { SecretMatch } from "./types"; -import { Octokit } from "@octokit/rest"; + +export async function scanFullRepoContentAndGetFindings(octokit: any, installationId: number, repositoryFullName: string): Promise { + const tempFolder = await createTempFolder(); + const findingsPath = join(tempFolder, "findings.json"); + const repoPath = join(tempFolder, "repo.git") + try { + const { data: { token }} = await octokit.apps.createInstallationAccessToken({installation_id: installationId}) + await cloneRepo(token, repositoryFullName, repoPath) + await runInfisicalScanOnRepo(repoPath, findingsPath); + const findingsData = await readFindingsFile(findingsPath); + return JSON.parse(findingsData); + } finally { + await deleteTempFolder(tempFolder); + } +} export async function scanContentAndGetFindings(textContent: string): Promise { const tempFolder = await createTempFolder(); @@ -36,6 +50,8 @@ export function createTempFolder(): Promise { }); } + + export function writeTextToFile(filePath: string, content: string): Promise { return new Promise((resolve, reject) => { writeFile(filePath, content, (err) => { @@ -48,6 +64,33 @@ export function writeTextToFile(filePath: string, content: string): Promise { + const cloneUrl = `https://x-access-token:${installationAcccessToken}@github.com/${repositoryFullName}.git`; + const command = `git clone ${cloneUrl} ${repoPath} --bare` + return new Promise((resolve, reject) => { + exec(command, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }) +} + +export function runInfisicalScanOnRepo(repoPath: string, outputPath: string): Promise { + return new Promise((resolve, reject) => { + const command = `cd ${repoPath} && infisical scan --exit-code=77 -r "${outputPath}"`; + exec(command, (error) => { + if (error && error.code != 77) { + reject(error); + } else { + resolve(); + } + }); + }); +} + export function runInfisicalScan(inputPath: string, outputPath: string): Promise { return new Promise((resolve, reject) => { const command = `cat "${inputPath}" | infisical scan --exit-code=77 --pipe -r "${outputPath}"`; @@ -96,30 +139,4 @@ export function convertKeysToLowercase(obj: T): T { } return convertedObj; -} - -export async function getCommits(octokit: Octokit, owner: string, repo: string) { - let commits: { sha: string }[] = []; - let page = 1; - while (true) { - const response = await octokit.repos.listCommits({ - owner, - repo, - per_page: 100, - page, - }); - - commits = commits.concat(response.data); - if (response.data.length == 0) break; - page++; - } - return commits; -} - -export async function getFilesFromCommit(octokit: any, owner: string, repo: string, sha: string) { - const response = await octokit.repos.getCommit({ - owner, - repo, - ref: sha, - }); } \ No newline at end of file diff --git a/backend/src/ee/services/ProjectRoleService.ts b/backend/src/ee/services/ProjectRoleService.ts new file mode 100644 index 000000000..51f4a26f1 --- /dev/null +++ b/backend/src/ee/services/ProjectRoleService.ts @@ -0,0 +1,250 @@ +import { + AbilityBuilder, + ForcedSubject, + MongoAbility, + RawRuleOf, + buildMongoQueryMatcher, + createMongoAbility +} from "@casl/ability"; +import { Membership } from "../../models"; +import { IRole } from "../models/role"; +import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; +import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; +import picomatch from "picomatch"; + +const $glob: FieldInstruction = { + type: "field", + validate(instruction, value) { + if (typeof value !== "string") { + throw new Error(`"${instruction.name}" expects value to be a string`); + } + } +}; + +const glob: JsInterpreter> = (node, object, context) => { + const secretPath = context.get(object, node.field); + const permissionSecretGlobPath = node.value; + return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false }); +}; + +export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); + +export enum ProjectPermissionActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete" +} + +export enum ProjectPermissionSub { + Role = "role", + Member = "member", + Settings = "settings", + Integrations = "integrations", + Webhooks = "webhooks", + ServiceTokens = "service-tokens", + Environments = "environments", + Tags = "tags", + AuditLogs = "audit-logs", + IpAllowList = "ip-allowlist", + Workspace = "workspace", + Secrets = "secrets", + SecretRollback = "secret-rollback" +} + +type SubjectFields = { + environment: string; + secretPath: string; +}; + +export type ProjectPermissionSet = + | [ + ProjectPermissionActions, + ProjectPermissionSub.Secrets | (ForcedSubject & SubjectFields) + ] + | [ProjectPermissionActions, ProjectPermissionSub.Role] + | [ProjectPermissionActions, ProjectPermissionSub.Tags] + | [ProjectPermissionActions, ProjectPermissionSub.Member] + | [ProjectPermissionActions, ProjectPermissionSub.Integrations] + | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] + | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] + | [ProjectPermissionActions, ProjectPermissionSub.Environments] + | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] + | [ProjectPermissionActions, ProjectPermissionSub.Settings] + | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] + | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] + | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] + | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] + | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]; + +const buildAdminPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Role); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Role); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Role); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Webhooks); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Create, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.ServiceTokens); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Settings); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Environments); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Tags); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionActions.Create, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.AuditLogs); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + can(ProjectPermissionActions.Create, ProjectPermissionSub.IpAllowList); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.IpAllowList); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.IpAllowList); + + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace); + + return build({ conditionsMatcher }); +}; + +export const adminProjectPermissions = buildAdminPermission(); + +const buildMemberPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Webhooks); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Create, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.ServiceTokens); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Settings); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Environments); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Tags); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + + return build({ conditionsMatcher }); +}; + +export const memberProjectPermissions = buildMemberPermission(); + +const buildViewerPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); + can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + + return build({ conditionsMatcher }); +}; + +export const viewerProjectPermission = buildViewerPermission(); + +export const getUserProjectPermissions = async (userId: string, workspaceId: string) => { + // TODO(akhilmhdh): speed this up by pulling from cache later + const membership = await Membership.findOne({ + user: userId, + workspace: workspaceId + }) + .populate<{ + customRole: IRole & { permissions: RawRuleOf>[] }; + }>("customRole") + .exec(); + + if (!membership || (membership.role === "custom" && !membership.customRole)) { + throw UnauthorizedRequestError({ message: "User doesn't belong to organization" }); + } + + if (membership.role === "admin") return { permission: adminProjectPermissions, membership }; + if (membership.role === "member") return { permission: memberProjectPermissions, membership }; + if (membership.role === "viewer") return { permission: viewerProjectPermission, membership }; + + if (membership.role === "custom") { + const permission = createMongoAbility(membership.customRole.permissions, { + conditionsMatcher + }); + return { permission, membership }; + } + + throw BadRequestError({ message: "User role not found" }); +}; diff --git a/backend/src/ee/services/RoleService.ts b/backend/src/ee/services/RoleService.ts new file mode 100644 index 000000000..8f8c4f315 --- /dev/null +++ b/backend/src/ee/services/RoleService.ts @@ -0,0 +1,134 @@ +import { AbilityBuilder, MongoAbility, RawRuleOf, createMongoAbility } from "@casl/ability"; +import { MembershipOrg } from "../../models"; +import { IRole } from "../models/role"; +import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; +import { ACCEPTED } from "../../variables"; +import { conditionsMatcher } from "./ProjectRoleService"; + +export enum OrgPermissionActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete" +} + +export enum OrgPermissionSubjects { + Workspace = "workspace", + Role = "role", + Member = "member", + Settings = "settings", + IncidentAccount = "incident-contact", + Sso = "sso", + Billing = "billing", + SecretScanning = "secret-scanning" +} + +export type OrgPermissionSet = + | [OrgPermissionActions.Read, OrgPermissionSubjects.Workspace] + | [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace] + | [OrgPermissionActions, OrgPermissionSubjects.Role] + | [OrgPermissionActions, OrgPermissionSubjects.Member] + | [OrgPermissionActions, OrgPermissionSubjects.Settings] + | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] + | [OrgPermissionActions, OrgPermissionSubjects.Sso] + | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] + | [OrgPermissionActions, OrgPermissionSubjects.Billing]; + +const buildAdminPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + // ws permissions + can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); + // role permission + can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); + can(OrgPermissionActions.Create, OrgPermissionSubjects.IncidentAccount); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.IncidentAccount); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Sso); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); + + return build({ conditionsMatcher }); +}; + +export const adminPermissions = buildAdminPermission(); + +const buildMemberPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); + + return build({ conditionsMatcher }); +}; + +export const memberPermissions = buildMemberPermission(); + +export const getUserOrgPermissions = async (userId: string, orgId: string) => { + // TODO(akhilmhdh): speed this up by pulling from cache later + const membership = await MembershipOrg.findOne({ + user: userId, + organization: orgId, + status: ACCEPTED + }) + .populate<{ customRole: IRole & { permissions: RawRuleOf>[] } }>( + "customRole" + ) + .exec(); + + if (!membership || (membership.role === "custom" && !membership.customRole)) { + throw UnauthorizedRequestError({ message: "User doesn't belong to organization" }); + } + + if (membership.role === "admin") return { permission: adminPermissions, membership }; + + if (membership.role === "member") return { permission: memberPermissions, membership }; + + if (membership.role === "custom") { + const permission = createMongoAbility(membership.customRole.permissions, { + conditionsMatcher + }); + return { permission, membership }; + } + + throw BadRequestError({ message: "User role not found" }); +}; diff --git a/backend/src/ee/validation/role.ts b/backend/src/ee/validation/role.ts new file mode 100644 index 000000000..e3ecafe59 --- /dev/null +++ b/backend/src/ee/validation/role.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +export const CreateRoleSchema = z.object({ + body: z.object({ + slug: z.string().trim(), + name: z.string().trim(), + description: z.string().trim().optional(), + workspaceId: z.string().trim().optional(), + orgId: z.string().trim(), + permissions: z + .object({ + subject: z.string().trim(), + action: z.string().trim(), + conditions: z + .record(z.union([z.string().trim(), z.number(), z.object({ $glob: z.string() })])) + .optional() + }) + .array() + }) +}); + +export const UpdateRoleSchema = z.object({ + params: z.object({ + id: z.string().trim() + }), + body: z.object({ + slug: z.string().trim().optional(), + name: z.string().trim().optional(), + description: z.string().trim().optional(), + workspaceId: z.string().trim().optional(), + orgId: z.string().trim(), + permissions: z + .object({ + subject: z.string().trim(), + action: z.string().trim(), + conditions: z + .record(z.union([z.string().trim(), z.number(), z.object({ $glob: z.string() })])) + .optional() + }) + .array() + .optional() + }) +}); + +export const DeleteRoleSchema = z.object({ + params: z.object({ + id: z.string().trim() + }) +}); + +export const GetRoleSchema = z.object({ + query: z.object({ + workspaceId: z.string().trim().optional(), + orgId: z.string().trim() + }) +}); + +export const GetUserPermission = z.object({ + params: z.object({ + orgId: z.string().trim() + }) +}); + +export const GetUserProjectPermission = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts index 245fe1192..b9b7aac51 100644 --- a/backend/src/helpers/bot.ts +++ b/backend/src/helpers/bot.ts @@ -134,7 +134,8 @@ export const getSecretsBotHelper = async ({ const importedSecrets = await getAllImportedSecrets( workspaceId.toString(), environment, - folderId + folderId, + () => true // integrations are setup to read all the ones ); importedSecrets.forEach(({ secrets }) => { diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 4b1604d50..6b94d5916 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -1,20 +1,24 @@ import { Types } from "mongoose"; -import { Bot, IntegrationAuth } from "../models"; +import { Bot, IIntegrationAuth, IntegrationAuth } from "../models"; import { exchangeCode, exchangeRefresh } from "../integrations"; import { BotService } from "../services"; import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_UTF8, + INTEGRATION_GCP_SECRET_MANAGER, INTEGRATION_NETLIFY, - INTEGRATION_VERCEL + INTEGRATION_VERCEL, } from "../variables"; -import { UnauthorizedRequestError } from "../utils/errors"; +import { InternalServerError, UnauthorizedRequestError } from "../utils/errors"; +import { IntegrationAuthMetadata } from "../models/integrationAuth/types"; interface Update { workspace: string; integration: string; + url?: string; teamId?: string; accountId?: string; + metadata?: IntegrationAuthMetadata } /** @@ -33,12 +37,14 @@ export const handleOAuthExchangeHelper = async ({ workspaceId, integration, code, - environment + environment, + url }: { workspaceId: string; integration: string; code: string; environment: string; + url?: string; }) => { const bot = await Bot.findOne({ workspace: workspaceId, @@ -50,13 +56,18 @@ export const handleOAuthExchangeHelper = async ({ // exchange code for access and refresh tokens const res = await exchangeCode({ integration, - code + code, + url }); const update: Update = { workspace: workspaceId, integration }; + + if (res.url) { + update.url = res.url; + } switch (integration) { case INTEGRATION_VERCEL: @@ -65,6 +76,11 @@ export const handleOAuthExchangeHelper = async ({ case INTEGRATION_NETLIFY: update.accountId = res.accountId; break; + case INTEGRATION_GCP_SECRET_MANAGER: + update.metadata = { + authMethod: "oauth2" + } + break; } const integrationAuth = await IntegrationAuth.findOneAndUpdate( @@ -93,7 +109,6 @@ export const handleOAuthExchangeHelper = async ({ // set integration auth access token await setIntegrationAuthAccessHelper({ integrationAuthId: integrationAuth._id.toString(), - accessId: null, accessToken: res.accessToken, accessExpiresAt: res.accessExpiresAt }); @@ -150,7 +165,7 @@ export const getIntegrationAuthAccessHelper = async ({ let accessId; let accessToken; const integrationAuth = await IntegrationAuth.findById(integrationAuthId).select( - "workspace integration +accessCiphertext +accessIV +accessTag +accessExpiresAt + refreshCiphertext +accessIdCiphertext +accessIdIV +accessIdTag" + "workspace integration +accessCiphertext +accessIV +accessTag +accessExpiresAt +refreshCiphertext +refreshIV +refreshTag +accessIdCiphertext +accessIdIV +accessIdTag metadata teamId url" ); if (!integrationAuth) @@ -158,22 +173,24 @@ export const getIntegrationAuthAccessHelper = async ({ message: "Failed to locate Integration Authentication credentials" }); - accessToken = await BotService.decryptSymmetric({ - workspaceId: integrationAuth.workspace, - ciphertext: integrationAuth.accessCiphertext as string, - iv: integrationAuth.accessIV as string, - tag: integrationAuth.accessTag as string - }); + if (integrationAuth.accessCiphertext && integrationAuth.accessIV && integrationAuth.accessTag) { + accessToken = await BotService.decryptSymmetric({ + workspaceId: integrationAuth.workspace, + ciphertext: integrationAuth.accessCiphertext as string, + iv: integrationAuth.accessIV as string, + tag: integrationAuth.accessTag as string + }); + } - if (integrationAuth?.accessExpiresAt && integrationAuth?.refreshCiphertext) { + if (integrationAuth?.refreshCiphertext) { // there is a access token expiration date // and refresh token to exchange with the OAuth2 server + const refreshToken = await getIntegrationAuthRefreshHelper({ + integrationAuthId + }); - if (integrationAuth.accessExpiresAt < new Date()) { + if (integrationAuth?.accessExpiresAt && integrationAuth.accessExpiresAt < new Date()) { // access token is expired - const refreshToken = await getIntegrationAuthRefreshHelper({ - integrationAuthId - }); accessToken = await exchangeRefresh({ integrationAuth, refreshToken @@ -194,7 +211,10 @@ export const getIntegrationAuthAccessHelper = async ({ }); } + if (!accessToken) throw InternalServerError(); + return { + integrationAuth, accessId, accessToken }; @@ -214,7 +234,7 @@ export const setIntegrationAuthRefreshHelper = async ({ }: { integrationAuthId: string; refreshToken: string; -}) => { +}): Promise => { let integrationAuth = await IntegrationAuth.findById(integrationAuthId); if (!integrationAuth) throw new Error("Failed to find integration auth"); @@ -239,6 +259,8 @@ export const setIntegrationAuthRefreshHelper = async ({ new: true } ); + + if (!integrationAuth) throw InternalServerError(); return integrationAuth; }; @@ -259,20 +281,24 @@ export const setIntegrationAuthAccessHelper = async ({ accessExpiresAt }: { integrationAuthId: string; - accessId: string | null; - accessToken: string; + accessId?: string; + accessToken?: string; accessExpiresAt: Date | undefined; }) => { let integrationAuth = await IntegrationAuth.findById(integrationAuthId); if (!integrationAuth) throw new Error("Failed to find integration auth"); - - const encryptedAccessTokenObj = await BotService.encryptSymmetric({ - workspaceId: integrationAuth.workspace, - plaintext: accessToken - }); - + + let encryptedAccessTokenObj; let encryptedAccessIdObj; + + if (accessToken) { + encryptedAccessTokenObj = await BotService.encryptSymmetric({ + workspaceId: integrationAuth.workspace, + plaintext: accessToken + }); + } + if (accessId) { encryptedAccessIdObj = await BotService.encryptSymmetric({ workspaceId: integrationAuth.workspace, @@ -286,11 +312,11 @@ export const setIntegrationAuthAccessHelper = async ({ }, { accessIdCiphertext: encryptedAccessIdObj?.ciphertext ?? undefined, - accessIdIV: encryptedAccessIdObj?.iv ?? undefined, - accessIdTag: encryptedAccessIdObj?.tag ?? undefined, - accessCiphertext: encryptedAccessTokenObj.ciphertext, - accessIV: encryptedAccessTokenObj.iv, - accessTag: encryptedAccessTokenObj.tag, + accessIdIV: encryptedAccessIdObj?.iv, + accessIdTag: encryptedAccessIdObj?.tag, + accessCiphertext: encryptedAccessTokenObj?.ciphertext, + accessIV: encryptedAccessTokenObj?.iv, + accessTag: encryptedAccessTokenObj?.tag, accessExpiresAt, algorithm: ALGORITHM_AES_256_GCM, keyEncoding: ENCODING_SCHEME_UTF8 diff --git a/backend/src/helpers/membership.ts b/backend/src/helpers/membership.ts index d2fcf5b17..7700c21ee 100644 --- a/backend/src/helpers/membership.ts +++ b/backend/src/helpers/membership.ts @@ -11,29 +11,29 @@ import { BadRequestError, MembershipNotFoundError } from "../utils/errors"; * @returns {Membership} membership - membership of user with id [userId] for workspace with id [workspaceId] */ export const validateMembership = async ({ - userId, - workspaceId, - acceptedRoles, + userId, + workspaceId, + acceptedRoles }: { userId: Types.ObjectId | string; workspaceId: Types.ObjectId | string; - acceptedRoles?: Array<"admin" | "member">; + acceptedRoles?: Array<"admin" | "member" | "custom" | "viewer">; }) => { const membership = await Membership.findOne({ user: userId, - workspace: workspaceId, + workspace: workspaceId }).populate("workspace"); if (!membership) { throw MembershipNotFoundError({ - message: "Failed to find workspace membership", + message: "Failed to find workspace membership" }); } if (acceptedRoles) { if (!acceptedRoles.includes(membership.role)) { throw BadRequestError({ - message: "Failed authorization for membership role", + message: "Failed authorization for membership role" }); } } @@ -47,7 +47,7 @@ export const validateMembership = async ({ * @return {Object} membership - membership */ export const findMembership = async (queryObj: any) => { - const membership = await Membership.findOne(queryObj); + const membership = await Membership.findOne(queryObj); return membership; }; @@ -60,9 +60,9 @@ export const findMembership = async (queryObj: any) => { * @param {String[]} obj.roles - roles of users. */ export const addMemberships = async ({ - userIds, - workspaceId, - roles, + userIds, + workspaceId, + roles }: { userIds: string[]; workspaceId: string; @@ -74,15 +74,15 @@ export const addMemberships = async ({ filter: { user: userId, workspace: workspaceId, - role: roles[idx], + role: roles[idx] }, update: { user: userId, workspace: workspaceId, - role: roles[idx], + role: roles[idx] }, - upsert: true, - }, + upsert: true + } }; }); await Membership.bulkWrite(operations as any); @@ -94,8 +94,8 @@ export const addMemberships = async ({ * @param {String} obj.membershipId - id of membership to delete */ export const deleteMembership = async ({ membershipId }: { membershipId: string }) => { - const deletedMembership = await Membership.findOneAndDelete({ - _id: membershipId, + const deletedMembership = await Membership.findOneAndDelete({ + _id: membershipId }); // delete keys associated with the membership @@ -103,9 +103,9 @@ export const deleteMembership = async ({ membershipId }: { membershipId: string // case: membership had a registered user await Key.deleteMany({ receiver: deletedMembership.user, - workspace: deletedMembership.workspace, + workspace: deletedMembership.workspace }); } - return deletedMembership; + return deletedMembership; }; diff --git a/backend/src/helpers/membershipOrg.ts b/backend/src/helpers/membershipOrg.ts index 3ed5be088..46f5fbf56 100644 --- a/backend/src/helpers/membershipOrg.ts +++ b/backend/src/helpers/membershipOrg.ts @@ -1,14 +1,6 @@ import { Types } from "mongoose"; -import { - Key, - Membership, - MembershipOrg, - Workspace, -} from "../models"; -import { - MembershipOrgNotFoundError, - UnauthorizedRequestError, -} from "../utils/errors"; +import { Key, Membership, MembershipOrg, Workspace } from "../models"; +import { MembershipOrgNotFoundError, UnauthorizedRequestError } from "../utils/errors"; /** * Validate that user with id [userId] is a member of organization with id [organizationId] @@ -19,39 +11,43 @@ import { * @param {String[]} obj.acceptedRoles */ export const validateMembershipOrg = async ({ - userId, - organizationId, - acceptedRoles, - acceptedStatuses, + userId, + organizationId, + acceptedRoles, + acceptedStatuses }: { - userId: Types.ObjectId; - organizationId: Types.ObjectId; - acceptedRoles?: Array<"owner" | "admin" | "member">; - acceptedStatuses?: Array<"invited" | "accepted">; + userId: Types.ObjectId; + organizationId: Types.ObjectId; + acceptedRoles?: Array<"owner" | "admin" | "member" | "custom">; + acceptedStatuses?: Array<"invited" | "accepted">; }) => { - const membershipOrg = await MembershipOrg.findOne({ - user: userId, - organization: organizationId, - }); - - if (!membershipOrg) { - throw MembershipOrgNotFoundError({ message: "Failed to find organization membership" }); - } - - if (acceptedRoles) { - if (!acceptedRoles.includes(membershipOrg.role)) { - throw UnauthorizedRequestError({ message: "Failed to validate organization membership role" }); - } - } - - if (acceptedStatuses) { - if (!acceptedStatuses.includes(membershipOrg.status)) { - throw UnauthorizedRequestError({ message: "Failed to validate organization membership status" }); - } - } - - return membershipOrg; -} + const membershipOrg = await MembershipOrg.findOne({ + user: userId, + organization: organizationId + }); + + if (!membershipOrg) { + throw MembershipOrgNotFoundError({ message: "Failed to find organization membership" }); + } + + if (acceptedRoles) { + if (!acceptedRoles.includes(membershipOrg.role)) { + throw UnauthorizedRequestError({ + message: "Failed to validate organization membership role" + }); + } + } + + if (acceptedStatuses) { + if (!acceptedStatuses.includes(membershipOrg.status)) { + throw UnauthorizedRequestError({ + message: "Failed to validate organization membership status" + }); + } + } + + return membershipOrg; +}; /** * Return organization membership matching criteria specified in @@ -60,8 +56,8 @@ export const validateMembershipOrg = async ({ * @return {Object} membershipOrg - membership */ export const findMembershipOrg = (queryObj: any) => { - const membershipOrg = MembershipOrg.findOne(queryObj); - return membershipOrg; + const membershipOrg = MembershipOrg.findOne(queryObj); + return membershipOrg; }; /** @@ -73,15 +69,15 @@ export const findMembershipOrg = (queryObj: any) => { * @param {String[]} obj.roles - roles of users. */ export const addMembershipsOrg = async ({ - userIds, - organizationId, - roles, - statuses, + userIds, + organizationId, + roles, + statuses }: { - userIds: string[]; - organizationId: string; - roles: string[]; - statuses: string[]; + userIds: string[]; + organizationId: string; + roles: string[]; + statuses: string[]; }) => { const operations = userIds.map((userId, idx) => { return { @@ -90,16 +86,16 @@ export const addMembershipsOrg = async ({ user: userId, organization: organizationId, role: roles[idx], - status: statuses[idx], + status: statuses[idx] }, update: { user: userId, organization: organizationId, role: roles[idx], - status: statuses[idx], + status: statuses[idx] }, - upsert: true, - }, + upsert: true + } }; }); @@ -111,13 +107,9 @@ export const addMembershipsOrg = async ({ * @param {Object} obj * @param {String} obj.membershipOrgId - id of organization membership to delete */ -export const deleteMembershipOrg = async ({ - membershipOrgId, -}: { - membershipOrgId: string; -}) => { +export const deleteMembershipOrg = async ({ membershipOrgId }: { membershipOrgId: string }) => { const deletedMembershipOrg = await MembershipOrg.findOneAndDelete({ - _id: membershipOrgId, + _id: membershipOrgId }); if (!deletedMembershipOrg) throw new Error("Failed to delete organization membership"); @@ -128,24 +120,24 @@ export const deleteMembershipOrg = async ({ const workspaces = ( await Workspace.find({ - organization: deletedMembershipOrg.organization, + organization: deletedMembershipOrg.organization }) ).map((w) => w._id.toString()); await Membership.deleteMany({ user: deletedMembershipOrg.user, workspace: { - $in: workspaces, - }, + $in: workspaces + } }); await Key.deleteMany({ receiver: deletedMembershipOrg.user, workspace: { - $in: workspaces, - }, + $in: workspaces + } }); } - return deletedMembershipOrg; -}; \ No newline at end of file + return deletedMembershipOrg; +}; diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index ad6296adf..5b4c8b465 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -48,6 +48,7 @@ import { getAuthDataPayloadIdObj, getAuthDataPayloadUserObj } from "../utils/aut import { getFolderByPath, getFolderIdFromServiceToken } from "../services/FolderService"; import picomatch from "picomatch"; import path from "path"; +import { getAnImportedSecret } from "../services/SecretImportService"; export const isValidScope = ( authPayload: IServiceTokenData, @@ -504,11 +505,6 @@ export const getSecretsHelper = async ({ }: GetSecretsParams) => { let secrets: ISecret[] = []; // if using service token filter towards the folderId by secretpath - if (authData.authPayload instanceof ServiceTokenData) { - if (!isValidScope(authData.authPayload, environment, secretPath)) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } if (!folderId) { folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); @@ -575,20 +571,22 @@ export const getSecretsHelper = async ({ const postHogClient = await TelemetryService.getPostHogClient(); // reduce the number of events captured - let shouldRecordK8Event = false + let shouldRecordK8Event = false; if (authData.userAgent == K8_USER_AGENT_NAME) { const randomNumber = Math.random(); if (randomNumber > 0.9) { - shouldRecordK8Event = true + shouldRecordK8Event = true; } } - const numberOfSignupSecrets = (secrets.filter((secret) => secret?.metadata?.source === "signup")).length; - const atLeastOneNonSignUpSecret = (secrets.length - numberOfSignupSecrets > 0) + const numberOfSignupSecrets = secrets.filter( + (secret) => secret?.metadata?.source === "signup" + ).length; + const atLeastOneNonSignUpSecret = secrets.length - numberOfSignupSecrets > 0; if (postHogClient && atLeastOneNonSignUpSecret) { const shouldCapture = authData.userAgent !== K8_USER_AGENT_NAME || shouldRecordK8Event; - const approximateForNoneCapturedEvents = secrets.length * 10 + const approximateForNoneCapturedEvents = secrets.length * 10; if (shouldCapture) { postHogClient.capture({ @@ -625,19 +623,16 @@ export const getSecretHelper = async ({ environment, type, authData, - secretPath = "/" + secretPath = "/", + include_imports = true }: GetSecretParams) => { const secretBlindIndex = await generateSecretBlindIndexHelper({ secretName, workspaceId: new Types.ObjectId(workspaceId) }); - let secret: ISecret | null = null; + let secret: ISecret | null | undefined = null; // if using service token filter towards the folderId by secretpath - if (authData.authPayload instanceof ServiceTokenData) { - if (!isValidScope(authData.authPayload, environment, secretPath)) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } + const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); // try getting personal secret first (if exists) @@ -662,6 +657,11 @@ export const getSecretHelper = async ({ }).lean(); } + if (!secret && include_imports) { + // if still no secret found search in imported secret and retreive + secret = await getAnImportedSecret(secretName, workspaceId.toString(), environment, folderId); + } + if (!secret) throw SecretNotFoundError(); // (EE) create (audit) log @@ -751,12 +751,6 @@ export const updateSecretHelper = async ({ }); let secret: ISecret | null = null; - // if using service token filter towards the folderId by secretpath - if (authData.authPayload instanceof ServiceTokenData) { - if (!isValidScope(authData.authPayload, environment, secretPath)) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); if (type === SECRET_SHARED) { @@ -916,12 +910,6 @@ export const deleteSecretHelper = async ({ workspaceId: new Types.ObjectId(workspaceId) }); - // if using service token filter towards the folderId by secretpath - if (authData.authPayload instanceof ServiceTokenData) { - if (!isValidScope(authData.authPayload, environment, secretPath)) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); let secrets: ISecret[] = []; diff --git a/backend/src/helpers/signup.ts b/backend/src/helpers/signup.ts index da494f4f7..27b1c16ab 100644 --- a/backend/src/helpers/signup.ts +++ b/backend/src/helpers/signup.ts @@ -1,7 +1,7 @@ import { IUser } from "../models"; import { createOrganization } from "./organization"; import { addMembershipsOrg } from "./membershipOrg"; -import { ACCEPTED, OWNER } from "../variables"; +import { ACCEPTED, ADMIN } from "../variables"; import { sendMail } from "../helpers/nodemailer"; import { TokenService } from "../services"; import { TOKEN_EMAIL_CONFIRMATION } from "../variables"; @@ -14,10 +14,10 @@ import { TOKEN_EMAIL_CONFIRMATION } from "../variables"; * @returns {Boolean} success - whether or not operation was successful */ export const sendEmailVerification = async ({ email }: { email: string }) => { - const token = await TokenService.createToken({ - type: TOKEN_EMAIL_CONFIRMATION, - email, - }); + const token = await TokenService.createToken({ + type: TOKEN_EMAIL_CONFIRMATION, + email + }); // send mail await sendMail({ @@ -25,8 +25,8 @@ export const sendEmailVerification = async ({ email }: { email: string }) => { subjectLine: "Infisical confirmation code", recipients: [email], substitutions: { - code: token, - }, + code: token + } }); }; @@ -36,17 +36,11 @@ export const sendEmailVerification = async ({ email }: { email: string }) => { * @param {String} obj.email - emai * @param {String} obj.code - code that was sent to [email] */ -export const checkEmailVerification = async ({ - email, - code, -}: { - email: string; - code: string; -}) => { +export const checkEmailVerification = async ({ email, code }: { email: string; code: string }) => { await TokenService.validateToken({ type: TOKEN_EMAIL_CONFIRMATION, email, - token: code, + token: code }); }; @@ -58,27 +52,27 @@ export const checkEmailVerification = async ({ * @param {IUser} obj.user - user who we are initializing for */ export const initializeDefaultOrg = async ({ - organizationName, - user, + organizationName, + user }: { - organizationName: string; - user: IUser; + organizationName: string; + user: IUser; }) => { - try { - // create organization with user as owner and initialize a free - // subscription - const organization = await createOrganization({ - email: user.email, - name: organizationName, - }); + try { + // create organization with user as owner and initialize a free + // subscription + const organization = await createOrganization({ + email: user.email, + name: organizationName + }); - await addMembershipsOrg({ - userIds: [user._id.toString()], - organizationId: organization._id.toString(), - roles: [OWNER], - statuses: [ACCEPTED], - }); - } catch (err) { - throw new Error(`Failed to initialize default organization and workspace [err=${err}]`); - } -}; \ No newline at end of file + await addMembershipsOrg({ + userIds: [user._id.toString()], + organizationId: organization._id.toString(), + roles: [ADMIN], + statuses: [ACCEPTED] + }); + } catch (err) { + throw new Error(`Failed to initialize default organization and workspace [err=${err}]`); + } +}; diff --git a/backend/src/helpers/user.ts b/backend/src/helpers/user.ts index 59030ff8c..728c7d089 100644 --- a/backend/src/helpers/user.ts +++ b/backend/src/helpers/user.ts @@ -1,7 +1,4 @@ -import { - IUser, - User, -} from "../models"; +import { IUser, User } from "../models"; import { sendMail } from "./nodemailer"; /** @@ -12,10 +9,10 @@ import { sendMail } from "./nodemailer"; */ export const setupAccount = async ({ email }: { email: string }) => { const user = await new User({ - email, + email }).save(); - return user; + return user; }; /** @@ -37,36 +34,36 @@ export const setupAccount = async ({ email }: { email: string }) => { * @returns {Object} user - the completed user */ export const completeAccount = async ({ - userId, - firstName, - lastName, - encryptionVersion, - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt, - verifier, + userId, + firstName, + lastName, + encryptionVersion, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier }: { - userId: string; - firstName: string; - lastName: string; - encryptionVersion: number; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - publicKey: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; - salt: string; - verifier: string; + userId: string; + firstName: string; + lastName?: string; + encryptionVersion: number; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + publicKey: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + salt: string; + verifier: string; }) => { const options = { - new: true, + new: true }; const user = await User.findByIdAndUpdate( userId, @@ -82,12 +79,12 @@ export const completeAccount = async ({ iv: encryptedPrivateKeyIV, tag: encryptedPrivateKeyTag, salt, - verifier, + verifier }, options ); - return user; + return user; }; /** @@ -98,38 +95,42 @@ export const completeAccount = async ({ * @param {String} obj.userAgent - login user-agent */ export const checkUserDevice = async ({ - user, - ip, - userAgent, + user, + ip, + userAgent }: { - user: IUser; - ip: string; - userAgent: string; + user: IUser; + ip: string; + userAgent: string; }) => { - const isDeviceSeen = user.devices.some((device) => device.ip === ip && device.userAgent === userAgent); - - if (!isDeviceSeen) { - // case: unseen login ip detected for user - // -> notify user about the sign-in from new ip - - user.devices = user.devices.concat([{ - ip: String(ip), - userAgent, - }]); - - await user.save(); + const isDeviceSeen = user.devices.some( + (device) => device.ip === ip && device.userAgent === userAgent + ); - // send MFA code [code] to [email] - await sendMail({ - template: "newDevice.handlebars", - subjectLine: "Successful login from new device", - recipients: [user.email], - substitutions: { - email: user.email, - timestamp: new Date().toString(), - ip, - userAgent, - }, - }); - } -} \ No newline at end of file + if (!isDeviceSeen) { + // case: unseen login ip detected for user + // -> notify user about the sign-in from new ip + + user.devices = user.devices.concat([ + { + ip: String(ip), + userAgent + } + ]); + + await user.save(); + + // send MFA code [code] to [email] + await sendMail({ + template: "newDevice.handlebars", + subjectLine: "Successful login from new device", + recipients: [user.email], + substitutions: { + email: user.email, + timestamp: new Date().toString(), + ip, + userAgent + } + }); + } +}; diff --git a/backend/src/helpers/validation.ts b/backend/src/helpers/validation.ts new file mode 100644 index 000000000..f552eb69b --- /dev/null +++ b/backend/src/helpers/validation.ts @@ -0,0 +1,17 @@ +import type { Request } from "express"; +import { AnyZodObject, ZodError, z } from "zod"; +import { BadRequestError } from "../utils/errors"; + +export async function validateRequest( + schema: T, + req: Request +): Promise> { + try { + return schema.parseAsync(req); + } catch (error) { + if (error instanceof ZodError) { + throw BadRequestError({ message: error.message }); + } + return BadRequestError({ message: JSON.stringify(error) }); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 098da2aad..aa4440a55 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -24,6 +24,7 @@ import { secretSnapshot as eeSecretSnapshotRouter, users as eeUsersRouter, workspace as eeWorkspaceRouter, + roles as v1RoleRouter, secretScanning as v1SecretScanningRouter } from "./ee/routes/v1"; import { @@ -37,7 +38,7 @@ import { membership as v1MembershipRouter, organization as v1OrganizationRouter, password as v1PasswordRouter, - secretImport as v1SecretImportRouter, + secretImps as v1SecretImpsRouter, secret as v1SecretRouter, secretsFolder as v1SecretsFolder, serviceToken as v1ServiceTokenRouter, @@ -137,6 +138,7 @@ const main = async () => { app.use((req, res, next) => { // default to IP address provided by Cloudflare + // #swagger.ignore = true const cfIp = req.headers["cf-connecting-ip"]; req.realIP = Array.isArray(cfIp) ? cfIp[0] : (cfIp as string) || req.ip; next(); @@ -152,7 +154,7 @@ const main = async () => { app.use("/api/v1/sso", eeSSORouter); app.use("/api/v1/cloud-products", eeCloudProductsRouter); - // v1 routes (default) + // v1 routes app.use("/api/v1/signup", v1SignupRouter); app.use("/api/v1/auth", v1AuthRouter); app.use("/api/v1/bot", v1BotRouter); @@ -161,7 +163,7 @@ const main = async () => { app.use("/api/v1/organization", v1OrganizationRouter); app.use("/api/v1/workspace", v1WorkspaceRouter); app.use("/api/v1/membership-org", v1MembershipOrgRouter); - app.use("/api/v1/membership", v1MembershipRouter); + app.use("/api/v1/membership", v1MembershipRouter); // app.use("/api/v1/key", v1KeyRouter); app.use("/api/v1/invite-org", v1InviteOrgRouter); app.use("/api/v1/secret", v1SecretRouter); // deprecate @@ -172,7 +174,8 @@ const main = async () => { app.use("/api/v1/folders", v1SecretsFolder); app.use("/api/v1/secret-scanning", v1SecretScanningRouter); app.use("/api/v1/webhooks", v1WebhooksRouter); - app.use("/api/v1/secret-imports", v1SecretImportRouter); + app.use("/api/v1/secret-imports", v1SecretImpsRouter); + app.use("/api/v1/roles", v1RoleRouter); // v2 routes (improvements) app.use("/api/v2/signup", v2SignupRouter); diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 1eef252b1..cd6cb263f 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -120,6 +120,7 @@ const getApps = async ({ break; case INTEGRATION_GITLAB: apps = await getAppsGitlab({ + integrationAuth, accessToken, teamId, }); @@ -607,6 +608,12 @@ const getAppsLaravelForge = async ({ * @returns {String} apps.name - name of Fly.io apps */ const getAppsFlyio = async ({ accessToken }: { accessToken: string }) => { + interface FlyioApp { + id: string; + name: string; + hostname: string; + } + const query = ` query($role: String) { apps(type: "container", first: 400, role: $role) { @@ -619,7 +626,7 @@ const getAppsFlyio = async ({ accessToken }: { accessToken: string }) => { } `; - const res = ( + const res: FlyioApp[] = ( await standardRequest.post( INTEGRATION_FLYIO_API_URL, { @@ -638,8 +645,9 @@ const getAppsFlyio = async ({ accessToken }: { accessToken: string }) => { ) ).data.data.apps.nodes; - const apps = res.map((a: any) => ({ + const apps = res.map((a: FlyioApp) => ({ name: a.name, + appId: a.id })); return apps; @@ -736,12 +744,16 @@ const getAppsTerraformCloud = async ({ * @returns {String} apps.name - name of GitLab site */ const getAppsGitlab = async ({ + integrationAuth, accessToken, teamId, }: { + integrationAuth: IIntegrationAuth; accessToken: string; teamId?: string; }) => { + const gitLabApiUrl = integrationAuth.url ? `${integrationAuth.url}/api` : INTEGRATION_GITLAB_API_URL; + const apps: App[] = []; let page = 1; @@ -758,7 +770,7 @@ const getAppsGitlab = async ({ }); const { data } = await standardRequest.get( - `${INTEGRATION_GITLAB_API_URL}/v4/groups/${teamId}/projects`, + `${gitLabApiUrl}/v4/groups/${teamId}/projects`, { params, headers: { @@ -785,7 +797,7 @@ const getAppsGitlab = async ({ // case: fetch projects for individual in GitLab const { id } = ( - await standardRequest.get(`${INTEGRATION_GITLAB_API_URL}/v4/user`, { + await standardRequest.get(`${gitLabApiUrl}/v4/user`, { headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json", @@ -800,7 +812,7 @@ const getAppsGitlab = async ({ }); const { data } = await standardRequest.get( - `${INTEGRATION_GITLAB_API_URL}/v4/users/${id}/projects`, + `${gitLabApiUrl}/v4/users/${id}/projects`, { params, headers: { diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 3201e6ca1..37382e79e 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -46,6 +46,14 @@ interface ExchangeCodeAzureResponse { id_token: string; } +interface ExchangeCodeGCPResponse { + access_token: string; + expires_in: number; + refresh_token: string; + scope: string; + token_type: string; +} + interface ExchangeCodeHerokuResponse { token_type: string; access_token: string; @@ -110,9 +118,11 @@ interface ExchangeCodeBitBucketResponse { const exchangeCode = async ({ integration, code, + url }: { integration: string; code: string; + url?: string; }) => { let obj = {} as any; @@ -150,6 +160,7 @@ const exchangeCode = async ({ case INTEGRATION_GITLAB: obj = await exchangeCodeGitlab({ code, + url }); break; case INTEGRATION_BITBUCKET: @@ -174,7 +185,7 @@ const exchangeCode = async ({ const exchangeCodeGCP = async ({ code }: { code: string }) => { const accessExpiresAt = new Date(); - const res: ExchangeCodeAzureResponse = ( + const res: ExchangeCodeGCPResponse = ( await standardRequest.post( INTEGRATION_GCP_TOKEN_URL, new URLSearchParams({ @@ -380,11 +391,17 @@ const exchangeCodeGithub = async ({ code }: { code: string }) => { * @returns {String} obj2.refreshToken - refresh token for Gitlab API * @returns {Date} obj2.accessExpiresAt - date of expiration for access token */ -const exchangeCodeGitlab = async ({ code }: { code: string }) => { +const exchangeCodeGitlab = async ({ + code, + url +}: { + code: string, + url?: string; +}) => { const accessExpiresAt = new Date(); const res: ExchangeCodeGitlabResponse = ( await standardRequest.post( - INTEGRATION_GITLAB_TOKEN_URL, + url ? `${url}/oauth/token` : INTEGRATION_GITLAB_TOKEN_URL, new URLSearchParams({ grant_type: "authorization_code", code: code, @@ -406,6 +423,7 @@ const exchangeCodeGitlab = async ({ code }: { code: string }) => { accessToken: res.access_token, refreshToken: res.refresh_token, accessExpiresAt, + url }; }; diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index 426d414f5..dee4931c8 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -1,11 +1,15 @@ +import jwt from "jsonwebtoken"; import { standardRequest } from "../config/request"; import { IIntegrationAuth } from "../models"; import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_BITBUCKET, INTEGRATION_BITBUCKET_TOKEN_URL, + INTEGRATION_GCP_CLOUD_PLATFORM_SCOPE, + INTEGRATION_GCP_SECRET_MANAGER, + INTEGRATION_GCP_TOKEN_URL, INTEGRATION_GITLAB, - INTEGRATION_HEROKU, + INTEGRATION_HEROKU } from "../variables"; import { INTEGRATION_AZURE_TOKEN_URL, @@ -16,9 +20,11 @@ import { IntegrationService } from "../services"; import { getClientIdAzure, getClientIdBitBucket, + getClientIdGCPSecretManager, getClientIdGitLab, getClientSecretAzure, getClientSecretBitBucket, + getClientSecretGCPSecretManager, getClientSecretGitLab, getClientSecretHeroku, getSiteURL, @@ -59,6 +65,19 @@ interface RefreshTokenBitBucketResponse { state: string; } +interface ServiceAccountAccessTokenGCPSecretManagerResponse { + access_token: string; + expires_in: number; + token_type: string; +} + +interface RefreshTokenGCPSecretManagerResponse { + access_token: string; + expires_in: number; + scope: string; + token_type: string; +} + /** * Return new access token by exchanging refresh token [refreshToken] for integration * named [integration] @@ -93,6 +112,7 @@ const exchangeRefresh = async ({ break; case INTEGRATION_GITLAB: tokenDetails = await exchangeRefreshGitLab({ + integrationAuth, refreshToken, }); break; @@ -101,18 +121,23 @@ const exchangeRefresh = async ({ refreshToken, }); break; + case INTEGRATION_GCP_SECRET_MANAGER: + tokenDetails = await exchangeRefreshGCPSecretManager({ + integrationAuth, + refreshToken, + }); + break; default: throw new Error("Failed to exchange token for incompatible integration"); } if ( - tokenDetails?.accessToken && - tokenDetails?.refreshToken && - tokenDetails?.accessExpiresAt + tokenDetails.accessToken && + tokenDetails.refreshToken && + tokenDetails.accessExpiresAt ) { await IntegrationService.setIntegrationAuthAccess({ integrationAuthId: integrationAuth._id.toString(), - accessId: null, accessToken: tokenDetails.accessToken, accessExpiresAt: tokenDetails.accessExpiresAt, }); @@ -202,17 +227,21 @@ const exchangeRefreshHeroku = async ({ * @returns */ const exchangeRefreshGitLab = async ({ + integrationAuth, refreshToken, }: { + integrationAuth: IIntegrationAuth; refreshToken: string; }) => { const accessExpiresAt = new Date(); + const url = integrationAuth.url; + const { data, }: { data: RefreshTokenGitLabResponse; } = await standardRequest.post( - INTEGRATION_GITLAB_TOKEN_URL, + url ? `${url}/oauth/token` : INTEGRATION_GITLAB_TOKEN_URL, new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, @@ -278,4 +307,76 @@ const exchangeRefreshBitBucket = async ({ }; }; -export { exchangeRefresh }; +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * GCP Secret Manager integration + * @param {Object} obj + * @param {String} obj.refreshToken - refresh token to use to get new access token for GCP Secret Manager + * @returns + */ +const exchangeRefreshGCPSecretManager = async ({ + integrationAuth, + refreshToken, +}: { + integrationAuth: IIntegrationAuth; + refreshToken: string; +}) => { + const accessExpiresAt = new Date(); + + if (integrationAuth.metadata?.authMethod === "serviceAccount") { + const serviceAccount = JSON.parse(refreshToken); + + const payload = { + iss: serviceAccount.client_email, + aud: serviceAccount.token_uri, + scope: INTEGRATION_GCP_CLOUD_PLATFORM_SCOPE, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + const token = jwt.sign(payload, serviceAccount.private_key, { algorithm: "RS256" }); + + const { data }: { data: ServiceAccountAccessTokenGCPSecretManagerResponse } = await standardRequest.post( + INTEGRATION_GCP_TOKEN_URL, + new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: token + }).toString(), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + } + ); + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + + return { + accessToken: data.access_token, + refreshToken, + accessExpiresAt + }; + } + + const { data }: { data: RefreshTokenGCPSecretManagerResponse } = ( + await standardRequest.post( + INTEGRATION_GCP_TOKEN_URL, + new URLSearchParams({ + client_id: await getClientIdGCPSecretManager(), + client_secret: await getClientSecretGCPSecretManager(), + refresh_token: refreshToken, + grant_type: "refresh_token", + } as any) + ) + ); + + accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + + return { + accessToken: data.access_token, + refreshToken, + accessExpiresAt, + }; +}; + +export { exchangeRefresh }; \ No newline at end of file diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 8023f74a2..d0c4ed775 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -40,6 +40,8 @@ import { INTEGRATION_NETLIFY_API_URL, INTEGRATION_NORTHFLANK, INTEGRATION_NORTHFLANK_API_URL, + INTEGRATION_QOVERY, + INTEGRATION_QOVERY_API_URL, INTEGRATION_RAILWAY, INTEGRATION_RAILWAY_API_URL, INTEGRATION_RENDER, @@ -156,6 +158,7 @@ const syncSecrets = async ({ break; case INTEGRATION_GITLAB: await syncSecretsGitLab({ + integrationAuth, integration, secrets, accessToken @@ -218,6 +221,13 @@ const syncSecrets = async ({ accessToken }); break; + case INTEGRATION_QOVERY: + await syncSecretsQovery({ + integration, + secrets, + accessToken + }); + break; case INTEGRATION_TERRAFORM_CLOUD: await syncSecretsTerraformCloud({ integration, @@ -327,15 +337,19 @@ const syncSecretsGCPSecretManager = async ({ const pageSize = 100; let pageToken: string | undefined; let hasMorePages = true; + + const filterParam = integration.metadata.secretGCPLabel + ? `?filter=labels.${integration.metadata.secretGCPLabel.labelName}=${integration.metadata.secretGCPLabel.labelValue}` + : ""; while (hasMorePages) { const params = new URLSearchParams({ pageSize: String(pageSize), ...(pageToken ? { pageToken } : {}) }); - + const res: GCPSMListSecretsRes = (await standardRequest.get( - `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1beta1/projects/${integration.appId}/secrets`, + `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets${filterParam}`, { params, headers: { @@ -346,7 +360,24 @@ const syncSecretsGCPSecretManager = async ({ )).data; if (res.secrets) { - gcpSecrets = gcpSecrets.concat(res.secrets); + const filteredSecrets = res.secrets?.filter((gcpSecret) => { + const arr = gcpSecret.name.split("/"); + const key = arr[arr.length - 1]; + + let isValid = true; + + if (integration.metadata.secretPrefix && !key.startsWith(integration.metadata.secretPrefix)) { + isValid = false; + } + + if (integration.metadata.secretSuffix && !key.endsWith(integration.metadata.secretSuffix)) { + isValid = false; + } + + return isValid; + }); + + gcpSecrets = gcpSecrets.concat(filteredSecrets); } if (!res.nextPageToken) { @@ -370,7 +401,7 @@ const syncSecretsGCPSecretManager = async ({ const key = arr[arr.length - 1]; const secretLatest: GCPLatestSecretVersionAccess = (await standardRequest.get( - `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1beta1/projects/${integration.appId}/secrets/${key}/versions/latest:access`, + `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}/versions/latest:access`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -378,6 +409,7 @@ const syncSecretsGCPSecretManager = async ({ } } )).data; + res[key] = Buffer.from(secretLatest.payload.data, "base64").toString("utf-8"); } @@ -386,11 +418,16 @@ const syncSecretsGCPSecretManager = async ({ if (!(key in res)) { // case: create secret await standardRequest.post( - `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1beta1/projects/${integration.appId}/secrets`, + `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets`, { replication: { automatic: {} - } + }, + ...(integration.metadata.secretGCPLabel ? { + labels: { + [integration.metadata.secretGCPLabel.labelName]: integration.metadata.secretGCPLabel.labelValue + } + } : {}) }, { params: { @@ -404,7 +441,7 @@ const syncSecretsGCPSecretManager = async ({ ); await standardRequest.post( - `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1beta1/projects/${integration.appId}/secrets/${key}:addVersion`, + `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}:addVersion`, { payload: { data: Buffer.from(secrets[key].value).toString("base64") @@ -424,7 +461,7 @@ const syncSecretsGCPSecretManager = async ({ if (!(key in secrets)) { // case: delete secret await standardRequest.delete( - `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1beta1/projects/${integration.appId}/secrets/${key}`, + `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -436,7 +473,7 @@ const syncSecretsGCPSecretManager = async ({ // case: update secret if (secrets[key].value !== res[key]) { await standardRequest.post( - `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1beta1/projects/${integration.appId}/secrets/${key}:addVersion`, + `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}:addVersion`, { payload: { data: Buffer.from(secrets[key].value).toString("base64") @@ -755,12 +792,12 @@ const syncSecretsAWSParameterStore = async ({ }; /** - * Sync/push [secrets] to AWS secret manager + * Sync/push [secrets] to AWS Secrets Manager * @param {Object} obj * @param {IIntegration} obj.integration - integration details * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) - * @param {String} obj.accessId - access id for AWS secret manager integration - * @param {String} obj.accessToken - access token for AWS secret manager integration + * @param {String} obj.accessId - access id for AWS Secrets Manager integration + * @param {String} obj.accessToken - access token for AWS Secrets Manager integration */ const syncSecretsAWSSecretManager = async ({ integration, @@ -913,7 +950,11 @@ const syncSecretsVercel = async ({ ? { teamId: integrationAuth.teamId } - : {}) + : {}), + ...(integration?.path + ? { + gitBranch: integration?.path + } : {}) }; const vercelSecrets: VercelSecret[] = ( @@ -932,7 +973,7 @@ const syncSecretsVercel = async ({ if ( integration.targetEnvironment === "preview" && - integration.path && + secret.gitBranch && integration.path !== secret.gitBranch ) { // case: secret on preview environment does not have same target git branch @@ -941,7 +982,7 @@ const syncSecretsVercel = async ({ return true; }); - + const res: { [key: string]: VercelSecret } = {}; for await (const vercelSecret of vercelSecrets) { @@ -1813,10 +1854,12 @@ const syncSecretsTravisCI = async ({ * @param {String} obj.accessToken - access token for GitLab integration */ const syncSecretsGitLab = async ({ + integrationAuth, integration, secrets, accessToken }: { + integrationAuth: IIntegrationAuth; integration: IIntegration; secrets: Record; accessToken: string; @@ -1826,9 +1869,10 @@ const syncSecretsGitLab = async ({ value: string; environment_scope: string; } + + const gitLabApiUrl = integrationAuth.url ? `${integrationAuth.url}/api` : INTEGRATION_GITLAB_API_URL; const getAllEnvVariables = async (integrationAppId: string, accessToken: string) => { - const gitLabApiUrl = `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integrationAppId}/variables`; const headers = { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json", @@ -1836,7 +1880,7 @@ const syncSecretsGitLab = async ({ }; let allEnvVariables: GitLabSecret[] = []; - let url: string | null = `${gitLabApiUrl}?per_page=100`; + let url: string | null = `${gitLabApiUrl}/v4/projects/${integrationAppId}/variables?per_page=100`; while (url) { const response: any = await standardRequest.get(url, { headers }); @@ -1856,15 +1900,29 @@ const syncSecretsGitLab = async ({ }; const allEnvVariables = await getAllEnvVariables(integration?.appId, accessToken); - const getSecretsRes: GitLabSecret[] = allEnvVariables.filter( - (secret: GitLabSecret) => secret.environment_scope === integration.targetEnvironment - ); + const getSecretsRes: GitLabSecret[] = allEnvVariables + .filter( + (secret: GitLabSecret) => secret.environment_scope === integration.targetEnvironment + ) + .filter((gitLabSecret) => { + let isValid = true; + if (integration.metadata.secretPrefix && !gitLabSecret.key.startsWith(integration.metadata.secretPrefix)) { + isValid = false; + } + + if (integration.metadata.secretSuffix && !gitLabSecret.key.endsWith(integration.metadata.secretSuffix)) { + isValid = false; + } + + return isValid; + }); + for await (const key of Object.keys(secrets)) { const existingSecret = getSecretsRes.find((s: any) => s.key == key); if (!existingSecret) { await standardRequest.post( - `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integration?.appId}/variables`, + `${gitLabApiUrl}/v4/projects/${integration?.appId}/variables`, { key: key, value: secrets[key].value, @@ -1885,7 +1943,7 @@ const syncSecretsGitLab = async ({ // update secret if (secrets[key].value !== existingSecret.value) { await standardRequest.put( - `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integration?.appId}/variables/${existingSecret.key}?filter[environment_scope]=${integration.targetEnvironment}`, + `${gitLabApiUrl}/v4/projects/${integration?.appId}/variables/${existingSecret.key}?filter[environment_scope]=${integration.targetEnvironment}`, { ...existingSecret, value: secrets[existingSecret.key].value @@ -1906,7 +1964,7 @@ const syncSecretsGitLab = async ({ for await (const sec of getSecretsRes) { if (!(sec.key in secrets)) { await standardRequest.delete( - `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integration?.appId}/variables/${sec.key}?filter[environment_scope]=${integration.targetEnvironment}`, + `${gitLabApiUrl}/v4/projects/${integration?.appId}/variables/${sec.key}?filter[environment_scope]=${integration.targetEnvironment}`, { headers: { Authorization: `Bearer ${accessToken}` @@ -2008,7 +2066,6 @@ const syncSecretsCheckly = async ({ secrets: Record; accessToken: string; }) => { - const getSecretsRes = ( await standardRequest.get(`${INTEGRATION_CHECKLY_API_URL}/v1/variables`, { headers: { @@ -2082,6 +2139,97 @@ const syncSecretsCheckly = async ({ } }; +/** + * Sync/push [secrets] to Qovery app + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Qovery integration + */ +const syncSecretsQovery = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: Record; + accessToken: string; +}) => { + + const getSecretsRes = ( + await standardRequest.get(`${INTEGRATION_QOVERY_API_URL}/${integration.scope}/${integration.appId}/environmentVariable`, { + headers: { + Authorization: `Token ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).data.results.reduce( + (obj: any, secret: any) => ({ + ...obj, + [secret.key]: {"id": secret.id, "value": secret.value} + }), + {} + ); + + // add secrets + for await (const key of Object.keys(secrets)) { + if (!(key in getSecretsRes)) { + // case: secret does not exist in qovery + // -> add secret + await standardRequest.post( + `${INTEGRATION_QOVERY_API_URL}/${integration.scope}/${integration.appId}/environmentVariable`, + { + key, + value: secrets[key].value + }, + { + headers: { + Authorization: `Token ${accessToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + } + ); + } else { + // case: secret exists in qovery + // -> update/set secret + + if (secrets[key].value !== getSecretsRes[key].value) { + await standardRequest.put( + `${INTEGRATION_QOVERY_API_URL}/${integration.scope}/${integration.appId}/environmentVariable/${getSecretsRes[key].id}`, + { + key, + value: secrets[key].value + }, + { + headers: { + Authorization: `Token ${accessToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } + } + } + + // This one is dangerous because there might be a lot of qovery-specific secrets + + // for await (const key of Object.keys(getSecretsRes)) { + // if (!(key in secrets)) { + // console.log(3) + // // delete secret + // await standardRequest.delete(`${INTEGRATION_QOVERY_API_URL}/application/${integration.appId}/environmentVariable/${getSecretsRes[key].id}`, { + // headers: { + // Authorization: `Token ${accessToken}`, + // Accept: "application/json", + // "X-Qovery-Account": integration.appId + // } + // }); + // } + // } +}; + /** * Sync/push [secrets] to Terraform Cloud project with id [integration.appId] * @param {Object} obj @@ -2886,4 +3034,4 @@ const syncSecretsNorthflank = async ({ ); }; -export { syncSecrets }; +export { syncSecrets }; \ No newline at end of file diff --git a/backend/src/integrations/teams.ts b/backend/src/integrations/teams.ts index 3b0564322..46791c5b3 100644 --- a/backend/src/integrations/teams.ts +++ b/backend/src/integrations/teams.ts @@ -34,6 +34,7 @@ const getTeams = async ({ switch (integrationAuth.integration) { case INTEGRATION_GITLAB: teams = await getTeamsGitLab({ + integrationAuth, accessToken, }); break; @@ -51,13 +52,17 @@ const getTeams = async ({ * @returns {String} teams.teamId - id of team */ const getTeamsGitLab = async ({ + integrationAuth, accessToken, }: { + integrationAuth: IIntegrationAuth; accessToken: string; }) => { + const gitLabApiUrl = integrationAuth.url ? `${integrationAuth.url}/api` : INTEGRATION_GITLAB_API_URL; + let teams: Team[] = []; const res = (await standardRequest.get( - `${INTEGRATION_GITLAB_API_URL}/v4/groups`, + `${gitLabApiUrl}/v4/groups`, { headers: { Authorization: `Bearer ${accessToken}`, diff --git a/backend/src/interfaces/services/SecretService/index.ts b/backend/src/interfaces/services/SecretService/index.ts index 9ea82ce94..f1f89a83b 100644 --- a/backend/src/interfaces/services/SecretService/index.ts +++ b/backend/src/interfaces/services/SecretService/index.ts @@ -19,7 +19,7 @@ export interface CreateSecretParams { secretPath: string; metadata?: { source?: string; - } + }; } export interface GetSecretsParams { @@ -37,6 +37,7 @@ export interface GetSecretParams { environment: string; type?: "shared" | "personal"; authData: AuthData; + include_imports?: boolean; } export interface UpdateSecretParams { diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index bc2cbc969..b8c88bd26 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -14,6 +14,9 @@ import requireServiceAccountAuth from "./requireServiceAccountAuth"; import requireServiceAccountWorkspacePermissionAuth from "./requireServiceAccountWorkspacePermissionAuth"; import requireSecretAuth from "./requireSecretAuth"; import requireSecretsAuth from "./requireSecretsAuth"; +import requireBlindIndicesEnabled from "./requireBlindIndicesEnabled"; +import requireE2EEOff from "./requireE2EEOff"; +import requireIPAllowlistCheck from "./requireIPAllowlistCheck"; import validateRequest from "./validateRequest"; export { @@ -33,5 +36,8 @@ export { requireServiceAccountWorkspacePermissionAuth, requireSecretAuth, requireSecretsAuth, + requireBlindIndicesEnabled, + requireE2EEOff, + requireIPAllowlistCheck, validateRequest, }; diff --git a/backend/src/middleware/requireBlindIndicesEnabled.ts b/backend/src/middleware/requireBlindIndicesEnabled.ts new file mode 100644 index 000000000..b1288dd2b --- /dev/null +++ b/backend/src/middleware/requireBlindIndicesEnabled.ts @@ -0,0 +1,34 @@ +import { NextFunction, Request, Response } from "express"; +import { Types } from "mongoose"; +import { SecretBlindIndexData } from "../models"; +import { UnauthorizedRequestError } from "../utils/errors"; + +type req = "params" | "body" | "query"; + +/** + * Validate if workspace with [workspaceId] has blind indices enabled + * @param {Object} obj + * @param {String} obj.locationWorkspaceId - location of [workspaceId] on request (e.g. params, body) for parsing + * @returns + */ +const requireBlindIndicesEnabled = ({ + locationWorkspaceId +}: { + locationWorkspaceId: req; +}) => { + return async (req: Request, res: Response, next: NextFunction) => { + const workspaceId = req[locationWorkspaceId]?.workspaceId; + + const secretBlindIndexData = await SecretBlindIndexData.exists({ + workspace: new Types.ObjectId(workspaceId) + }); + + if (!secretBlindIndexData) throw UnauthorizedRequestError({ + message: "Failed workspace authorization due to blind indices not being enabled" + }); + + return next(); + } +} + +export default requireBlindIndicesEnabled; \ No newline at end of file diff --git a/backend/src/middleware/requireE2EEOff.ts b/backend/src/middleware/requireE2EEOff.ts new file mode 100644 index 000000000..a9e5a735b --- /dev/null +++ b/backend/src/middleware/requireE2EEOff.ts @@ -0,0 +1,31 @@ +import { NextFunction, Request, Response } from "express"; +import { BadRequestError } from "../utils/errors"; +import { BotService } from "../services"; + +type req = "params" | "body" | "query"; + +/** + * Validate if workspace with [workspaceId] has E2EE off/disabled + * @param {Object} obj + * @param {String} obj.locationWorkspaceId - location of [workspaceId] on request (e.g. params, body) for parsing + * @returns + */ +const requireE2EEOff = ({ + locationWorkspaceId +}: { + locationWorkspaceId: req; +}) => { + return async (req: Request, _: Response, next: NextFunction) => { + const workspaceId = req[locationWorkspaceId]?.workspaceId; + + const isWorkspaceE2EE = await BotService.getIsWorkspaceE2EE(workspaceId); + + if (isWorkspaceE2EE) throw BadRequestError({ + message: "Failed workspace authorization due to end-to-end encryption not being disabled" + }); + + return next(); + } +} + +export default requireE2EEOff; \ No newline at end of file diff --git a/backend/src/middleware/requireIPAllowlistCheck.ts b/backend/src/middleware/requireIPAllowlistCheck.ts new file mode 100644 index 000000000..411c802a9 --- /dev/null +++ b/backend/src/middleware/requireIPAllowlistCheck.ts @@ -0,0 +1,55 @@ +import net from "net"; +import { NextFunction, Request, Response } from "express"; +import { UnauthorizedRequestError } from "../utils/errors"; +import { extractIPDetails } from "../utils/ip"; +import { ActorType, TrustedIP } from "../ee/models"; + +type req = "params" | "body" | "query"; + +/** + * Validate if workspace with [workspaceId] has E2EE off/disabled + * @param {Object} obj + * @param {String} obj.locationWorkspaceId - location of [workspaceId] on request (e.g. params, body) for parsing + * @returns + */ +const requireIPAllowlistCheck = ({ + locationWorkspaceId +}: { + locationWorkspaceId: req; +}) => { + return async (req: Request, _: Response, next: NextFunction) => { + const workspaceId = req[locationWorkspaceId]?.workspaceId; + + if (req.authData.actor.type === ActorType.SERVICE) { + const trustedIps = await TrustedIP.find({ + workspace: workspaceId + }); + + if (trustedIps.length > 0) { + // case: check the IP address of the inbound request against trusted IPs + + const blockList = new net.BlockList(); + + for (const trustedIp of trustedIps) { + if (trustedIp.prefix !== undefined) { + blockList.addSubnet(trustedIp.ipAddress, trustedIp.prefix, trustedIp.type); + } else { + blockList.addAddress(trustedIp.ipAddress, trustedIp.type); + } + } + + const { type } = extractIPDetails(req.authData.ipAddress); + const check = blockList.check(req.authData.ipAddress, type); + + if (!check) + throw UnauthorizedRequestError({ + message: "Failed workspace authorization" + }); + } + } + + return next(); + } +} + +export default requireIPAllowlistCheck; \ No newline at end of file diff --git a/backend/src/middleware/requireWorkspaceAuth.ts b/backend/src/middleware/requireWorkspaceAuth.ts index f6f7405a9..bbf829565 100644 --- a/backend/src/middleware/requireWorkspaceAuth.ts +++ b/backend/src/middleware/requireWorkspaceAuth.ts @@ -9,24 +9,18 @@ type req = "params" | "body" | "query"; * on request params. * @param {Object} obj * @param {String[]} obj.acceptedRoles - accepted workspace roles for JWT auth - * @param {String[]} obj.location - location of [workspaceId] on request (e.g. params, body) for parsing + * @param {String} obj.locationWorkspaceId - location of [workspaceId] on request (e.g. params, body) for parsing */ const requireWorkspaceAuth = ({ acceptedRoles, locationWorkspaceId, locationEnvironment = undefined, requiredPermissions = [], - requireBlindIndicesEnabled = false, - requireE2EEOff = false, - checkIPAllowlist = false }: { acceptedRoles: Array<"admin" | "member">; locationWorkspaceId: req; locationEnvironment?: req | undefined; requiredPermissions?: string[]; - requireBlindIndicesEnabled?: boolean; - requireE2EEOff?: boolean; - checkIPAllowlist?: boolean; }) => { return async (req: Request, res: Response, next: NextFunction) => { const workspaceId = req[locationWorkspaceId]?.workspaceId; @@ -38,10 +32,7 @@ const requireWorkspaceAuth = ({ workspaceId: new Types.ObjectId(workspaceId), environment, acceptedRoles, - requiredPermissions, - requireBlindIndicesEnabled, - requireE2EEOff, - checkIPAllowlist + requiredPermissions }); if (membership) { diff --git a/backend/src/models/integration/integration.ts b/backend/src/models/integration/integration.ts index 7a7775bac..7b9957393 100644 --- a/backend/src/models/integration/integration.ts +++ b/backend/src/models/integration/integration.ts @@ -18,6 +18,7 @@ import { INTEGRATION_LARAVELFORGE, INTEGRATION_NETLIFY, INTEGRATION_NORTHFLANK, + INTEGRATION_QOVERY, INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, @@ -45,6 +46,7 @@ export interface IIntegration { targetServiceId: string; path: string; region: string; + scope: string; secretPath: string; integration: | "azure-key-vault" @@ -63,6 +65,7 @@ export interface IIntegration { | "travisci" | "supabase" | "checkly" + | "qovery" | "terraform-cloud" | "teamcity" | "hashicorp-vault" @@ -119,11 +122,13 @@ const integrationSchema = new Schema( }, targetService: { // railway-specific service + // qovery-specific project type: String, default: null, }, targetServiceId: { // railway-specific service + // qovery specific project type: String, default: null, }, @@ -143,6 +148,11 @@ const integrationSchema = new Schema( type: String, default: null, }, + scope: { + // qovery-specific scope + type: String, + default: null + }, integration: { type: String, enum: [ @@ -162,6 +172,7 @@ const integrationSchema = new Schema( INTEGRATION_TRAVISCI, INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, + INTEGRATION_QOVERY, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TEAMCITY, INTEGRATION_HASHICORP_VAULT, @@ -187,7 +198,8 @@ const integrationSchema = new Schema( default: "/", }, metadata: { - type: Schema.Types.Mixed + type: Schema.Types.Mixed, + default: {} } }, { diff --git a/backend/src/models/integration/types.ts b/backend/src/models/integration/types.ts index 0415a9556..5c4387bba 100644 --- a/backend/src/models/integration/types.ts +++ b/backend/src/models/integration/types.ts @@ -1,3 +1,8 @@ export type Metadata = { + secretPrefix?: string; secretSuffix?: string; + secretGCPLabel?: { + labelName: string; + labelValue: string; + } } \ No newline at end of file diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts deleted file mode 100644 index 5dcd3dfe8..000000000 --- a/backend/src/models/integrationAuth.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { - ALGORITHM_AES_256_GCM, - ENCODING_SCHEME_BASE64, - ENCODING_SCHEME_UTF8, - INTEGRATION_AWS_PARAMETER_STORE, - INTEGRATION_AWS_SECRET_MANAGER, - INTEGRATION_AZURE_KEY_VAULT, - INTEGRATION_BITBUCKET, - INTEGRATION_CIRCLECI, - INTEGRATION_CLOUDFLARE_PAGES, - INTEGRATION_CLOUD_66, - INTEGRATION_CODEFRESH, - INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, - INTEGRATION_FLYIO, - INTEGRATION_GCP_SECRET_MANAGER, - INTEGRATION_GITHUB, - INTEGRATION_GITLAB, - INTEGRATION_HASHICORP_VAULT, - INTEGRATION_HEROKU, - INTEGRATION_LARAVELFORGE, - INTEGRATION_NETLIFY, - INTEGRATION_NORTHFLANK, - INTEGRATION_RAILWAY, - INTEGRATION_RENDER, - INTEGRATION_SUPABASE, - INTEGRATION_TEAMCITY, - INTEGRATION_TERRAFORM_CLOUD, - INTEGRATION_TRAVISCI, - INTEGRATION_VERCEL, - INTEGRATION_WINDMILL -} from "../variables"; -import { Document, Schema, Types, model } from "mongoose"; - -export interface IIntegrationAuth extends Document { - _id: Types.ObjectId; - workspace: Types.ObjectId; - integration: - | "heroku" - | "vercel" - | "netlify" - | "github" - | "gitlab" - | "render" - | "railway" - | "flyio" - | "azure-key-vault" - | "laravel-forge" - | "circleci" - | "travisci" - | "supabase" - | "aws-parameter-store" - | "aws-secret-manager" - | "checkly" - | "cloudflare-pages" - | "codefresh" - | "digital-ocean-app-platform" - | "bitbucket" - | "cloud-66" - | "terraform-cloud" - | "teamcity" - | "northflank" - | "windmill" - | "gcp-secret-manager"; - teamId: string; - accountId: string; - url: string; - namespace: string; - refreshCiphertext?: string; - refreshIV?: string; - refreshTag?: string; - accessIdCiphertext?: string; - accessIdIV?: string; - accessIdTag?: string; - accessCiphertext?: string; - accessIV?: string; - accessTag?: string; - algorithm?: "aes-256-gcm"; - keyEncoding?: "utf8" | "base64"; - accessExpiresAt?: Date; -} - -const integrationAuthSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - integration: { - type: String, - enum: [ - INTEGRATION_AZURE_KEY_VAULT, - INTEGRATION_AWS_PARAMETER_STORE, - INTEGRATION_AWS_SECRET_MANAGER, - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_GITHUB, - INTEGRATION_GITLAB, - INTEGRATION_RENDER, - INTEGRATION_RAILWAY, - INTEGRATION_FLYIO, - INTEGRATION_CIRCLECI, - INTEGRATION_LARAVELFORGE, - INTEGRATION_TRAVISCI, - INTEGRATION_TEAMCITY, - INTEGRATION_SUPABASE, - INTEGRATION_TERRAFORM_CLOUD, - INTEGRATION_HASHICORP_VAULT, - INTEGRATION_CLOUDFLARE_PAGES, - INTEGRATION_CODEFRESH, - INTEGRATION_WINDMILL, - INTEGRATION_BITBUCKET, - INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, - INTEGRATION_CLOUD_66, - INTEGRATION_NORTHFLANK, - INTEGRATION_GCP_SECRET_MANAGER - ], - required: true, - }, - teamId: { - // vercel-specific integration param - type: String, - }, - url: { - // for any self-hosted integrations (e.g. self-hosted hashicorp-vault) - type: String, - }, - namespace: { - // hashicorp-vault-specific integration param - type: String, - }, - accountId: { - // netlify-specific integration param - type: String, - }, - refreshCiphertext: { - type: String, - select: false, - }, - refreshIV: { - type: String, - select: false, - }, - refreshTag: { - type: String, - select: false, - }, - accessIdCiphertext: { - type: String, - select: false, - }, - accessIdIV: { - type: String, - select: false, - }, - accessIdTag: { - type: String, - select: false, - }, - accessCiphertext: { - type: String, - select: false, - }, - accessIV: { - type: String, - select: false, - }, - accessTag: { - type: String, - select: false, - }, - accessExpiresAt: { - type: Date, - select: false, - }, - algorithm: { // the encryption algorithm used - type: String, - enum: [ALGORITHM_AES_256_GCM], - required: true, - }, - keyEncoding: { - type: String, - enum: [ - ENCODING_SCHEME_UTF8, - ENCODING_SCHEME_BASE64, - ], - required: true, - }, - }, - { - timestamps: true, - } -); - -export const IntegrationAuth = model( - "IntegrationAuth", - integrationAuthSchema -); \ No newline at end of file diff --git a/backend/src/models/integrationAuth/index.ts b/backend/src/models/integrationAuth/index.ts new file mode 100644 index 000000000..157095bd2 --- /dev/null +++ b/backend/src/models/integrationAuth/index.ts @@ -0,0 +1 @@ +export * from "./integrationAuth"; \ No newline at end of file diff --git a/backend/src/models/integrationAuth/integrationAuth.ts b/backend/src/models/integrationAuth/integrationAuth.ts new file mode 100644 index 000000000..312ee09d7 --- /dev/null +++ b/backend/src/models/integrationAuth/integrationAuth.ts @@ -0,0 +1,205 @@ +import { + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_BASE64, + ENCODING_SCHEME_UTF8, + INTEGRATION_AWS_PARAMETER_STORE, + INTEGRATION_AWS_SECRET_MANAGER, + INTEGRATION_AZURE_KEY_VAULT, + INTEGRATION_BITBUCKET, + INTEGRATION_CIRCLECI, + INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUD_66, + INTEGRATION_CODEFRESH, + INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, + INTEGRATION_FLYIO, + INTEGRATION_GCP_SECRET_MANAGER, + INTEGRATION_GITHUB, + INTEGRATION_GITLAB, + INTEGRATION_HASHICORP_VAULT, + INTEGRATION_HEROKU, + INTEGRATION_LARAVELFORGE, + INTEGRATION_NETLIFY, + INTEGRATION_NORTHFLANK, + INTEGRATION_RAILWAY, + INTEGRATION_RENDER, + INTEGRATION_SUPABASE, + INTEGRATION_TEAMCITY, + INTEGRATION_TERRAFORM_CLOUD, + INTEGRATION_TRAVISCI, + INTEGRATION_VERCEL, + INTEGRATION_WINDMILL + } from "../../variables"; + import { Document, Schema, Types, model } from "mongoose"; + import { IntegrationAuthMetadata } from "./types"; + + export interface IIntegrationAuth extends Document { + _id: Types.ObjectId; + workspace: Types.ObjectId; + integration: + | "heroku" + | "vercel" + | "netlify" + | "github" + | "gitlab" + | "render" + | "railway" + | "flyio" + | "azure-key-vault" + | "laravel-forge" + | "circleci" + | "travisci" + | "supabase" + | "aws-parameter-store" + | "aws-secret-manager" + | "checkly" + | "qovery" + | "cloudflare-pages" + | "codefresh" + | "digital-ocean-app-platform" + | "bitbucket" + | "cloud-66" + | "terraform-cloud" + | "teamcity" + | "northflank" + | "windmill" + | "gcp-secret-manager"; + teamId: string; + accountId: string; + url: string; + namespace: string; + refreshCiphertext?: string; + refreshIV?: string; + refreshTag?: string; + accessIdCiphertext?: string; + accessIdIV?: string; + accessIdTag?: string; + accessCiphertext?: string; + accessIV?: string; + accessTag?: string; + algorithm?: "aes-256-gcm"; + keyEncoding?: "utf8" | "base64"; + accessExpiresAt?: Date; + metadata?: IntegrationAuthMetadata; + } + + const integrationAuthSchema = new Schema( + { + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true, + }, + integration: { + type: String, + enum: [ + INTEGRATION_AZURE_KEY_VAULT, + INTEGRATION_AWS_PARAMETER_STORE, + INTEGRATION_AWS_SECRET_MANAGER, + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB, + INTEGRATION_GITLAB, + INTEGRATION_RENDER, + INTEGRATION_RAILWAY, + INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, + INTEGRATION_LARAVELFORGE, + INTEGRATION_TRAVISCI, + INTEGRATION_TEAMCITY, + INTEGRATION_SUPABASE, + INTEGRATION_TERRAFORM_CLOUD, + INTEGRATION_HASHICORP_VAULT, + INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CODEFRESH, + INTEGRATION_WINDMILL, + INTEGRATION_BITBUCKET, + INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, + INTEGRATION_CLOUD_66, + INTEGRATION_NORTHFLANK, + INTEGRATION_GCP_SECRET_MANAGER + ], + required: true, + }, + teamId: { + // vercel-specific integration param + type: String, + }, + url: { + // for any self-hosted integrations (e.g. self-hosted hashicorp-vault) + type: String, + }, + namespace: { + // hashicorp-vault-specific integration param + type: String, + }, + accountId: { + // netlify-specific integration param + type: String, + }, + refreshCiphertext: { + type: String, + select: false, + }, + refreshIV: { + type: String, + select: false, + }, + refreshTag: { + type: String, + select: false, + }, + accessIdCiphertext: { + type: String, + select: false, + }, + accessIdIV: { + type: String, + select: false, + }, + accessIdTag: { + type: String, + select: false, + }, + accessCiphertext: { + type: String, + select: false, + }, + accessIV: { + type: String, + select: false, + }, + accessTag: { + type: String, + select: false, + }, + accessExpiresAt: { + type: Date, + select: false, + }, + algorithm: { // the encryption algorithm used + type: String, + enum: [ALGORITHM_AES_256_GCM], + required: true, + }, + keyEncoding: { + type: String, + enum: [ + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64, + ], + required: true, + }, + metadata: { + type: Schema.Types.Mixed + } + }, + { + timestamps: true, + } + ); + + export const IntegrationAuth = model( + "IntegrationAuth", + integrationAuthSchema + ); \ No newline at end of file diff --git a/backend/src/models/integrationAuth/types.ts b/backend/src/models/integrationAuth/types.ts new file mode 100644 index 000000000..d29869e3b --- /dev/null +++ b/backend/src/models/integrationAuth/types.ts @@ -0,0 +1,5 @@ +interface GCPIntegrationAuthMetadata { + authMethod: "oauth2" | "serviceAccount" +} + +export type IntegrationAuthMetadata = GCPIntegrationAuthMetadata; \ No newline at end of file diff --git a/backend/src/models/membership.ts b/backend/src/models/membership.ts index 6c32ff64f..22a3819e2 100644 --- a/backend/src/models/membership.ts +++ b/backend/src/models/membership.ts @@ -1,55 +1,60 @@ import { Schema, Types, model } from "mongoose"; -import { ADMIN, MEMBER } from "../variables"; +import { ADMIN, CUSTOM, MEMBER, VIEWER } from "../variables"; export interface IMembershipPermission { - environmentSlug: string, - ability: string + environmentSlug: string; + ability: string; } export interface IMembership { - _id: Types.ObjectId; - user: Types.ObjectId; - inviteEmail?: string; - workspace: Types.ObjectId; - role: "admin" | "member"; - deniedPermissions: IMembershipPermission[] + _id: Types.ObjectId; + user: Types.ObjectId; + inviteEmail?: string; + workspace: Types.ObjectId; + role: "admin" | "member" | "viewer" | "custom"; + customRole: Types.ObjectId; + deniedPermissions: IMembershipPermission[]; } const membershipSchema = new Schema( - { - user: { - type: Schema.Types.ObjectId, - ref: "User", - }, - inviteEmail: { - type: String, - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - deniedPermissions: { - type: [ - { - environmentSlug: String, - ability: { - type: String, - enum: ["read", "write"], - }, - }, - ], - default: [], - }, - role: { - type: String, - enum: [ADMIN, MEMBER], - required: true, - }, - }, - { - timestamps: true, - } + { + user: { + type: Schema.Types.ObjectId, + ref: "User" + }, + inviteEmail: { + type: String + }, + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true + }, + deniedPermissions: { + type: [ + { + environmentSlug: String, + ability: { + type: String, + enum: ["read", "write"] + } + } + ], + default: [] + }, + role: { + type: String, + enum: [ADMIN, MEMBER, VIEWER, CUSTOM], + required: true + }, + customRole: { + type: Schema.Types.ObjectId, + ref: "Role" + } + }, + { + timestamps: true + } ); export const Membership = model("Membership", membershipSchema); \ No newline at end of file diff --git a/backend/src/models/membershipOrg.ts b/backend/src/models/membershipOrg.ts index b45f9cfe8..09b16be84 100644 --- a/backend/src/models/membershipOrg.ts +++ b/backend/src/models/membershipOrg.ts @@ -1,45 +1,47 @@ import { Document, Schema, Types, model } from "mongoose"; -import { ACCEPTED, ADMIN, INVITED, MEMBER, OWNER } from "../variables"; +import { ACCEPTED, ADMIN, CUSTOM, INVITED, MEMBER } from "../variables"; export interface IMembershipOrg extends Document { - _id: Types.ObjectId; - user: Types.ObjectId; - inviteEmail: string; - organization: Types.ObjectId; - role: "owner" | "admin" | "member"; - status: "invited" | "accepted"; + _id: Types.ObjectId; + user: Types.ObjectId; + inviteEmail: string; + organization: Types.ObjectId; + role: "owner" | "admin" | "member" | "custom"; + customRole: Types.ObjectId; + status: "invited" | "accepted"; } const membershipOrgSchema = new Schema( - { - user: { - type: Schema.Types.ObjectId, - ref: "User", - }, - inviteEmail: { - type: String, - }, - organization: { - type: Schema.Types.ObjectId, - ref: "Organization", - }, - role: { - type: String, - enum: [OWNER, ADMIN, MEMBER], - required: true, - }, - status: { - type: String, - enum: [INVITED, ACCEPTED], - required: true, - }, - }, - { - timestamps: true, - } + { + user: { + type: Schema.Types.ObjectId, + ref: "User" + }, + inviteEmail: { + type: String + }, + organization: { + type: Schema.Types.ObjectId, + ref: "Organization" + }, + role: { + type: String, + enum: [ADMIN, MEMBER, CUSTOM], + required: true + }, + status: { + type: String, + enum: [INVITED, ACCEPTED], + required: true + }, + customRole: { + type: Schema.Types.ObjectId, + ref: "Role" + } + }, + { + timestamps: true + } ); -export const MembershipOrg = model( - "MembershipOrg", - membershipOrgSchema -); \ No newline at end of file +export const MembershipOrg = model("MembershipOrg", membershipOrgSchema); diff --git a/backend/src/models/secretBlindIndexData.ts b/backend/src/models/secretBlindIndexData.ts index 5d0cd976a..da397d2c1 100644 --- a/backend/src/models/secretBlindIndexData.ts +++ b/backend/src/models/secretBlindIndexData.ts @@ -1,5 +1,5 @@ import { Document, Schema, Types, model } from "mongoose"; -import { +import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_BASE64, ENCODING_SCHEME_UTF8, @@ -53,4 +53,6 @@ const secretBlindIndexDataSchema = new Schema( } ); +secretBlindIndexDataSchema.index({ workspace: 1 }); + export const SecretBlindIndexData = model("SecretBlindIndexData", secretBlindIndexDataSchema); \ No newline at end of file diff --git a/backend/src/queues/integrations/syncSecretsToThirdPartyServices.ts b/backend/src/queues/integrations/syncSecretsToThirdPartyServices.ts index 3b5e04a3c..7b6819b8c 100644 --- a/backend/src/queues/integrations/syncSecretsToThirdPartyServices.ts +++ b/backend/src/queues/integrations/syncSecretsToThirdPartyServices.ts @@ -22,7 +22,6 @@ syncSecretsToThirdPartyServices.process(async (job: Job) => { } : {}), isActive: true, - app: { $ne: null } }); // for each workspace integration, sync/push secrets @@ -36,9 +35,12 @@ syncSecretsToThirdPartyServices.process(async (job: Job) => { }); const suffixedSecrets: any = {}; - if (integration.metadata?.secretSuffix) { + if (integration.metadata) { for (const key in secrets) { - const newKey = key + integration.metadata?.secretSuffix; + const prefix = (integration.metadata?.secretPrefix || ""); + const suffix = (integration.metadata?.secretSuffix || ""); + const newKey = prefix + key + suffix; + suffixedSecrets[newKey] = secrets[key]; } } diff --git a/backend/src/queues/secret-scanning/githubScanFullRepository.ts b/backend/src/queues/secret-scanning/githubScanFullRepository.ts index bd2054d90..eeb6aaf1e 100644 --- a/backend/src/queues/secret-scanning/githubScanFullRepository.ts +++ b/backend/src/queues/secret-scanning/githubScanFullRepository.ts @@ -1,201 +1,100 @@ -// import Queue, { Job } from "bull"; -// import { ProbotOctokit } from "probot" -// import { Commit, Committer, Repository } from "@octokit/webhooks-types"; -// import TelemetryService from "../../services/TelemetryService"; -// import { sendMail } from "../../helpers"; -// import GitRisks from "../../ee/models/gitRisks"; -// import { MembershipOrg, User } from "../../models"; -// import { OWNER, ADMIN } from "../../variables"; -// import { convertKeysToLowercase, getFilesFromCommit, scanContentAndGetFindings } from "../../ee/services/GithubSecretScanning/helper"; -// import { getSecretScanningGitAppId, getSecretScanningPrivateKey } from "../../config"; +import Queue, { Job } from "bull"; +import { ProbotOctokit } from "probot" +import TelemetryService from "../../services/TelemetryService"; +import { sendMail } from "../../helpers"; +import GitRisks from "../../ee/models/gitRisks"; +import { MembershipOrg, User } from "../../models"; +import { ADMIN } from "../../variables"; +import { convertKeysToLowercase, scanFullRepoContentAndGetFindings } from "../../ee/services/GithubSecretScanning/helper"; +import { getSecretScanningGitAppId, getSecretScanningPrivateKey } from "../../config"; +import { SecretMatch } from "../../ee/services/GithubSecretScanning/types"; -// const githubFullRepositoryScan = new Queue('github-historical-secret-scanning', 'redis://redis:6379'); +export const githubFullRepositorySecretScan = new Queue("github-full-repository-secret-scanning", "redis://redis:6379"); -// type TScanFullRepositoryDetails = { -// organizationId: string, -// repositories: { -// id: number; -// node_id: string; -// name: string; -// full_name: string; -// private: boolean; -// }[] | undefined -// installationId: number -// } +type TScanPushEventQueueDetails = { + organizationId: string, + installationId: number, + repository: { + id: number, + fullName: string, + }, +} -// type SecretMatch = { -// Description: string; -// StartLine: number; -// EndLine: number; -// StartColumn: number; -// EndColumn: number; -// Match: string; -// Secret: string; -// File: string; -// SymlinkFile: string; -// Commit: string; -// Entropy: number; -// Author: string; -// Email: string; -// Date: string; -// Message: string; -// Tags: string[]; -// RuleID: string; -// Fingerprint: string; -// FingerPrintWithoutCommitId: string -// }; +githubFullRepositorySecretScan.process(async (job: Job, done: Queue.DoneCallback) => { + const { organizationId, repository, installationId }: TScanPushEventQueueDetails = job.data + try { + const octokit = new ProbotOctokit({ + auth: { + appId: await getSecretScanningGitAppId(), + privateKey: await getSecretScanningPrivateKey(), + installationId: installationId + }, + }); + const findings: SecretMatch[] = await scanFullRepoContentAndGetFindings(octokit, installationId, repository.fullName) + for (const finding of findings) { + await GitRisks.findOneAndUpdate({ fingerprint: finding.Fingerprint }, + { + ...convertKeysToLowercase(finding), + installationId: installationId, + organization: organizationId, + repositoryFullName: repository.fullName, + repositoryId: repository.id + }, { + upsert: true + }).lean() + } -// type Helllo = { -// url: string; -// sha: string; -// node_id: string; -// html_url: string; -// comments_url: string; -// commit: { -// url: string; -// author: { -// name?: string | undefined; -// email?: string | undefined; -// date?: string | undefined; -// } | null; -// verification?: { -// } | undefined; -// }; -// files?: {}[] | undefined; -// }[] + // get emails of admins + const adminsOfWork = await MembershipOrg.find({ + organization: organizationId, + role: ADMIN, + }).lean() + const userEmails = await User.find({ + _id: { + $in: [adminsOfWork.map(orgMembership => orgMembership.user)] + } + }).select("email").lean() -// githubFullRepositoryScan.process(async (job: Job, done: Queue.DoneCallback) => { -// const { organizationId, repositories, installationId }: TScanFullRepositoryDetails = job.data -// const repositoryFullNamesList = repositories ? repositories.map(repoDetails => repoDetails.full_name) : [] -// const octokit = new ProbotOctokit({ -// auth: { -// appId: await getSecretScanningGitAppId(), -// privateKey: await getSecretScanningPrivateKey(), -// installationId: installationId -// }, -// }); + const usersToNotify = userEmails.map(userObject => userObject.email) -// for (const repositoryFullName of repositoryFullNamesList) { -// const [owner, repo] = repositoryFullName.split("/"); + if (findings.length) { + await sendMail({ + template: "historicalSecretLeakIncident.handlebars", + subjectLine: `Incident alert: leaked secrets found in Github repository ${repository.fullName}`, + recipients: usersToNotify, + substitutions: { + numberOfSecrets: findings.length, + } + }); + } -// let page = 1; -// while (true) { -// // octokit.repos.getco -// const { data } = await octokit.repos.listCommits({ -// owner, -// repo, -// per_page: 100, -// page -// }); - - -// await getFilesFromCommit(octokit, owner, repo, "646b386605177ed0a2cc0a596eeee0cf57666342") - - -// page++; -// } - -// } - -// done() - -// // const allFindingsByFingerprint: { [key: string]: SecretMatch; } = {} -// // for (const commit of commits) { -// // for (const filepath of [...commit.added, ...commit.modified]) { -// // try { -// // const fileContentsResponse = await octokit.repos.getContent({ -// // owner, -// // repo, -// // path: filepath, -// // }); - -// // const data: any = fileContentsResponse.data; -// // const fileContent = Buffer.from(data.content, "base64").toString(); - -// // const findings = await scanContentAndGetFindings(`\n${fileContent}`) // extra line to count lines correctly - -// // for (const finding of findings) { -// // const fingerPrintWithCommitId = `${commit.id}:${filepath}:${finding.RuleID}:${finding.StartLine}` -// // const fingerPrintWithoutCommitId = `${filepath}:${finding.RuleID}:${finding.StartLine}` -// // finding.Fingerprint = fingerPrintWithCommitId -// // finding.FingerPrintWithoutCommitId = fingerPrintWithoutCommitId -// // finding.Commit = commit.id -// // finding.File = filepath -// // finding.Author = commit.author.name -// // finding.Email = commit?.author?.email ? commit?.author?.email : "" - -// // allFindingsByFingerprint[fingerPrintWithCommitId] = finding -// // } - -// // } catch (error) { -// // done(new Error(`gitHubHistoricalScanning.process: unable to fetch content for [filepath=${filepath}] because [error=${error}]`), null) -// // } -// // } -// // } - -// // // change to update -// // for (const key in allFindingsByFingerprint) { -// // await GitRisks.findOneAndUpdate({ fingerprint: allFindingsByFingerprint[key].Fingerprint }, -// // { -// // ...convertKeysToLowercase(allFindingsByFingerprint[key]), -// // installationId: installationId, -// // organization: organizationId, -// // repositoryFullName: repository.fullName, -// // repositoryId: repository.id -// // }, { -// // upsert: true -// // }).lean() -// // } -// // // get emails of admins -// // const adminsOfWork = await MembershipOrg.find({ -// // organization: organizationId, -// // $or: [ -// // { role: OWNER }, -// // { role: ADMIN } -// // ] -// // }).lean() - -// // const userEmails = await User.find({ -// // _id: { -// // $in: [adminsOfWork.map(orgMembership => orgMembership.user)] -// // } -// // }).select("email").lean() - -// // const adminOrOwnerEmails = userEmails.map(userObject => userObject.email) - -// // const usersToNotify = pusher?.email ? [pusher.email, ...adminOrOwnerEmails] : [...adminOrOwnerEmails] -// // if (Object.keys(allFindingsByFingerprint).length) { -// // await sendMail({ -// // template: "secretLeakIncident.handlebars", -// // subjectLine: `Incident alert: leaked secrets found in Github repository ${repository.fullName}`, -// // recipients: usersToNotify, -// // substitutions: { -// // numberOfSecrets: Object.keys(allFindingsByFingerprint).length, -// // pusher_email: pusher.email, -// // pusher_name: pusher.name -// // } -// // }); -// // } - -// // const postHogClient = await TelemetryService.getPostHogClient(); -// // if (postHogClient) { -// // postHogClient.capture({ -// // event: "cloud secret scan", -// // distinctId: pusher.email, -// // properties: { -// // numberOfCommitsScanned: commits.length, -// // numberOfRisksFound: Object.keys(allFindingsByFingerprint).length, -// // } -// // }); -// // } - -// // done(null, allFindingsByFingerprint) - -// }) - -// export const scanGithubFullRepositoryForSecretLeaks = (scanFullRepositoryDetails: TScanFullRepositoryDetails) => { -// console.log("full repo scan started") -// githubFullRepositoryScan.add(scanFullRepositoryDetails) -// } + const postHogClient = await TelemetryService.getPostHogClient(); + if (postHogClient) { + postHogClient.capture({ + event: "historical cloud secret scan", + distinctId: repository.fullName, + properties: { + numberOfRisksFound: findings.length, + } + }); + } + done(null, findings) + } catch (error) { + done(new Error(`gitHubHistoricalScanning.process: an error occurred ${error}`), null) + } +}) +export const scanGithubFullRepoForSecretLeaks = (pushEventPayload: TScanPushEventQueueDetails) => { + githubFullRepositorySecretScan.add(pushEventPayload, { + attempts: 3, + backoff: { + type: "exponential", + delay: 5000 + }, + removeOnComplete: true, + removeOnFail: { + count: 20 // keep the most recent 20 jobs + } + }) +} \ No newline at end of file diff --git a/backend/src/queues/secret-scanning/githubScanPushEvent.ts b/backend/src/queues/secret-scanning/githubScanPushEvent.ts index 71a7e92d4..09261e235 100644 --- a/backend/src/queues/secret-scanning/githubScanPushEvent.ts +++ b/backend/src/queues/secret-scanning/githubScanPushEvent.ts @@ -5,7 +5,7 @@ import TelemetryService from "../../services/TelemetryService"; import { sendMail } from "../../helpers"; import GitRisks from "../../ee/models/gitRisks"; import { MembershipOrg, User } from "../../models"; -import { ADMIN, OWNER } from "../../variables"; +import { ADMIN } from "../../variables"; import { convertKeysToLowercase, scanContentAndGetFindings } from "../../ee/services/GithubSecretScanning/helper"; import { getSecretScanningGitAppId, getSecretScanningPrivateKey } from "../../config"; import { SecretMatch } from "../../ee/services/GithubSecretScanning/types"; @@ -88,10 +88,7 @@ githubPushEventSecretScan.process(async (job: Job, done: Queue.DoneCallback) => // get emails of admins const adminsOfWork = await MembershipOrg.find({ organization: organizationId, - $or: [ - { role: OWNER }, - { role: ADMIN } - ] + role: ADMIN }).lean() const userEmails = await User.find({ diff --git a/backend/src/routes/v1/auth.ts b/backend/src/routes/v1/auth.ts index d9c0d6120..a3037f311 100644 --- a/backend/src/routes/v1/auth.ts +++ b/backend/src/routes/v1/auth.ts @@ -1,6 +1,5 @@ import express from "express"; const router = express.Router(); -import { body } from "express-validator"; import { requireAuth, validateRequest } from "../../middleware"; import { authController } from "../../controllers/v1"; import { authLimiter } from "../../helpers/rateLimiter"; @@ -12,9 +11,6 @@ router.post( // TODO endpoint: deprecate (moved to api/v3/auth/login1) "/login1", authLimiter, - body("email").exists().trim().notEmpty().toLowerCase(), - body("clientPublicKey").exists().trim().notEmpty(), - validateRequest, authController.login1 ); @@ -22,9 +18,6 @@ router.post( // TODO endpoint: deprecate (moved to api/v3/auth/login2) "/login2", authLimiter, - body("email").exists().trim().notEmpty().toLowerCase(), - body("clientProof").exists().trim().notEmpty(), - validateRequest, authController.login2 ); diff --git a/backend/src/routes/v1/bot.ts b/backend/src/routes/v1/bot.ts index e50d35625..5e76288cd 100644 --- a/backend/src/routes/v1/bot.ts +++ b/backend/src/routes/v1/bot.ts @@ -1,41 +1,25 @@ import express from "express"; const router = express.Router(); -import { body, param } from "express-validator"; import { - requireAuth, - requireBotAuth, - requireWorkspaceAuth, - validateRequest, + requireAuth } from "../../middleware"; import { botController } from "../../controllers/v1"; -import { ADMIN, AuthMode, MEMBER } from "../../variables"; +import { AuthMode } from "../../variables"; router.get( - "/:workspaceId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim().notEmpty(), - validateRequest, - botController.getBotByWorkspaceId + "/:workspaceId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + botController.getBotByWorkspaceId ); router.patch( - "/:botId/active", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireBotAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - body("isActive").exists().isBoolean(), - body("botKey"), - validateRequest, - botController.setBotActiveState + "/:botId/active", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + botController.setBotActiveState ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 08298a1c6..46e74d58c 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -16,7 +16,7 @@ import integration from "./integration"; import integrationAuth from "./integrationAuth"; import secretsFolder from "./secretsFolder"; import webhooks from "./webhook"; -import secretImport from "./secretImport"; +import secretImps from "./secretImps"; export { signup, @@ -37,5 +37,5 @@ export { integrationAuth, secretsFolder, webhooks, - secretImport + secretImps }; diff --git a/backend/src/routes/v1/integration.ts b/backend/src/routes/v1/integration.ts index a5d32349e..6dda7527b 100644 --- a/backend/src/routes/v1/integration.ts +++ b/backend/src/routes/v1/integration.ts @@ -1,45 +1,14 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - requireIntegrationAuth, - requireIntegrationAuthorizationAuth, - requireWorkspaceAuth, - validateRequest, -} from "../../middleware"; -import { - ADMIN, - AuthMode, - MEMBER -} from "../../variables"; -import { body, param } from "express-validator"; +import { requireAuth } from "../../middleware"; +import { AuthMode } from "../../variables"; import { integrationController } from "../../controllers/v1"; router.post( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireIntegrationAuthorizationAuth({ - acceptedRoles: [ADMIN, MEMBER], - location: "body", - }), - body("integrationAuthId").exists().isString().trim(), - body("app").trim(), - body("isActive").exists().isBoolean(), - body("appId").trim(), - body("secretPath").default("/").isString().trim(), - body("sourceEnvironment").trim(), - body("targetEnvironment").trim(), - body("targetEnvironmentId").trim(), - body("targetService").trim(), - body("targetServiceId").trim(), - body("owner").trim(), - body("path").trim(), - body("region").trim(), - body("metadata").optional().isObject().withMessage("Metadata should be an object"), - body("metadata.secretSuffix").optional().isString().withMessage("Suffix should be a string"), - validateRequest, integrationController.createIntegration ); @@ -48,18 +17,6 @@ router.patch( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - requireIntegrationAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("integrationId").exists().trim(), - body("isActive").exists().isBoolean(), - body("app").exists().trim(), - body("secretPath").default("/").isString().trim(), - body("environment").exists().trim(), - body("appId").exists(), - body("targetEnvironment").exists(), - body("owner").exists(), - validateRequest, integrationController.updateIntegration ); @@ -68,11 +25,6 @@ router.delete( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - requireIntegrationAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("integrationId").exists().trim(), - validateRequest, integrationController.deleteIntegration ); @@ -81,13 +33,6 @@ router.post( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body", - }), - body("environment").isString().exists().trim(), - body("workspaceId").exists().trim(), - validateRequest, integrationController.manualSync ); diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index edc7314cc..e28874788 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -1,199 +1,159 @@ import express from "express"; const router = express.Router(); -import { body, param, query } from "express-validator"; -import { - requireAuth, - requireIntegrationAuthorizationAuth, - requireWorkspaceAuth, - validateRequest, -} from "../../middleware"; -import { - ADMIN, - AuthMode, - MEMBER -} from "../../variables"; +import { requireAuth } from "../../middleware"; +import { AuthMode } from "../../variables"; import { integrationAuthController } from "../../controllers/v1"; router.get( - "/integration-options", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - integrationAuthController.getIntegrationOptions + "/integration-options", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationOptions ); router.get( - "/:integrationAuthId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireIntegrationAuthorizationAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("integrationAuthId"), - validateRequest, - integrationAuthController.getIntegrationAuth + "/:integrationAuthId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuth ); router.post( - "/oauth-token", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body", - }), - body("workspaceId").exists().trim().notEmpty(), - body("code").exists().trim().notEmpty(), - body("integration").exists().trim().notEmpty(), - validateRequest, - integrationAuthController.oAuthExchange + "/oauth-token", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.oAuthExchange ); router.post( - "/access-token", - body("workspaceId").exists().trim().notEmpty(), - body("accessId").trim(), - body("accessToken").exists().trim().notEmpty(), - body("url").trim(), - body("namespace").trim(), - body("integration").exists().trim().notEmpty(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body", - }), - integrationAuthController.saveIntegrationAccessToken + "/access-token", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + integrationAuthController.saveIntegrationToken ); router.get( - "/:integrationAuthId/apps", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireIntegrationAuthorizationAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("integrationAuthId"), - query("teamId"), - query("workspaceSlug"), - validateRequest, - integrationAuthController.getIntegrationAuthApps + "/:integrationAuthId/apps", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthApps ); router.get( - "/:integrationAuthId/teams", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireIntegrationAuthorizationAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("integrationAuthId"), - validateRequest, - integrationAuthController.getIntegrationAuthTeams + "/:integrationAuthId/teams", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthTeams ); router.get( - "/:integrationAuthId/vercel/branches", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireIntegrationAuthorizationAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("integrationAuthId").exists().isString(), - query("appId").exists().isString(), - query("teamId").optional().isString(), - validateRequest, - integrationAuthController.getIntegrationAuthVercelBranches + "/:integrationAuthId/vercel/branches", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthVercelBranches ); router.get( - "/:integrationAuthId/railway/environments", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireIntegrationAuthorizationAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("integrationAuthId").exists().isString(), - query("appId").exists().isString(), - validateRequest, - integrationAuthController.getIntegrationAuthRailwayEnvironments + "/:integrationAuthId/qovery/orgs", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryOrgs ); router.get( - "/:integrationAuthId/railway/services", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireIntegrationAuthorizationAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("integrationAuthId").exists().isString(), - query("appId").exists().isString(), - validateRequest, - integrationAuthController.getIntegrationAuthRailwayServices + "/:integrationAuthId/qovery/projects", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryProjects ); router.get( - "/:integrationAuthId/bitbucket/workspaces", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireIntegrationAuthorizationAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("integrationAuthId").exists().isString(), - validateRequest, - integrationAuthController.getIntegrationAuthBitBucketWorkspaces + "/:integrationAuthId/qovery/environments", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryEnvironments ); router.get( - "/:integrationAuthId/northflank/secret-groups", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireIntegrationAuthorizationAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("integrationAuthId").exists().isString(), - query("appId").exists().isString(), - validateRequest, - integrationAuthController.getIntegrationAuthNorthflankSecretGroups + "/:integrationAuthId/qovery/apps", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryApps ); router.get( - "/:integrationAuthId/teamcity/build-configs", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireIntegrationAuthorizationAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("integrationAuthId").exists().isString(), - query("appId").exists().isString(), - validateRequest, - integrationAuthController.getIntegrationAuthTeamCityBuildConfigs + "/:integrationAuthId/qovery/containers", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryContainers +); + +router.get( + "/:integrationAuthId/qovery/jobs", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryJobs +); + +router.get( + "/:integrationAuthId/railway/environments", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthRailwayEnvironments +); + +router.get( + "/:integrationAuthId/railway/services", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthRailwayServices +); + +router.get( + "/:integrationAuthId/bitbucket/workspaces", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthBitBucketWorkspaces +); + +router.get( + "/:integrationAuthId/northflank/secret-groups", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthNorthflankSecretGroups +); + +router.get( + "/:integrationAuthId/teamcity/build-configs", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthTeamCityBuildConfigs ); router.delete( - "/:integrationAuthId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireIntegrationAuthorizationAuth({ - acceptedRoles: [ADMIN, MEMBER], - attachAccessToken: false, - }), - param("integrationAuthId"), - validateRequest, - integrationAuthController.deleteIntegrationAuth + "/:integrationAuthId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.deleteIntegrationAuth ); export default router; diff --git a/backend/src/routes/v1/inviteOrg.ts b/backend/src/routes/v1/inviteOrg.ts index f79ce61d1..0fa4ffbfe 100644 --- a/backend/src/routes/v1/inviteOrg.ts +++ b/backend/src/routes/v1/inviteOrg.ts @@ -8,23 +8,20 @@ import { AuthMode } from "../../variables"; // TODO endpoint: consider moving these endpoints to be under /organization to be more RESTful router.post( - "/signup", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - body("inviteeEmail").exists().trim().notEmpty().isEmail(), - body("organizationId").exists().trim().notEmpty(), - validateRequest, - membershipOrgController.inviteUserToOrganization + "/signup", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + membershipOrgController.inviteUserToOrganization ); router.post( - "/verify", - body("email").exists().trim().notEmpty(), - body("organizationId").exists().trim().notEmpty(), - body("code").exists().trim().notEmpty(), - validateRequest, - membershipOrgController.verifyUserToOrganization + "/verify", + body("email").exists().trim().notEmpty(), + body("organizationId").exists().trim().notEmpty(), + body("code").exists().trim().notEmpty(), + validateRequest, + membershipOrgController.verifyUserToOrganization ); export default router; diff --git a/backend/src/routes/v1/key.ts b/backend/src/routes/v1/key.ts index a72b508b9..be2c8d929 100644 --- a/backend/src/routes/v1/key.ts +++ b/backend/src/routes/v1/key.ts @@ -1,43 +1,26 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - requireWorkspaceAuth, - validateRequest, -} from "../../middleware"; -import { body, param } from "express-validator"; -import { ADMIN, AuthMode, MEMBER } from "../../variables"; +import { requireAuth } from "../../middleware"; +import { AuthMode } from "../../variables"; import { keyController } from "../../controllers/v1"; // TODO endpoint: consider moving these endpoints to be under /workspaces to be more RESTful router.post( - "/:workspaceId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - body("key").exists(), - validateRequest, - keyController.uploadKey + "/:workspaceId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + keyController.uploadKey ); -router.get( // TODO endpoint: deprecate (note: move frontend to v2/workspace/key or something) - "/:workspaceId/latest", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId"), - validateRequest, - keyController.getLatestKey +router.get( + // TODO endpoint: deprecate (note: move frontend to v2/workspace/key or something) + "/:workspaceId/latest", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + keyController.getLatestKey ); export default router; diff --git a/backend/src/routes/v1/membership.ts b/backend/src/routes/v1/membership.ts index cf38c7cbc..54b3b7c9e 100644 --- a/backend/src/routes/v1/membership.ts +++ b/backend/src/routes/v1/membership.ts @@ -1,53 +1,37 @@ import express from "express"; const router = express.Router(); -import { body, param } from "express-validator"; -import { requireAuth, validateRequest } from "../../middleware"; +import { requireAuth } from "../../middleware"; import { membershipController } from "../../controllers/v1"; -import { membershipController as EEMembershipControllers } from "../../ee/controllers/v1"; import { AuthMode } from "../../variables"; // note: ALL DEPRECIATED (moved to api/v2/workspace/:workspaceId/memberships/:membershipId) // TODO endpoint: consider moving these endpoints to be under /workspace to be more RESTful -router.get( // TODO endpoint: deprecate - used for old CLI (deprecate) - "/:workspaceId/connect", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - param("workspaceId").exists().trim(), - validateRequest, - membershipController.validateMembership +router.get( + // TODO endpoint: deprecate - used for old CLI (deprecate) + "/:workspaceId/connect", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + membershipController.validateMembership ); -router.delete( // TODO endpoint: check dashboard - "/:membershipId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - param("membershipId").exists().trim(), - validateRequest, - membershipController.deleteMembership +router.delete( + // TODO endpoint: check dashboard + "/:membershipId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + membershipController.deleteMembership ); -router.post( // TODO endpoint: check dashboard - "/:membershipId/change-role", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - body("role").exists().trim(), - validateRequest, - membershipController.changeMembershipRole -); - -router.post( // TODO endpoint: check dashboard - "/:membershipId/deny-permissions", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - param("membershipId").isMongoId().exists().trim(), - body("permissions").isArray().exists(), - validateRequest, - EEMembershipControllers.denyMembershipPermissions +router.post( + // TODO endpoint: check dashboard + "/:membershipId/change-role", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + membershipController.changeMembershipRole ); export default router; diff --git a/backend/src/routes/v1/membershipOrg.ts b/backend/src/routes/v1/membershipOrg.ts index 34899072b..1c7c47f88 100644 --- a/backend/src/routes/v1/membershipOrg.ts +++ b/backend/src/routes/v1/membershipOrg.ts @@ -5,24 +5,23 @@ import { requireAuth, validateRequest } from "../../middleware"; import { membershipOrgController } from "../../controllers/v1"; import { AuthMode } from "../../variables"; -router.post( // TODO endpoint: check dashboard - "/membershipOrg/:membershipOrgId/change-role", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - param("membershipOrgId"), - validateRequest, - membershipOrgController.changeMembershipOrgRole +router.post( + // TODO endpoint: check dashboard + "/membershipOrg/:membershipOrgId/change-role", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + param("membershipOrgId"), + validateRequest, + membershipOrgController.changeMembershipOrgRole ); router.delete( - "/:membershipOrgId", // TODO endpoint: check dashboard - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - param("membershipOrgId").exists().trim(), - validateRequest, - membershipOrgController.deleteMembershipOrg + "/:membershipOrgId", // TODO endpoint: check dashboard + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + membershipOrgController.deleteMembershipOrg ); export default router; diff --git a/backend/src/routes/v1/organization.ts b/backend/src/routes/v1/organization.ts index 78672093d..011d5fed6 100644 --- a/backend/src/routes/v1/organization.ts +++ b/backend/src/routes/v1/organization.ts @@ -1,166 +1,99 @@ import express from "express"; const router = express.Router(); -import { body, param } from "express-validator"; -import { - requireAuth, - requireOrganizationAuth, - validateRequest, -} from "../../middleware"; -import { - ACCEPTED, - ADMIN, - AuthMode, - MEMBER, - OWNER -} from "../../variables"; +import { requireAuth } from "../../middleware"; +import { AuthMode } from "../../variables"; import { organizationController } from "../../controllers/v1"; -router.get( // TODO endpoint: deprecate (moved to api/v2/users/me/organizations) - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - organizationController.getOrganizations +router.get( + // TODO endpoint: deprecate (moved to api/v2/users/me/organizations) + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.getOrganizations ); -router.post( // not used on frontend - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - body("organizationName").exists().trim().notEmpty(), - validateRequest, - organizationController.createOrganization +router.post( + // not used on frontend + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.createOrganization ); router.get( - "/:organizationId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationController.getOrganization + "/:organizationId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.getOrganization ); -router.get( // TODO endpoint: deprecate (moved to api/v2/organizations/:organizationId/memberships) - "/:organizationId/users", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationController.getOrganizationMembers +router.get( + // TODO endpoint: deprecate (moved to api/v2/organizations/:organizationId/memberships) + "/:organizationId/users", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.getOrganizationMembers ); -router.get( // TODO endpoint: move to /v2/users/me/organizations/:organizationId/workspaces - "/:organizationId/my-workspaces", // deprecated (moved to api/v2/organizations/:organizationId/workspaces) - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationController.getOrganizationWorkspaces +router.get( + // TODO endpoint: move to /v2/users/me/organizations/:organizationId/workspaces + "/:organizationId/my-workspaces", // deprecated (moved to api/v2/organizations/:organizationId/workspaces) + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.getOrganizationWorkspaces ); router.patch( - "/:organizationId/name", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("name").exists().trim().notEmpty(), - validateRequest, - organizationController.changeOrganizationName + "/:organizationId/name", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.changeOrganizationName ); router.get( - "/:organizationId/incidentContactOrg", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationController.getOrganizationIncidentContacts + "/:organizationId/incidentContactOrg", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.getOrganizationIncidentContacts ); router.post( - "/:organizationId/incidentContactOrg", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("email").exists().trim().notEmpty(), - validateRequest, - organizationController.addOrganizationIncidentContact + "/:organizationId/incidentContactOrg", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.addOrganizationIncidentContact ); router.delete( - "/:organizationId/incidentContactOrg", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("email").exists().trim().notEmpty(), - validateRequest, - organizationController.deleteOrganizationIncidentContact + "/:organizationId/incidentContactOrg", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.deleteOrganizationIncidentContact ); router.post( - "/:organizationId/customer-portal-session", // TODO endpoint: move to EE - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationController.createOrganizationPortalSession + "/:organizationId/customer-portal-session", // TODO endpoint: move to EE + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.createOrganizationPortalSession ); router.get( - "/:organizationId/workspace-memberships", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationController.getOrganizationMembersAndTheirWorkspaces + "/:organizationId/workspace-memberships", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.getOrganizationMembersAndTheirWorkspaces ); - export default router; diff --git a/backend/src/routes/v1/password.ts b/backend/src/routes/v1/password.ts index 3bccc0934..aec995cee 100644 --- a/backend/src/routes/v1/password.ts +++ b/backend/src/routes/v1/password.ts @@ -1,93 +1,51 @@ import express from "express"; const router = express.Router(); -import { body } from "express-validator"; -import { requireAuth, requireSignupAuth, validateRequest } from "../../middleware"; +import { requireAuth, requireSignupAuth } from "../../middleware"; import { passwordController } from "../../controllers/v1"; import { passwordLimiter } from "../../helpers/rateLimiter"; import { AuthMode } from "../../variables"; router.post( - "/srp1", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - body("clientPublicKey").exists().isString().trim().notEmpty(), - validateRequest, - passwordController.srp1 + "/srp1", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + passwordController.srp1 ); router.post( - "/change-password", - passwordLimiter, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - body("clientProof").exists().trim().notEmpty(), - body("protectedKey").exists().isString().trim().notEmpty(), - body("protectedKeyIV").exists().isString().trim().notEmpty(), - body("protectedKeyTag").exists().isString().trim().notEmpty(), - body("encryptedPrivateKey").exists().isString().trim().notEmpty(), // private key encrypted under new pwd - body("encryptedPrivateKeyIV").exists().isString().trim().notEmpty(), // new iv for private key - body("encryptedPrivateKeyTag").exists().isString().trim().notEmpty(), // new tag for private key - body("salt").exists().isString().trim().notEmpty(), // part of new pwd - body("verifier").exists().isString().trim().notEmpty(), // part of new pwd - validateRequest, - passwordController.changePassword + "/change-password", + passwordLimiter, + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + passwordController.changePassword ); -router.post( - "/email/password-reset", - passwordLimiter, - body("email").exists().isString().trim().notEmpty().isEmail(), - validateRequest, - passwordController.emailPasswordReset -); +router.post("/email/password-reset", passwordLimiter, passwordController.emailPasswordReset); router.post( - "/email/password-reset-verify", - passwordLimiter, - body("email").exists().isString().trim().notEmpty().isEmail(), - body("code").exists().isString().trim().notEmpty(), - validateRequest, - passwordController.emailPasswordResetVerify + "/email/password-reset-verify", + passwordLimiter, + passwordController.emailPasswordResetVerify ); router.get( - "/backup-private-key", - passwordLimiter, - requireSignupAuth, - passwordController.getBackupPrivateKey + "/backup-private-key", + passwordLimiter, + requireSignupAuth, + passwordController.getBackupPrivateKey ); router.post( - "/backup-private-key", - passwordLimiter, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - body("clientProof").exists().isString().trim().notEmpty(), - body("encryptedPrivateKey").exists().isString().trim().notEmpty(), // (backup) private key encrypted under a strong key - body("iv").exists().isString().trim().notEmpty(), // new iv for (backup) private key - body("tag").exists().isString().trim().notEmpty(), // new tag for (backup) private key - body("salt").exists().isString().trim().notEmpty(), // salt generated from strong key - body("verifier").exists().isString().trim().notEmpty(), // salt generated from strong key - validateRequest, - passwordController.createBackupPrivateKey + "/backup-private-key", + passwordLimiter, + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + passwordController.createBackupPrivateKey ); -router.post( - "/password-reset", - requireSignupAuth, - body("protectedKey").exists().isString().trim().notEmpty(), - body("protectedKeyIV").exists().isString().trim().notEmpty(), - body("protectedKeyTag").exists().isString().trim().notEmpty(), - body("encryptedPrivateKey").exists().isString().trim().notEmpty(), // private key encrypted under new pwd - body("encryptedPrivateKeyIV").exists().isString().trim().notEmpty(), // new iv for private key - body("encryptedPrivateKeyTag").exists().isString().trim().notEmpty(), // new tag for private key - body("salt").exists().isString().trim().notEmpty(), // part of new pwd - body("verifier").exists().isString().trim().notEmpty(), // part of new pwd - validateRequest, - passwordController.resetPassword -); +router.post("/password-reset", requireSignupAuth, passwordController.resetPassword); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/routes/v1/secretImport.ts b/backend/src/routes/v1/secretImport.ts deleted file mode 100644 index 21973f57e..000000000 --- a/backend/src/routes/v1/secretImport.ts +++ /dev/null @@ -1,84 +0,0 @@ -import express from "express"; -import { body, param, query } from "express-validator"; -import { secretImportController } from "../../controllers/v1"; -import { requireAuth, requireWorkspaceAuth, validateRequest } from "../../middleware"; -import { ADMIN, AuthMode, MEMBER } from "../../variables"; -const router = express.Router(); - -router.post( - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT,AuthMode.SERVICE_TOKEN] - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body" - }), - body("workspaceId").exists().isString().trim().notEmpty(), - body("environment").exists().isString().trim().notEmpty(), - body("folderId").default("root").isString().trim(), - body("secretImport").exists().isObject(), - body("secretImport.environment").isString().exists().trim(), - body("secretImport.secretPath").isString().exists().trim(), - validateRequest, - secretImportController.createSecretImport -); - -router.put( - "/:id", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT,AuthMode.SERVICE_TOKEN] - }), - param("id").exists().isString().trim(), - body("secretImports").exists().isArray(), - body("secretImports.*.environment").isString().exists().trim(), - body("secretImports.*.secretPath").isString().exists().trim(), - validateRequest, - secretImportController.updateSecretImport -); - -router.delete( - "/:id", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT,AuthMode.SERVICE_TOKEN] - }), - param("id").exists().isString().trim(), - body("secretImportPath").isString().exists().trim(), - body("secretImportEnv").isString().exists().trim(), - validateRequest, - secretImportController.deleteSecretImport -); - -router.get( - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT,AuthMode.SERVICE_TOKEN] - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "query" - }), - query("workspaceId").exists().isString().trim().notEmpty(), - query("environment").exists().isString().trim().notEmpty(), - query("folderId").default("root").isString().trim(), - validateRequest, - secretImportController.getSecretImports -); - -router.get( - "/secrets", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT,AuthMode.SERVICE_TOKEN] - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "query" - }), - query("workspaceId").exists().isString().trim().notEmpty(), - query("environment").exists().isString().trim().notEmpty(), - query("folderId").default("root").isString().trim(), - validateRequest, - secretImportController.getAllSecretsFromImport -); - -export default router; diff --git a/backend/src/routes/v1/secretImps.ts b/backend/src/routes/v1/secretImps.ts new file mode 100644 index 000000000..1d2696d60 --- /dev/null +++ b/backend/src/routes/v1/secretImps.ts @@ -0,0 +1,47 @@ +import express from "express"; +const router = express.Router(); +import { requireAuth } from "../../middleware"; +import { secretImpsController } from "../../controllers/v1"; +import { AuthMode } from "../../variables"; + +router.post( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + }), + secretImpsController.createSecretImp +); + +router.put( + "/:id", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + }), + secretImpsController.updateSecretImport +); + +router.delete( + "/:id", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + }), + secretImpsController.deleteSecretImport +); + +router.get( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + }), + secretImpsController.getSecretImports +); + +router.get( + "/secrets", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + }), + secretImpsController.getAllSecretsFromImport +); + +export default router; diff --git a/backend/src/routes/v1/secretsFolder.ts b/backend/src/routes/v1/secretsFolder.ts index 4580e50d3..00ca2acea 100644 --- a/backend/src/routes/v1/secretsFolder.ts +++ b/backend/src/routes/v1/secretsFolder.ts @@ -1,66 +1,43 @@ import express from "express"; -import { body, param, query } from "express-validator"; +const router = express.Router(); +import { requireAuth } from "../../middleware"; import { createFolder, deleteFolder, getFolders, updateFolderById } from "../../controllers/v1/secretsFolderController"; -import { requireAuth, requireWorkspaceAuth, validateRequest } from "../../middleware"; -import { ADMIN, AuthMode, MEMBER } from "../../variables"; -const router = express.Router(); +import { AuthMode } from "../../variables"; router.post( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT,AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body" - }), - body("workspaceId").exists(), - body("environment").exists(), - body("folderName").exists(), - body("parentFolderId"), - validateRequest, createFolder ); router.patch( "/:folderId", requireAuth({ - acceptedAuthModes: [AuthMode.JWT,AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] }), - body("workspaceId").exists(), - body("environment").exists(), - param("folderId").not().isIn(["root"]).exists(), - validateRequest, updateFolderById ); router.delete( "/:folderId", requireAuth({ - acceptedAuthModes: [AuthMode.JWT,AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] }), - body("workspaceId").exists(), - body("environment").exists(), - param("folderId").not().isIn(["root"]).exists(), - validateRequest, deleteFolder ); router.get( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT,AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] }), - query("workspaceId").exists().isString().trim(), - query("environment").exists().isString().trim(), - query("parentFolderId").optional().isString().trim(), - query("parentFolderPath").optional().isString().trim(), - validateRequest, getFolders ); diff --git a/backend/src/routes/v1/signup.ts b/backend/src/routes/v1/signup.ts index 80d250b1a..f3b5d3adf 100644 --- a/backend/src/routes/v1/signup.ts +++ b/backend/src/routes/v1/signup.ts @@ -1,27 +1,21 @@ import express from "express"; const router = express.Router(); -import { body } from "express-validator"; -import { validateRequest } from "../../middleware"; import { signupController } from "../../controllers/v1"; import { authLimiter } from "../../helpers/rateLimiter"; // TODO: consider moving to users/v3/signup -router.post( // TODO endpoint: consider moving to v3/users/signup/mail - "/email/signup", - authLimiter, - body("email").exists().trim().notEmpty().isEmail(), - validateRequest, - signupController.beginEmailSignup +router.post( + // TODO endpoint: consider moving to v3/users/signup/mail + "/email/signup", + authLimiter, + signupController.beginEmailSignup ); router.post( - "/email/verify", // TODO endpoint: consider moving to v3/users/signup/verify - authLimiter, - body("email").exists().trim().notEmpty().isEmail(), - body("code").exists().trim().notEmpty(), - validateRequest, - signupController.verifyEmailSignup + "/email/verify", // TODO endpoint: consider moving to v3/users/signup/verify + authLimiter, + signupController.verifyEmailSignup ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/routes/v1/userAction.ts b/backend/src/routes/v1/userAction.ts index 7fd26f783..762e1cde3 100644 --- a/backend/src/routes/v1/userAction.ts +++ b/backend/src/routes/v1/userAction.ts @@ -1,29 +1,25 @@ import express from "express"; const router = express.Router(); -import { requireAuth, validateRequest } from "../../middleware"; -import { body, query } from "express-validator"; +import { requireAuth } from "../../middleware"; import { userActionController } from "../../controllers/v1"; import { AuthMode } from "../../variables"; // note: [userAction] will be deprecated in /v2 in favor of [action] -router.post( // TODO endpoint: move this into /users/me - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - body("action"), - validateRequest, - userActionController.addUserAction +router.post( + // TODO endpoint: move this into /users/me + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + userActionController.addUserAction ); router.get( - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - query("action"), - validateRequest, - userActionController.getUserAction + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + userActionController.getUserAction ); export default router; diff --git a/backend/src/routes/v1/webhook.ts b/backend/src/routes/v1/webhook.ts index 16264d4ed..30c59a15b 100644 --- a/backend/src/routes/v1/webhook.ts +++ b/backend/src/routes/v1/webhook.ts @@ -1,74 +1,46 @@ import express from "express"; const router = express.Router(); -import { requireAuth, requireWorkspaceAuth, validateRequest } from "../../middleware"; -import { body, param, query } from "express-validator"; -import { ADMIN, AuthMode, MEMBER } from "../../variables"; +import { requireAuth } from "../../middleware"; +import { AuthMode } from "../../variables"; import { webhookController } from "../../controllers/v1"; router.post( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body", - locationEnvironment: "body" - }), - body("workspaceId").exists().isString().trim(), - body("environment").exists().isString().trim(), - body("webhookUrl").exists().isString().isURL().trim(), - body("webhookSecretKey").isString().trim(), - body("secretPath").default("/").isString().trim(), - validateRequest, webhookController.createWebhook ); router.patch( "/:webhookId", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - param("webhookId").exists().isString().trim(), - body("isDisabled").default(false).isBoolean(), - validateRequest, webhookController.updateWebhook ); router.post( "/:webhookId/test", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - param("webhookId").exists().isString().trim(), - validateRequest, webhookController.testWebhook ); router.delete( "/:webhookId", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - param("webhookId").exists().isString().trim(), - validateRequest, webhookController.deleteWebhook ); router.get( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "query", - locationEnvironment: "query" - }), - query("workspaceId").exists().isString().trim(), - query("environment").optional().isString().trim(), - query("secretPath").optional().isString().trim(), - validateRequest, webhookController.listWebhooks ); diff --git a/backend/src/routes/v1/workspace.ts b/backend/src/routes/v1/workspace.ts index f08d92865..4dbc121b9 100644 --- a/backend/src/routes/v1/workspace.ts +++ b/backend/src/routes/v1/workspace.ts @@ -1,163 +1,95 @@ import express from "express"; const router = express.Router(); -import { body, param } from "express-validator"; -import { - requireAuth, - requireWorkspaceAuth, - validateRequest, -} from "../../middleware"; -import { - ADMIN, - AuthMode, - MEMBER -} from "../../variables"; +import { requireAuth } from "../../middleware"; +import { AuthMode } from "../../variables"; import { membershipController, workspaceController } from "../../controllers/v1"; router.get( - "/:workspaceId/keys", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - validateRequest, - workspaceController.getWorkspacePublicKeys + "/:workspaceId/keys", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.getWorkspacePublicKeys ); router.get( - "/:workspaceId/users", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - validateRequest, - workspaceController.getWorkspaceMemberships + "/:workspaceId/users", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.getWorkspaceMemberships ); router.get( - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - workspaceController.getWorkspaces + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + workspaceController.getWorkspaces ); router.get( - "/:workspaceId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - validateRequest, - workspaceController.getWorkspace + "/:workspaceId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.getWorkspace ); router.post( - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - body("workspaceName").exists().trim().notEmpty(), - body("organizationId").exists().trim().notEmpty(), - validateRequest, - workspaceController.createWorkspace + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.createWorkspace ); router.delete( - "/:workspaceId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - validateRequest, - workspaceController.deleteWorkspace + "/:workspaceId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.deleteWorkspace ); router.post( - "/:workspaceId/name", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - body("name").exists().trim().notEmpty(), - validateRequest, - workspaceController.changeWorkspaceName + "/:workspaceId/name", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.changeWorkspaceName ); router.post( - "/:workspaceId/invite-signup", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - body("email").exists().trim().notEmpty(), - validateRequest, - membershipController.inviteUserToWorkspace + "/:workspaceId/invite-signup", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + membershipController.inviteUserToWorkspace ); router.get( - "/:workspaceId/integrations", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - validateRequest, - workspaceController.getWorkspaceIntegrations + "/:workspaceId/integrations", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.getWorkspaceIntegrations ); router.get( - "/:workspaceId/authorizations", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - validateRequest, - workspaceController.getWorkspaceIntegrationAuthorizations + "/:workspaceId/authorizations", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.getWorkspaceIntegrationAuthorizations ); router.get( - "/:workspaceId/service-tokens", // TODO endpoint: deprecate - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - validateRequest, - workspaceController.getWorkspaceServiceTokens + "/:workspaceId/service-tokens", // TODO endpoint: deprecate + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.getWorkspaceServiceTokens ); export default router; diff --git a/backend/src/routes/v2/auth.ts b/backend/src/routes/v2/auth.ts index e2d45f452..c7aa6b066 100644 --- a/backend/src/routes/v2/auth.ts +++ b/backend/src/routes/v2/auth.ts @@ -5,7 +5,8 @@ import { requireMfaAuth, validateRequest } from "../../middleware"; import { authController } from "../../controllers/v2"; import { authLimiter } from "../../helpers/rateLimiter"; -router.post( // TODO: deprecate (moved to api/v3/auth/login1) +router.post( + // TODO: deprecate (moved to api/v3/auth/login1) "/login1", authLimiter, body("email").isString().trim().notEmpty().toLowerCase(), @@ -14,7 +15,8 @@ router.post( // TODO: deprecate (moved to api/v3/auth/login1) authController.login1 ); -router.post( // TODO: deprecate (moved to api/v3/auth/login1) +router.post( + // TODO: deprecate (moved to api/v3/auth/login1) "/login2", authLimiter, body("email").isString().trim().notEmpty().toLowerCase(), @@ -23,22 +25,9 @@ router.post( // TODO: deprecate (moved to api/v3/auth/login1) authController.login2 ); -router.post( - "/mfa/send", - authLimiter, - body("email").isString().trim().notEmpty().isEmail(), - validateRequest, - authController.sendMfaToken -); +//remove above ones after depreciation +router.post("/mfa/send", authLimiter, authController.sendMfaToken); -router.post( - "/mfa/verify", - authLimiter, - requireMfaAuth, - body("email").isString().trim().notEmpty(), - body("mfaToken").isString().trim().notEmpty(), - validateRequest, - authController.verifyMfaToken -); +router.post("/mfa/verify", authLimiter, requireMfaAuth, authController.verifyMfaToken); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/routes/v2/environment.ts b/backend/src/routes/v2/environment.ts index a6d5baa13..9682a04a2 100644 --- a/backend/src/routes/v2/environment.ts +++ b/backend/src/routes/v2/environment.ts @@ -1,96 +1,47 @@ import express from "express"; const router = express.Router(); -import { body, param } from "express-validator"; import { environmentController } from "../../controllers/v2"; -import { - requireAuth, - requireWorkspaceAuth, - validateRequest, -} from "../../middleware"; -import { - ADMIN, - AuthMode, - MEMBER -} from "../../variables"; +import { requireAuth } from "../../middleware"; +import { AuthMode } from "../../variables"; router.post( "/:workspaceId/environments", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - body("environmentSlug").exists().trim(), - body("environmentName").exists().trim(), - validateRequest, environmentController.createWorkspaceEnvironment ); router.put( "/:workspaceId/environments", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - body("environmentSlug").exists().trim(), - body("environmentName").exists().trim(), - body("oldEnvironmentSlug").exists().trim(), - validateRequest, environmentController.renameWorkspaceEnvironment ); router.patch( "/:workspaceId/environments", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - body("environmentSlug").exists().isString().trim(), - body("environmentName").exists().isString().trim(), - body("otherEnvironmentSlug").exists().isString().trim(), - body("otherEnvironmentName").exists().isString().trim(), - validateRequest, environmentController.reorderWorkspaceEnvironments ); router.delete( "/:workspaceId/environments", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - body("environmentSlug").exists().trim(), - validateRequest, environmentController.deleteWorkspaceEnvironment ); router.get( "/:workspaceId/environments", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireWorkspaceAuth({ - acceptedRoles: [MEMBER, ADMIN], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - validateRequest, environmentController.getAllAccessibleEnvironmentsOfWorkspace ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/routes/v2/organizations.ts b/backend/src/routes/v2/organizations.ts index 55f8606df..d1387bd42 100644 --- a/backend/src/routes/v2/organizations.ts +++ b/backend/src/routes/v2/organizations.ts @@ -1,102 +1,57 @@ import express from "express"; const router = express.Router(); import { - requireAuth, - requireMembershipOrgAuth, - requireOrganizationAuth, - validateRequest, + requireAuth, + requireOrganizationAuth } from "../../middleware"; -import { body, param } from "express-validator"; -import { - ACCEPTED, - ADMIN, - AuthMode, - MEMBER, - OWNER -} from "../../variables"; +import { ACCEPTED, ADMIN, AuthMode } from "../../variables"; import { organizationsController } from "../../controllers/v2"; // TODO: /POST to create membership router.get( - "/:organizationId/memberships", - param("organizationId").exists().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - organizationsController.getOrganizationMemberships + "/:organizationId/memberships", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + organizationsController.getOrganizationMemberships ); router.patch( - "/:organizationId/memberships/:membershipId", - param("organizationId").exists().trim(), - param("membershipId").exists().trim(), - body("role").exists().isString().trim().isIn([OWNER, ADMIN, MEMBER]), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - requireMembershipOrgAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - organizationsController.updateOrganizationMembership + "/:organizationId/memberships/:membershipId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + organizationsController.updateOrganizationMembership ); router.delete( - "/:organizationId/memberships/:membershipId", - param("organizationId").exists().trim(), - param("membershipId").exists().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - requireMembershipOrgAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - organizationsController.deleteOrganizationMembership + "/:organizationId/memberships/:membershipId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + organizationsController.deleteOrganizationMembership ); router.get( - "/:organizationId/workspaces", - param("organizationId").exists().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - organizationsController.getOrganizationWorkspaces + "/:organizationId/workspaces", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + organizationsController.getOrganizationWorkspaces ); -router.get( // TODO endpoint: deprecate service accounts - "/:organizationId/service-accounts", - param("organizationId").exists().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - organizationsController.getOrganizationServiceAccounts +router.get( + // TODO endpoint: deprecate service accounts + "/:organizationId/service-accounts", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + requireOrganizationAuth({ + acceptedRoles: [ADMIN], + acceptedStatuses: [ACCEPTED] + }), + organizationsController.getOrganizationServiceAccounts ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index cb60035a7..c175335ff 100644 --- a/backend/src/routes/v2/secrets.ts +++ b/backend/src/routes/v2/secrets.ts @@ -1,14 +1,12 @@ import express from "express"; const router = express.Router(); -import { Types } from "mongoose"; import { requireAuth, requireSecretsAuth, requireWorkspaceAuth, validateRequest } from "../../middleware"; -import { validateClientForSecrets } from "../../validation"; -import { body, query } from "express-validator"; +import { body } from "express-validator"; import { secretsController } from "../../controllers/v2"; import { ADMIN, @@ -19,44 +17,18 @@ import { SECRET_PERSONAL, SECRET_SHARED } from "../../variables"; -import { BatchSecretRequest } from "../../types/secret"; -router.post( // TODO endpoint: strongly consider deprecation in favor of a single operation experience on dashboard +router.post( + // TODO endpoint: strongly consider deprecation in favor of a single operation experience on dashboard "/batch", requireAuth({ acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body" - }), - body("workspaceId").exists().isString().trim(), - body("folderId").default("root").isString().trim(), - body("environment").exists().isString().trim(), - body("secretPath").optional().isString().trim(), - body("requests") - .exists() - .custom(async (requests: BatchSecretRequest[], { req }) => { - if (Array.isArray(requests)) { - const secretIds = requests - .map((request) => request.secret._id) - .filter((secretId) => secretId !== undefined); - - if (secretIds.length > 0) { - req.secrets = await validateClientForSecrets({ - authData: req.authData, - secretIds: secretIds.map((secretId: string) => new Types.ObjectId(secretId)), - requiredPermissions: [] - }); - } - } - return true; - }), - validateRequest, secretsController.batchSecrets ); -router.post( // TODO endpoint: deprecate (moved to POST api/v3/secrets) +router.post( + // TODO endpoint: deprecate (moved to POST api/v3/secrets) "/", body("workspaceId").exists().isString().trim(), body("environment").exists().isString().trim(), @@ -117,15 +89,9 @@ router.post( // TODO endpoint: deprecate (moved to POST api/v3/secrets) secretsController.createSecrets ); -router.get( // TODO endpoint: deprecate (moved to GET api/v3/secrets) +router.get( + // TODO endpoint: deprecate (moved to GET api/v3/secrets) "/", - query("workspaceId").exists().trim(), - query("environment").exists().trim(), - query("tagSlugs"), - query("folderId").default("root").isString().trim(), - query("secretPath").optional().isString().trim(), - query("include_imports").optional().default(false).isBoolean(), - validateRequest, requireAuth({ acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] }), @@ -138,7 +104,8 @@ router.get( // TODO endpoint: deprecate (moved to GET api/v3/secrets) secretsController.getSecrets ); -router.patch( // TODO endpoint: deprecate (moved to PATCH api/v3/secrets) +router.patch( + // TODO endpoint: deprecate (moved to PATCH api/v3/secrets) "/", body("secrets") .exists() @@ -173,7 +140,8 @@ router.patch( // TODO endpoint: deprecate (moved to PATCH api/v3/secrets) secretsController.updateSecrets ); -router.delete( // TODO endpoint: deprecate (moved to DELETE api/v3/secrets) +router.delete( + // TODO endpoint: deprecate (moved to DELETE api/v3/secrets) "/", body("secretIds") .exists() diff --git a/backend/src/routes/v2/serviceTokenData.ts b/backend/src/routes/v2/serviceTokenData.ts index efdc905fe..611cf6003 100644 --- a/backend/src/routes/v2/serviceTokenData.ts +++ b/backend/src/routes/v2/serviceTokenData.ts @@ -1,18 +1,9 @@ import express from "express"; const router = express.Router(); import { - requireAuth, - requireServiceTokenDataAuth, - requireWorkspaceAuth, - validateRequest + requireAuth } from "../../middleware"; -import { body, param } from "express-validator"; -import { - ADMIN, - AuthMode, - MEMBER, - PERMISSION_WRITE_SECRETS -} from "../../variables"; +import { AuthMode } from "../../variables"; import { serviceTokenDataController } from "../../controllers/v2"; router.get( @@ -28,33 +19,6 @@ router.post( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body", - locationEnvironment: "body", - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }), - body("name").exists().isString().trim(), - body("workspaceId").exists().isString().trim(), - body("scopes").exists().isArray(), - body("scopes.*.environment").exists().isString().trim(), - body("scopes.*.secretPath").exists().isString().trim(), - body("encryptedKey").exists().isString().trim(), - body("iv").exists().isString().trim(), - body("tag").exists().isString().trim(), - body("expiresIn").exists().isNumeric(), // measured in ms - body("permissions") - .isArray({ min: 1 }) - .custom((value: string[]) => { - const allowedPermissions = ["read", "write"]; - const invalidValues = value.filter((v) => !allowedPermissions.includes(v)); - if (invalidValues.length > 0) { - throw new Error(`permissions contains invalid values: ${invalidValues.join(", ")}`); - } - - return true; - }), - validateRequest, serviceTokenDataController.createServiceTokenData ); @@ -63,11 +27,6 @@ router.delete( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - requireServiceTokenDataAuth({ - acceptedRoles: [ADMIN, MEMBER] - }), - param("serviceTokenDataId").exists().trim(), - validateRequest, serviceTokenDataController.deleteServiceTokenData ); diff --git a/backend/src/routes/v2/tags.ts b/backend/src/routes/v2/tags.ts index aca1b6d8c..7926e9452 100644 --- a/backend/src/routes/v2/tags.ts +++ b/backend/src/routes/v2/tags.ts @@ -1,56 +1,30 @@ import express from "express"; const router = express.Router(); -import { body, param } from "express-validator"; import { tagController } from "../../controllers/v2"; -import { - requireAuth, - requireWorkspaceAuth, - validateRequest, -} from "../../middleware"; -import { - ADMIN, - AuthMode, - MEMBER -} from "../../variables"; +import { requireAuth } from "../../middleware"; +import { AuthMode } from "../../variables"; router.get( "/:workspaceId/tags", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - requireWorkspaceAuth({ - acceptedRoles: [MEMBER, ADMIN], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - validateRequest, tagController.getWorkspaceTags ); router.delete( "/tags/:tagId", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - param("tagId").exists().trim(), - validateRequest, tagController.deleteWorkspaceTag ); router.post( "/:workspaceId/tags", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - requireWorkspaceAuth({ - acceptedRoles: [MEMBER, ADMIN], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - body("name").exists().trim(), - body("tagColor").exists().trim(), - body("slug").exists().trim(), - validateRequest, tagController.createWorkspaceTag ); diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index 850fa2cc2..1723c1b4b 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -1,43 +1,31 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - validateRequest, -} from "../../middleware"; -import { body, param } from "express-validator"; +import { requireAuth } from "../../middleware"; import { usersController } from "../../controllers/v2"; import { AuthMode } from "../../variables"; -import { - AuthMethod -} from "../../models"; router.get( - "/me", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - usersController.getMe + "/me", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + usersController.getMe ); router.patch( - "/me/mfa", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - body("isMfaEnabled").exists().isBoolean(), - validateRequest, - usersController.updateMyMfaEnabled + "/me/mfa", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + usersController.updateMyMfaEnabled ); router.patch( - "/me/name", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - body("firstName").exists().isString(), - body("lastName").isString(), - validateRequest, - usersController.updateName + "/me/name", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + usersController.updateName ); router.put( @@ -60,56 +48,51 @@ router.put( ); router.get( - "/me/organizations", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - usersController.getMyOrganizations + "/me/organizations", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + usersController.getMyOrganizations ); router.get( - "/me/api-keys", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - usersController.getMyAPIKeys + "/me/api-keys", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + usersController.getMyAPIKeys ); router.post( - "/me/api-keys", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - body("name").exists().isString().trim(), - body("expiresIn").isNumeric(), - validateRequest, - usersController.createAPIKey + "/me/api-keys", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + usersController.createAPIKey ); router.delete( - "/me/api-keys/:apiKeyDataId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - param("apiKeyDataId").exists().trim(), - validateRequest, - usersController.deleteAPIKey + "/me/api-keys/:apiKeyDataId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + usersController.deleteAPIKey ); router.get( - "/me/sessions", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - usersController.getMySessions + "/me/sessions", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + usersController.getMySessions ); router.delete( - "/me/sessions", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - usersController.deleteMySessions + "/me/sessions", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + usersController.deleteMySessions ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/routes/v2/workspace.ts b/backend/src/routes/v2/workspace.ts index fde41d945..304f69b34 100644 --- a/backend/src/routes/v2/workspace.ts +++ b/backend/src/routes/v2/workspace.ts @@ -1,147 +1,96 @@ import express from "express"; const router = express.Router(); import { body, param, query } from "express-validator"; -import { - requireAuth, - requireMembershipAuth, - requireWorkspaceAuth, - validateRequest, -} from "../../middleware"; -import { - ADMIN, - AuthMode, - MEMBER -} from "../../variables"; +import { requireAuth, requireWorkspaceAuth, validateRequest } from "../../middleware"; +import { ADMIN, AuthMode, MEMBER } from "../../variables"; import { workspaceController } from "../../controllers/v2"; -router.post( // TODO endpoint: deprecate (moved to POST v3/secrets) - "/:workspaceId/secrets", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - body("secrets").exists(), - body("keys").exists(), - body("environment").exists().trim().notEmpty(), - body("channel"), - param("workspaceId").exists().trim(), - validateRequest, - workspaceController.pushWorkspaceSecrets -); - -router.get( // TODO endpoint: deprecate (moved to GET v3/secrets) - "/:workspaceId/secrets", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - query("environment").exists().trim(), - query("channel"), - param("workspaceId").exists().trim(), - validateRequest, - workspaceController.pullSecrets -); - -router.get( // TODO endpoint: consider moving to v3/users/me/workspaces/:workspaceId/key - "/:workspaceId/encrypted-key", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - validateRequest, - workspaceController.getWorkspaceKey +router.post( + // TODO endpoint: deprecate (moved to POST v3/secrets) + "/:workspaceId/secrets", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: "params" + }), + body("secrets").exists(), + body("keys").exists(), + body("environment").exists().trim().notEmpty(), + body("channel"), + param("workspaceId").exists().trim(), + validateRequest, + workspaceController.pushWorkspaceSecrets ); router.get( - "/:workspaceId/service-token-data", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - validateRequest, - workspaceController.getWorkspaceServiceTokenData + // TODO endpoint: deprecate (moved to GET v3/secrets) + "/:workspaceId/secrets", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: "params" + }), + query("environment").exists().trim(), + query("channel"), + param("workspaceId").exists().trim(), + validateRequest, + workspaceController.pullSecrets ); -router.get( // new - TODO: rewire dashboard to this route - "/:workspaceId/memberships", - param("workspaceId").exists().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - workspaceController.getWorkspaceMemberships +router.get( + // TODO endpoint: consider moving to v3/users/me/workspaces/:workspaceId/key + "/:workspaceId/encrypted-key", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + workspaceController.getWorkspaceKey ); -router.patch( // TODO - rewire dashboard to this route - "/:workspaceId/memberships/:membershipId", - param("workspaceId").exists().trim(), - param("membershipId").exists().trim(), - body("role").exists().isString().trim().isIn([ADMIN, MEMBER]), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN], - locationWorkspaceId: "params", - }), - requireMembershipAuth({ - acceptedRoles: [ADMIN], - locationMembershipId: "params", - }), - workspaceController.updateWorkspaceMembership +router.get( + "/:workspaceId/service-token-data", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.getWorkspaceServiceTokenData ); -router.delete( // TODO - rewire dashboard to this route - "/:workspaceId/memberships/:membershipId", - param("workspaceId").exists().trim(), - param("membershipId").exists().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN], - locationWorkspaceId: "params", - }), - requireMembershipAuth({ - acceptedRoles: [ADMIN], - locationMembershipId: "params", - }), - workspaceController.deleteWorkspaceMembership +router.get( + // new - TODO: rewire dashboard to this route + "/:workspaceId/memberships", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + workspaceController.getWorkspaceMemberships ); router.patch( - "/:workspaceId/auto-capitalization", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - body("autoCapitalization").exists().trim().notEmpty(), - validateRequest, - workspaceController.toggleAutoCapitalization + // TODO - rewire dashboard to this route + "/:workspaceId/memberships/:membershipId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + workspaceController.updateWorkspaceMembership +); + +router.delete( + // TODO - rewire dashboard to this route + "/:workspaceId/memberships/:membershipId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + workspaceController.deleteWorkspaceMembership +); + +router.patch( + "/:workspaceId/auto-capitalization", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.toggleAutoCapitalization ); export default router; diff --git a/backend/src/routes/v3/auth.ts b/backend/src/routes/v3/auth.ts index ba6f30335..44fdaef4b 100644 --- a/backend/src/routes/v3/auth.ts +++ b/backend/src/routes/v3/auth.ts @@ -1,29 +1,11 @@ import express from "express"; -import { body } from "express-validator"; -import { validateRequest } from "../../middleware"; import { authController } from "../../controllers/v3"; import { authLimiter } from "../../helpers/rateLimiter"; const router = express.Router(); -router.post( - "/login1", - authLimiter, - body("email").isString().trim().toLowerCase(), - body("providerAuthToken").isString().trim().optional({nullable: true}), - body("clientPublicKey").isString().trim().notEmpty(), - validateRequest, - authController.login1 -); +router.post("/login1", authLimiter, authController.login1); -router.post( - "/login2", - authLimiter, - body("email").isString().trim().toLowerCase(), - body("providerAuthToken").isString().trim().optional({nullable: true}), - body("clientProof").isString().trim().notEmpty(), - validateRequest, - authController.login2 -); +router.post("/login2", authLimiter, authController.login2); export default router; diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index 648fc57f5..1765c2e33 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -1,288 +1,130 @@ import express from "express"; const router = express.Router(); -import { requireAuth, requireWorkspaceAuth, validateRequest } from "../../middleware"; -import { body, param, query } from "express-validator"; +import { + requireAuth, + requireBlindIndicesEnabled, + requireE2EEOff +} from "../../middleware"; import { secretsController } from "../../controllers/v3"; import { - ADMIN, - AuthMode, - MEMBER, - PERMISSION_READ_SECRETS, - PERMISSION_WRITE_SECRETS, - SECRET_PERSONAL, - SECRET_SHARED + AuthMode } from "../../variables"; router.get( "/raw", - query("workspaceId").optional().isString().trim(), - query("environment").optional().isString().trim(), - query("folderId").optional().isString().trim(), - query("secretPath").default("/").isString().trim(), - query("include_imports").optional().isBoolean().default(false), - validateRequest, requireAuth({ - acceptedAuthModes: [ - AuthMode.JWT, - AuthMode.API_KEY, - AuthMode.SERVICE_TOKEN - ] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] }), secretsController.getSecretsRaw ); router.get( "/raw/:secretName", - param("secretName").exists().isString().trim(), - query("workspaceId").exists().isString().trim(), - query("environment").exists().isString().trim(), - query("secretPath").default("/").isString().trim(), - query("type").optional().isIn([SECRET_SHARED, SECRET_PERSONAL]), - validateRequest, requireAuth({ - acceptedAuthModes: [ - AuthMode.JWT, - AuthMode.API_KEY, - AuthMode.SERVICE_TOKEN - ] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "query", - locationEnvironment: "query", - requiredPermissions: [PERMISSION_READ_SECRETS], - requireBlindIndicesEnabled: true, - requireE2EEOff: true, - checkIPAllowlist: false + requireBlindIndicesEnabled({ + locationWorkspaceId: "query" + }), + requireE2EEOff({ + locationWorkspaceId: "query" }), secretsController.getSecretByNameRaw ); router.post( "/raw/:secretName", - body("workspaceId").exists().isString().trim(), - body("environment").exists().isString().trim(), - body("type").exists().isIn([SECRET_SHARED, SECRET_PERSONAL]), - body("secretValue").exists().isString().trim(), - body("secretComment").default("").isString().trim(), - body("secretPath").default("/").isString().trim(), - validateRequest, requireAuth({ - acceptedAuthModes: [ - AuthMode.JWT, - AuthMode.API_KEY, - AuthMode.SERVICE_TOKEN - ] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body", - locationEnvironment: "body", - requiredPermissions: [PERMISSION_WRITE_SECRETS], - requireBlindIndicesEnabled: true, - requireE2EEOff: true, - checkIPAllowlist: false + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" + }), + requireE2EEOff({ + locationWorkspaceId: "body" }), secretsController.createSecretRaw ); router.patch( "/raw/:secretName", - param("secretName").exists().isString().trim(), - body("workspaceId").exists().isString().trim(), - body("environment").exists().isString().trim(), - body("type").exists().isIn([SECRET_SHARED, SECRET_PERSONAL]), - body("secretValue").exists().isString().trim(), - body("secretPath").default("/").isString().trim(), - validateRequest, requireAuth({ - acceptedAuthModes: [ - AuthMode.JWT, - AuthMode.API_KEY, - AuthMode.SERVICE_TOKEN - ] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body", - locationEnvironment: "body", - requiredPermissions: [PERMISSION_WRITE_SECRETS], - requireBlindIndicesEnabled: true, - requireE2EEOff: true, - checkIPAllowlist: false + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" + }), + requireE2EEOff({ + locationWorkspaceId: "body" }), secretsController.updateSecretByNameRaw ); router.delete( "/raw/:secretName", - param("secretName").exists().isString().trim(), - body("workspaceId").exists().isString().trim(), - body("environment").exists().isString().trim(), - body("secretPath").default("/").isString().trim(), - body("type").exists().isIn([SECRET_SHARED, SECRET_PERSONAL]), - validateRequest, requireAuth({ - acceptedAuthModes: [ - AuthMode.JWT, - AuthMode.API_KEY, - AuthMode.SERVICE_TOKEN - ] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body", - locationEnvironment: "body", - requiredPermissions: [PERMISSION_WRITE_SECRETS], - requireBlindIndicesEnabled: true, - requireE2EEOff: true, - checkIPAllowlist: false + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" + }), + requireE2EEOff({ + locationWorkspaceId: "body" }), secretsController.deleteSecretByNameRaw ); router.get( "/", - query("workspaceId").exists().isString().trim(), - query("environment").exists().isString().trim(), - query("folderId").optional().isString().trim(), - query("secretPath").default("/").isString().trim(), - validateRequest, requireAuth({ - acceptedAuthModes: [ - AuthMode.JWT, - AuthMode.API_KEY, - AuthMode.SERVICE_TOKEN - ] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "query", - locationEnvironment: "query", - requiredPermissions: [PERMISSION_READ_SECRETS], - requireBlindIndicesEnabled: true, - requireE2EEOff: false, - checkIPAllowlist: false + requireBlindIndicesEnabled({ + locationWorkspaceId: "query" }), secretsController.getSecrets ); router.post( "/:secretName", - body("workspaceId").exists().isString().trim(), - body("environment").exists().isString().trim(), - body("type").exists().isIn([SECRET_SHARED, SECRET_PERSONAL]), - body("secretKeyCiphertext").exists().isString().trim(), - body("secretKeyIV").exists().isString().trim(), - body("secretKeyTag").exists().isString().trim(), - body("secretValueCiphertext").exists().isString().trim(), - body("secretValueIV").exists().isString().trim(), - body("secretValueTag").exists().isString().trim(), - body("secretCommentCiphertext").optional().isString().trim(), - body("secretCommentIV").optional().isString().trim(), - body("secretCommentTag").optional().isString().trim(), - body("secretPath").default("/").isString().trim(), - body("metadata").optional().isObject().withMessage("Metadata should be an object"), - body("metadata.source").optional().isString().withMessage("Source should be a string"), - validateRequest, requireAuth({ - acceptedAuthModes: [ - AuthMode.JWT, - AuthMode.API_KEY, - AuthMode.SERVICE_TOKEN - ] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body", - locationEnvironment: "body", - requiredPermissions: [PERMISSION_WRITE_SECRETS], - requireBlindIndicesEnabled: true, - requireE2EEOff: false, - checkIPAllowlist: false + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" }), secretsController.createSecret ); router.get( "/:secretName", - param("secretName").exists().isString().trim(), - query("workspaceId").exists().isString().trim(), - query("environment").exists().isString().trim(), - query("secretPath").default("/").isString().trim(), - query("type").optional().isIn([SECRET_SHARED, SECRET_PERSONAL]), - validateRequest, requireAuth({ - acceptedAuthModes: [ - AuthMode.JWT, - AuthMode.API_KEY, - AuthMode.SERVICE_TOKEN - ] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "query", - locationEnvironment: "query", - requiredPermissions: [PERMISSION_READ_SECRETS], - requireBlindIndicesEnabled: true, - checkIPAllowlist: false + requireBlindIndicesEnabled({ + locationWorkspaceId: "query" }), secretsController.getSecretByName ); router.patch( "/:secretName", - param("secretName").exists().isString().trim(), - body("workspaceId").exists().isString().trim(), - body("environment").exists().isString().trim(), - body("type").exists().isIn([SECRET_SHARED, SECRET_PERSONAL]), - body("secretValueCiphertext").exists().isString().trim(), - body("secretValueIV").exists().isString().trim(), - body("secretValueTag").exists().isString().trim(), - body("secretPath").default("/").isString().trim(), - validateRequest, requireAuth({ - acceptedAuthModes: [ - AuthMode.JWT, - AuthMode.API_KEY, - AuthMode.SERVICE_TOKEN - ] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body", - locationEnvironment: "body", - requiredPermissions: [PERMISSION_WRITE_SECRETS], - requireBlindIndicesEnabled: true, - requireE2EEOff: false, - checkIPAllowlist: false + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" }), secretsController.updateSecretByName ); router.delete( "/:secretName", - param("secretName").exists().isString().trim(), - body("workspaceId").exists().isString().trim(), - body("environment").exists().isString().trim(), - body("secretPath").default("/").isString().trim(), - body("type").exists().isIn([SECRET_SHARED, SECRET_PERSONAL]), - validateRequest, requireAuth({ - acceptedAuthModes: [ - AuthMode.JWT, - AuthMode.API_KEY, - AuthMode.SERVICE_TOKEN - ] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "body", - locationEnvironment: "body", - requiredPermissions: [PERMISSION_WRITE_SECRETS], - requireBlindIndicesEnabled: true, - requireE2EEOff: false, - checkIPAllowlist: false + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" }), secretsController.deleteSecretByName ); diff --git a/backend/src/routes/v3/signup.ts b/backend/src/routes/v3/signup.ts index bfd0c9c4d..241f5d04c 100644 --- a/backend/src/routes/v3/signup.ts +++ b/backend/src/routes/v3/signup.ts @@ -1,30 +1,14 @@ import express from "express"; const router = express.Router(); -import { body } from "express-validator"; import { signupController } from "../../controllers/v3"; import { authLimiter } from "../../helpers/rateLimiter"; import { validateRequest } from "../../middleware"; router.post( - "/complete-account/signup", // TODO: consider moving endpoint to v3/users/new/complete-account/signup - authLimiter, - body("email").exists().isString().trim().notEmpty().isEmail(), - body("firstName").exists().isString().trim().notEmpty(), - body("lastName").exists().isString().trim().optional({nullable: true}), - body("protectedKey").exists().isString().trim().notEmpty(), - body("protectedKeyIV").exists().isString().trim().notEmpty(), - body("protectedKeyTag").exists().isString().trim().notEmpty(), - body("publicKey").exists().isString().trim().notEmpty(), - body("encryptedPrivateKey").exists().isString().trim().notEmpty(), - body("encryptedPrivateKeyIV").exists().isString().trim().notEmpty(), - body("encryptedPrivateKeyTag").exists().isString().trim().notEmpty(), - body("salt").exists().isString().trim().notEmpty(), - body("verifier").exists().isString().trim().notEmpty(), - body("organizationName").exists().isString().trim().notEmpty(), - body("providerAuthToken").isString().trim().optional({ nullable: true }), - body("attributionSource").optional().isString().trim(), - validateRequest, - signupController.completeAccountSignup, + "/complete-account/signup", // TODO: consider moving endpoint to v3/users/new/complete-account/signup + authLimiter, + validateRequest, + signupController.completeAccountSignup ); export default router; diff --git a/backend/src/routes/v3/workspaces.ts b/backend/src/routes/v3/workspaces.ts index 38695e8b9..834d54cd1 100644 --- a/backend/src/routes/v3/workspaces.ts +++ b/backend/src/routes/v3/workspaces.ts @@ -1,79 +1,37 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - requireWorkspaceAuth, - validateRequest, -} from "../../middleware"; +import { requireAuth } from "../../middleware"; import { workspacesController } from "../../controllers/v3"; -import { - ADMIN, - AuthMode -} from "../../variables"; -import { body, param } from "express-validator"; +import { AuthMode } from "../../variables"; // -- migration to blind indices endpoints router.get( - "/:workspaceId/secrets/blind-index-status", - param("workspaceId").exists().isString().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN], - locationWorkspaceId: "params", - }), - workspacesController.getWorkspaceBlindIndexStatus + "/:workspaceId/secrets/blind-index-status", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspacesController.getWorkspaceBlindIndexStatus ); -router.get( // allow admins to get all workspace secrets (part of blind indices migration) - "/:workspaceId/secrets", - param("workspaceId").exists().isString().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN], - locationWorkspaceId: "params", - }), - workspacesController.getWorkspaceSecrets +router.get( + // allow admins to get all workspace secrets (part of blind indices migration) + "/:workspaceId/secrets", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspacesController.getWorkspaceSecrets ); -router.post( // allow admins to name all workspace secrets (part of blind indices migration) - "/:workspaceId/secrets/names", - param("workspaceId").exists().isString().trim(), - body("secretsToUpdate") - .exists() - .isArray() - .withMessage("secretsToUpdate must be an array") - .customSanitizer((value) => { - return value.map((secret: any) => ({ - secretName: secret.secretName, - _id: secret._id, - })); - }), - body("secretsToUpdate.*.secretName") - .exists() - .isString() - .withMessage("secretName must be a string"), - body("secretsToUpdate.*._id") - .exists() - .isString() - .withMessage("secretId must be a string"), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN], - locationWorkspaceId: "params", - }), - workspacesController.nameWorkspaceSecrets +router.post( + // allow admins to name all workspace secrets (part of blind indices migration) + "/:workspaceId/secrets/names", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspacesController.nameWorkspaceSecrets ); // -- -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 9b28bd07d..06d0426f3 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -7,6 +7,7 @@ import { setIntegrationAuthRefreshHelper, } from "../helpers/integration"; import { syncSecretsToActiveIntegrationsQueue } from "../queues/integrations/syncSecretsToThirdPartyServices"; +import { IIntegrationAuth } from "../models"; /** * Class to handle integrations @@ -31,17 +32,20 @@ class IntegrationService { integration, code, environment, + url }: { workspaceId: string; integration: string; code: string; environment: string; + url?: string; }) { return await handleOAuthExchangeHelper({ workspaceId, integration, code, environment, + url }); } @@ -102,7 +106,7 @@ class IntegrationService { }: { integrationAuthId: string; refreshToken: string; - }) { + }): Promise { return await setIntegrationAuthRefreshHelper({ integrationAuthId, refreshToken, @@ -127,8 +131,8 @@ class IntegrationService { accessExpiresAt, }: { integrationAuthId: string; - accessId: string | null; - accessToken: string; + accessId?: string; + accessToken?: string; accessExpiresAt: Date | undefined; }) { return await setIntegrationAuthAccessHelper({ diff --git a/backend/src/services/RedisService.ts b/backend/src/services/RedisService.ts new file mode 100644 index 000000000..23f945bb7 --- /dev/null +++ b/backend/src/services/RedisService.ts @@ -0,0 +1,12 @@ +import Redis, { Redis as TRedis } from "ioredis"; + +let redisClient: TRedis | null; + +if (process.env.REDIS_URL) { + redisClient = new Redis(process.env.REDIS_URL as string); +} else { + console.warn("Redis URL not set, skipping Redis initialization."); + redisClient = null; +} + +export { redisClient }; diff --git a/backend/src/services/SecretImportService.ts b/backend/src/services/SecretImportService.ts index d4442b835..d07c841f5 100644 --- a/backend/src/services/SecretImportService.ts +++ b/backend/src/services/SecretImportService.ts @@ -1,18 +1,66 @@ import { Types } from "mongoose"; -import { - Folder, - ISecret, - Secret, - SecretImport -} from "../models"; +import { generateSecretBlindIndexHelper } from "../helpers"; +import { Folder, ISecret, Secret, SecretImport } from "../models"; import { getFolderByPath } from "./FolderService"; type TSecretImportFid = { environment: string; folderId: string; secretPath: string }; -export const getAllImportedSecrets = async ( +export const getAnImportedSecret = async ( + secretName: string, workspaceId: string, environment: string, folderId = "root" +) => { + const secretBlindIndex = await generateSecretBlindIndexHelper({ + secretName, + workspaceId: new Types.ObjectId(workspaceId) + }); + + const secImports = await SecretImport.findOne({ + workspace: workspaceId, + environment, + folderId + }); + if (!secImports) return; + if (secImports.imports.length === 0) return; + const folders = await Folder.find({ + workspace: workspaceId, + environment: { $in: secImports.imports.map((el) => el.environment) } + }); + + const importedSecByFid: TSecretImportFid[] = []; + secImports.imports.forEach((el) => { + const folder = folders.find((fl) => fl.environment === el.environment); + if (folder) { + const secPathFolder = getFolderByPath(folder.nodes, el.secretPath); + if (secPathFolder) + importedSecByFid.push({ + environment: el.environment, + folderId: secPathFolder.id, + secretPath: el.secretPath + }); + } else { + if (el.secretPath === "/") { + // this happens when importing with a fresh env without any folders + importedSecByFid.push({ environment: el.environment, folderId: "root", secretPath: "/" }); + } + } + }); + if (importedSecByFid.length === 0) return; + + const secret = await Secret.findOne({ + workspace: workspaceId, + secretBlindIndex + }).or(importedSecByFid.map(({ environment, folderId }) => ({ environment, folder: folderId }))).lean() + + return secret; +}; + +export const getAllImportedSecrets = async ( + workspaceId: string, + environment: string, + folderId = "root", + permissionCheckCB: (env: string, secPath: string) => boolean ) => { const secImports = await SecretImport.findOne({ workspace: workspaceId, @@ -23,7 +71,10 @@ export const getAllImportedSecrets = async ( if (secImports.imports.length === 0) return []; const importedEnv: Record = {}; // to get folders from all environment - secImports.imports.forEach((el) => (importedEnv[el.environment] = true)); + const allowedSecretImports = secImports.imports.filter((el) => + permissionCheckCB(el.environment, el.secretPath) + ); + allowedSecretImports.forEach((el) => (importedEnv[el.environment] = true)); const folders = await Folder.find({ workspace: workspaceId, @@ -31,7 +82,7 @@ export const getAllImportedSecrets = async ( }); const importedSecByFid: TSecretImportFid[] = []; - secImports.imports.forEach((el) => { + allowedSecretImports.forEach((el) => { const folder = folders.find((fl) => fl.environment === el.environment); if (folder) { const secPathFolder = getFolderByPath(folder.nodes, el.secretPath); diff --git a/backend/src/templates/historicalSecretLeakIncident.handlebars b/backend/src/templates/historicalSecretLeakIncident.handlebars new file mode 100644 index 000000000..3cb517a57 --- /dev/null +++ b/backend/src/templates/historicalSecretLeakIncident.handlebars @@ -0,0 +1,21 @@ + + + + + + + Incident alert: secrets potentially leaked + + + +

Infisical has uncovered {{numberOfSecrets}} secret(s) from historical commits to your repo

+

View leaked secrets

+ +

If these are production secrets, please rotate them immediately.

+ +

Once you have taken action, be sure to update the status of the risk in your Infisical + dashboard.

+ + + \ No newline at end of file diff --git a/backend/src/types/secret/index.d.ts b/backend/src/types/secret/index.d.ts index f60fdfd4c..05016b38e 100644 --- a/backend/src/types/secret/index.d.ts +++ b/backend/src/types/secret/index.d.ts @@ -28,7 +28,13 @@ export interface BatchSecretRequest { } export interface BatchSecret { - _id: string; + version?: number; + _id?: string; + user?: string; + environment: string; + workspace?: string; + algorithm?: string; + keyEncoding?: string; type: "shared" | "personal"; secretName: string; secretBlindIndex: string; @@ -42,5 +48,5 @@ export interface BatchSecret { secretCommentIV: string; secretCommentTag: string; tags: string[]; - folder: string + folder: string; } diff --git a/backend/src/utils/addDevelopmentUser.ts b/backend/src/utils/addDevelopmentUser.ts index d8b668909..b5b0f3495 100644 --- a/backend/src/utils/addDevelopmentUser.ts +++ b/backend/src/utils/addDevelopmentUser.ts @@ -80,7 +80,7 @@ export const createTestUserForDevelopment = async () => { const testMembershipOrg = { _id: testMembershipOrgId, organization: testOrgId, - role: "owner", + role: "admin", status: "accepted", user: testUserId, } @@ -121,7 +121,7 @@ export const createTestUserForDevelopment = async () => { const workspaceInDB = await Workspace.findById(testWorkspaceId) if (!workspaceInDB) { const workspace = await Workspace.create(testWorkspace) - + // initialize blind index salt for workspace await SecretService.createSecretBlindIndexData({ workspaceId: workspace._id, diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index 664a27707..f30eb2137 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -23,8 +23,9 @@ import { } from "../config"; import { getSSOConfigHelper } from "../ee/helpers/organizations"; import { InternalServerError, OrganizationNotFoundError } from "./errors"; -import { ACCEPTED, INVITED, MEMBER } from "../variables"; +import { ACCEPTED, INTEGRATION_GITHUB_API_URL, INVITED, MEMBER } from "../variables"; import { getSiteURL } from "../config"; +import { standardRequest } from "../config/request"; // eslint-disable-next-line @typescript-eslint/no-var-requires const GoogleStrategy = require("passport-google-oauth20").Strategy; @@ -149,10 +150,28 @@ const initializePassport = async () => { passReqToCallback: true, clientID: clientIdGitHubLogin, clientSecret: clientSecretGitHubLogin, - callbackURL: "/api/v1/sso/github" + callbackURL: "/api/v1/sso/github", + scope: ["user:email"] }, async (req : express.Request, accessToken : any, refreshToken : any, profile : any, done : any) => { - const email = profile.emails[0].value; + interface GitHubEmail { + email: string; + primary: boolean; + verified: boolean; + visibility: null | string; + } + + const { data }: { data: GitHubEmail[] } = await standardRequest.get( + `${INTEGRATION_GITHUB_API_URL}/user/emails`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + const primaryEmail = data.filter((gitHubEmail: GitHubEmail) => gitHubEmail.primary)[0]; + const email = primaryEmail.email; let user = await User.findOne({ email diff --git a/backend/src/utils/setup/backfillData.ts b/backend/src/utils/setup/backfillData.ts index 9c301722a..cc481b011 100644 --- a/backend/src/utils/setup/backfillData.ts +++ b/backend/src/utils/setup/backfillData.ts @@ -3,21 +3,19 @@ import crypto from "crypto"; import { Types } from "mongoose"; import { encryptSymmetric128BitHexKeyUTF8 } from "../crypto"; import { EESecretService } from "../../ee/services"; -import { - IPType, - ISecretVersion, - SecretSnapshot, - SecretVersion, - TrustedIP -} from "../../ee/models"; +import { redisClient } from "../../services/RedisService" +import { IPType, ISecretVersion, SecretSnapshot, SecretVersion, TrustedIP } from "../../ee/models"; import { AuthMethod, BackupPrivateKey, Bot, BotOrg, ISecret, + IWorkspace, Integration, IntegrationAuth, + Membership, + MembershipOrg, Organization, Secret, SecretBlindIndexData, @@ -28,11 +26,22 @@ import { import { generateKeyPair } from "../../utils/crypto"; import { client, getEncryptionKey, getRootEncryptionKey } from "../../config"; import { + ADMIN, ALGORITHM_AES_256_GCM, + CUSTOM, ENCODING_SCHEME_BASE64, - ENCODING_SCHEME_UTF8 + ENCODING_SCHEME_UTF8, + MEMBER, + OWNER } from "../../variables"; + import { InternalServerError } from "../errors"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + memberProjectPermissions +} from "../../ee/services/ProjectRoleService"; +import Role from "../../ee/models/role"; /** * Backfill secrets to ensure that they're all versioned and have @@ -582,7 +591,7 @@ export const backfillTrustedIps = async () => { filter: { workspace: Types.ObjectId; ipAddress: string; - }, + }; update: { workspace: Types.ObjectId; ipAddress: string; @@ -590,9 +599,9 @@ export const backfillTrustedIps = async () => { prefix: number; isActive: boolean; comment: string; - }, + }; upsert: boolean; - } + }; }[] = []; workspaceIdsToAddTrustedIp.forEach((workspaceId) => { @@ -638,7 +647,7 @@ export const backfillTrustedIps = async () => { await TrustedIP.bulkWrite(operations); console.log("Backfill: Trusted IPs complete"); } -} +}; export const backfillUserAuthMethods = async () => { await User.updateMany( @@ -655,7 +664,6 @@ export const backfillUserAuthMethods = async () => { } ); - const documentsToUpdate = await User.find({ authProvider: { $exists: true }, authMethods: { $exists: false } @@ -676,4 +684,153 @@ export const backfillUserAuthMethods = async () => { } ); } -} +}; + +export const backfillPermission = async () => { + const lockKey = "backfill_permission_lock"; + const timeout = 900000; // 15 min lock timeout in milliseconds + const lock = await redisClient?.set(lockKey, 1, "PX", timeout, "NX"); + + if (lock) { + try { + console.info("Lock acquired for script [backfillPermission]"); + + const memberships = await Membership.find({ + deniedPermissions: { + $exists: true, + $ne: [] + }, + role: MEMBER, + }) + .populate<{ workspace: IWorkspace }>("workspace") + .lean(); + + // group memberships that need the same permission set + const roleMap = new Map(); + + for (const membership of memberships) { + // get permissions of members except secret permission + const customPermissions = memberProjectPermissions.rules.filter( + ({ subject }) => subject !== ProjectPermissionSub.Secrets + ); + const secretAccessRule: Record = {}; + + // iterate and record true and false ones + membership.deniedPermissions.forEach(({ ability, environmentSlug }) => { + if (!secretAccessRule?.[environmentSlug]) + secretAccessRule[environmentSlug] = { read: true, write: true }; + if (ability === "write") secretAccessRule[environmentSlug].write = false; + if (ability === "read") secretAccessRule[environmentSlug].read = false; + }); + + // environments that are not listed in deniedPermissions should be set to allowed for both read & and write + membership.workspace.environments.forEach(env => { + if (!secretAccessRule?.[env.slug]) { + secretAccessRule[env.slug] = { read: true, write: true }; + } + }) + + const secretPermissions: any = []; + Object.entries(secretAccessRule).forEach(([envSlug, { read, write }]) => { + if (read) { + secretPermissions.push({ + subject: ProjectPermissionSub.Secrets, + action: ProjectPermissionActions.Read, + conditions: { environment: envSlug } + }); + } + if (write) { + secretPermissions.push( + { + subject: ProjectPermissionSub.Secrets, + action: ProjectPermissionActions.Edit, + conditions: { environment: envSlug } + }, + { + subject: ProjectPermissionSub.Secrets, + action: ProjectPermissionActions.Delete, + conditions: { environment: envSlug } + }, + { + subject: ProjectPermissionSub.Secrets, + action: ProjectPermissionActions.Create, + conditions: { environment: envSlug } + } + ); + } + }); + + const key = `${JSON.stringify(secretPermissions)}-${membership.workspace._id.toString()}`; // group roles that have same permission with in the same workspace + const value = roleMap.get(key); + if (value) { + value.membershipIds.push(membership._id.toString()); + value.organizationId = membership.workspace.organization.toString() + value.workspaceId = membership.workspace._id.toString() + } else { + roleMap.set(key, { membershipIds: [membership._id.toString()], permissions: [...customPermissions, ...secretPermissions], organizationId: membership.workspace.organization.toString(), workspaceId: membership.workspace._id.toString() }); + } + } + + for (const [key, value] of roleMap.entries()) { + const { membershipIds, permissions, workspaceId, organizationId } = value + const membership_identity = crypto.randomBytes(3).toString("hex") + const role = new Role({ + name: `Limited [${membership_identity.toUpperCase()}]`, + organization: organizationId, + workspace: workspaceId, + description: "This role was auto generated by Infisical in effort to migrate your project members to our new permission system", + isOrgRole: false, + slug: `custom-role-${membership_identity}`, + permissions: permissions + }); + + await role.save(); + + for (const id of membershipIds) { + await Membership.findByIdAndUpdate(id, { // document db doesn't support update many so we must loop + $set: { + role: CUSTOM, + customRole: role + } + }); + } + } + + console.info("Backfill: Finished converting old denied permission in workspace to viewers"); + + await MembershipOrg.updateMany( + { + role: OWNER + }, + { + $set: { + role: ADMIN + } + } + ); + + console.info("Backfill: Finished converting owner role to member"); + + } catch (error) { + console.error("An error occurred when running script [backfillPermission]:", error); + } + + } else { + console.info("Could not acquire lock for script [backfillPermission], skipping"); + } +}; + +export const migrateRoleFromOwnerToAdmin = async () => { + await MembershipOrg.updateMany( + { + role: OWNER + }, + { + $set: { + role: ADMIN + } + } + ); + + console.info("Backfill: Finished converting owner role to member"); +} \ No newline at end of file diff --git a/backend/src/utils/setup/index.ts b/backend/src/utils/setup/index.ts index 90072eb6f..d85d63aca 100644 --- a/backend/src/utils/setup/index.ts +++ b/backend/src/utils/setup/index.ts @@ -11,25 +11,22 @@ import { backfillBots, backfillEncryptionMetadata, backfillIntegration, + backfillPermission, backfillSecretBlindIndexData, backfillSecretFolders, backfillSecretVersions, backfillServiceToken, backfillServiceTokenMultiScope, backfillTrustedIps, - backfillUserAuthMethods + backfillUserAuthMethods, + migrateRoleFromOwnerToAdmin } from "./backfillData"; import { reencryptBotOrgKeys, reencryptBotPrivateKeys, reencryptSecretBlindIndexDataSalts } from "./reencryptData"; -import { - getMongoURL, - getNodeEnv, - getRedisUrl, - getSentryDSN -} from "../../config"; +import { getMongoURL, getNodeEnv, getRedisUrl, getSentryDSN } from "../../config"; import { initializePassport } from "../auth"; /** @@ -43,8 +40,10 @@ import { initializePassport } from "../auth"; * - Re-encrypting data */ export const setup = async () => { - if (await getRedisUrl() === undefined || await getRedisUrl() === "") { - console.error("WARNING: Redis is not yet configured. Infisical may not function as expected without it.") + if ((await getRedisUrl()) === undefined || (await getRedisUrl()) === "") { + console.error( + "WARNING: Redis is not yet configured. Infisical may not function as expected without it." + ); } await validateEncryptionKeysConfig(); @@ -86,6 +85,8 @@ export const setup = async () => { await backfillServiceTokenMultiScope(); await backfillTrustedIps(); await backfillUserAuthMethods(); + // await backfillPermission(); + await migrateRoleFromOwnerToAdmin() // re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY // to base64 256-bit ROOT_ENCRYPTION_KEY diff --git a/backend/src/validation/action.ts b/backend/src/validation/action.ts new file mode 100644 index 000000000..7c76a5365 --- /dev/null +++ b/backend/src/validation/action.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const GetActionV1 = z.object({ + params: z.object({ + actionId: z.string().trim() + }) +}); + +export const AddUserActionV1 = z.object({ + body: z.object({ + action: z.string().trim() + }) +}); + +export const GetUserActionV1 = z.object({ + query: z.object({ + action: z.string().trim() + }) +}); diff --git a/backend/src/validation/auth.ts b/backend/src/validation/auth.ts new file mode 100644 index 000000000..f32fc1197 --- /dev/null +++ b/backend/src/validation/auth.ts @@ -0,0 +1,134 @@ +import { z } from "zod"; + +export const BeginEmailSignUpV1 = z.object({ + body: z.object({ + email: z.string().email().trim() + }) +}); + +export const VerifyEmailSignUpV1 = z.object({ + body: z.object({ + email: z.string().email().trim(), + code: z.string().trim() + }) +}); + +export const Login1V1 = z.object({ + body: z.object({ + email: z.string().email().trim(), + clientPublicKey: z.string().trim() + }) +}); + +export const Login2V1 = z.object({ + body: z.object({ + email: z.string().email().trim(), + clientProof: z.string().trim() + }) +}); + +export const Srp1V1 = z.object({ + body: z.object({ + clientPublicKey: z.string().trim() + }) +}); + +export const ChangePasswordV1 = z.object({ + body: z.object({ + clientProof: z.string().trim(), + protectedKey: z.string().trim(), + protectedKeyIV: z.string().trim(), + protectedKeyTag: z.string().trim(), + encryptedPrivateKey: z.string().trim(), + encryptedPrivateKeyIV: z.string().trim(), + encryptedPrivateKeyTag: z.string().trim(), + salt: z.string().trim(), + verifier: z.string().trim() + }) +}); + +export const EmailPasswordResetV1 = z.object({ + body: z.object({ + email: z.string().email().trim() + }) +}); + +export const EmailPasswordResetVerifyV1 = z.object({ + body: z.object({ + email: z.string().email().trim(), + code: z.string().trim() + }) +}); + +export const CreateBackupPrivateKeyV1 = z.object({ + body: z.object({ + clientProof: z.string().trim(), + encryptedPrivateKey: z.string().trim(), + iv: z.string().trim(), + tag: z.string().trim(), + salt: z.string().trim(), + verifier: z.string().trim() + }) +}); + +export const ResetPasswordV1 = z.object({ + body: z.object({ + protectedKey: z.string().trim(), + protectedKeyIV: z.string().trim(), + protectedKeyTag: z.string().trim(), + encryptedPrivateKey: z.string().trim(), + encryptedPrivateKeyIV: z.string().trim(), + encryptedPrivateKeyTag: z.string().trim(), + salt: z.string().trim(), + verifier: z.string().trim() + }) +}); + +export const SendMfaTokenV2 = z.object({ + body: z.object({ + email: z.string().email().trim() + }) +}); + +export const VerifyMfaTokenV2 = z.object({ + body: z.object({ + email: z.string().email().trim(), + mfaToken: z.string().trim() + }) +}); + +export const Login1V3 = z.object({ + body: z.object({ + email: z.string().email().trim(), + providerAuthToken: z.string().trim().optional(), + clientPublicKey: z.string().trim() + }) +}); + +export const Login2V3 = z.object({ + body: z.object({ + email: z.string().email().trim(), + providerAuthToken: z.string().trim().optional(), + clientProof: z.string().trim() + }) +}); + +export const CompletedAccountSignupV3 = z.object({ + body: z.object({ + email: z.string().email().trim(), + firstName: z.string().trim(), + lastName: z.string().trim().optional(), + protectedKey: z.string().trim(), + protectedKeyIV: z.string().trim(), + protectedKeyTag: z.string().trim(), + publicKey: z.string().trim(), + encryptedPrivateKey: z.string().trim(), + encryptedPrivateKeyIV: z.string().trim(), + encryptedPrivateKeyTag: z.string().trim(), + salt: z.string().trim(), + verifier: z.string().trim(), + organizationName: z.string().trim(), + providerAuthToken: z.string().trim().optional().nullish(), + attributionSource: z.string().trim().optional() + }) +}); diff --git a/backend/src/validation/bot.ts b/backend/src/validation/bot.ts index 2bb6ec6dd..2980aeac1 100644 --- a/backend/src/validation/bot.ts +++ b/backend/src/validation/bot.ts @@ -1,15 +1,10 @@ import { Types } from "mongoose"; -import { - Bot, - IUser, -} from "../models"; +import { Bot, IUser } from "../models"; import { validateUserClientForWorkspace } from "./user"; -import { - BotNotFoundError, - UnauthorizedRequestError, -} from "../utils/errors"; +import { BotNotFoundError, UnauthorizedRequestError } from "../utils/errors"; import { AuthData } from "../interfaces/middleware"; import { ActorType } from "../ee/models"; +import { z } from "zod"; /** * Validate authenticated clients for bot with id [botId] based @@ -22,7 +17,7 @@ import { ActorType } from "../ee/models"; export const validateClientForBot = async ({ authData, botId, - acceptedRoles, + acceptedRoles }: { authData: AuthData; botId: Types.ObjectId; @@ -30,18 +25,39 @@ export const validateClientForBot = async ({ }) => { const bot = await Bot.findById(botId); if (!bot) throw BotNotFoundError(); - + switch (authData.actor.type) { case ActorType.USER: await validateUserClientForWorkspace({ user: authData.authPayload as IUser, workspaceId: bot.workspace, - acceptedRoles, + acceptedRoles }); return bot; case ActorType.SERVICE: throw UnauthorizedRequestError({ - message: "Failed service token authorization for bot", + message: "Failed service token authorization for bot" }); } -}; \ No newline at end of file +}; + +export const GetBotByWorkspaceIdV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const SetBotActiveStateV1 = z.object({ + body: z.object({ + isActive: z.boolean(), + botKey: z + .object({ + nonce: z.string().trim().optional(), + encryptedKey: z.string().trim().optional() + }) + .optional() + }), + params: z.object({ + botId: z.string().trim() + }) +}); diff --git a/backend/src/validation/cloudProducts.ts b/backend/src/validation/cloudProducts.ts new file mode 100644 index 000000000..1cfd361b3 --- /dev/null +++ b/backend/src/validation/cloudProducts.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const GetCloudProductsV1 = z.object({ + query: z.object({ + "billing-cycle": z.enum(["monthly", "yearly"]) + }) +}); diff --git a/backend/src/validation/environments.ts b/backend/src/validation/environments.ts new file mode 100644 index 000000000..6cf7cf68a --- /dev/null +++ b/backend/src/validation/environments.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +export const CreateWorkspaceEnvironmentV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + environmentSlug: z.string().trim(), + environmentName: z.string().trim() + }) +}); + +export const UpdateWorkspaceEnvironmentV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + environmentSlug: z.string().trim(), + environmentName: z.string().trim(), + oldEnvironmentSlug: z.string().trim() + }) +}); + +export const DeleteWorkspaceEnvironmentV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + environmentSlug: z.string().trim() + }) +}); + +export const GetAllAccessibileEnvironmentsOfWorkspaceV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const ReorderWorkspaceEnvironmentsV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + environmentSlug: z.string().trim(), + environmentName: z.string().trim(), + otherEnvironmentSlug: z.string().trim(), + otherEnvironmentName: z.string().trim() + }) +}); diff --git a/backend/src/validation/folders.ts b/backend/src/validation/folders.ts new file mode 100644 index 000000000..ecfadea1c --- /dev/null +++ b/backend/src/validation/folders.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +export const CreateFolderV1 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + folderName: z.string().trim(), + parentFolderId: z.string().trim().optional() + }) +}); + +export const UpdateFolderV1 = z.object({ + params: z.object({ + folderId: z.string().trim() + }), + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + name: z.string().trim() + }) +}); + +export const DeleteFolderV1 = z.object({ + params: z.object({ + folderId: z.string().trim() + }), + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim() + }) +}); + +export const GetFoldersV1 = z.object({ + query: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + parentFolderId: z.string().trim().optional(), + parentFolderPath: z.string().trim().optional() + }) +}); diff --git a/backend/src/validation/index.ts b/backend/src/validation/index.ts index 4cc25450f..409b563c6 100644 --- a/backend/src/validation/index.ts +++ b/backend/src/validation/index.ts @@ -8,4 +8,4 @@ export * from "./membershipOrg"; export * from "./organization"; export * from "./secrets"; export * from "./serviceAccount"; -export * from "./serviceTokenData"; \ No newline at end of file +export * from "./serviceTokenData"; diff --git a/backend/src/validation/integration.ts b/backend/src/validation/integration.ts index b1143a9f7..20c02efc5 100644 --- a/backend/src/validation/integration.ts +++ b/backend/src/validation/integration.ts @@ -1,18 +1,15 @@ import { Types } from "mongoose"; -import { - IUser, - Integration, - IntegrationAuth, -} from "../models"; +import { IUser, Integration, IntegrationAuth } from "../models"; import { validateUserClientForWorkspace } from "./user"; import { IntegrationService } from "../services"; import { - IntegrationAuthNotFoundError, - IntegrationNotFoundError, - UnauthorizedRequestError, + IntegrationAuthNotFoundError, + IntegrationNotFoundError, + UnauthorizedRequestError } from "../utils/errors"; import { AuthData } from "../interfaces/middleware"; import { ActorType } from "../ee/models"; +import { z } from "zod"; /** * Validate authenticated clients for integration with id [integrationId] based @@ -25,42 +22,96 @@ import { ActorType } from "../ee/models"; * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint */ export const validateClientForIntegration = async ({ - authData, - integrationId, - acceptedRoles, + authData, + integrationId, + acceptedRoles }: { - authData: AuthData; - integrationId: Types.ObjectId; - acceptedRoles: Array<"admin" | "member">; + authData: AuthData; + integrationId: Types.ObjectId; + acceptedRoles: Array<"admin" | "member">; }) => { - - const integration = await Integration.findById(integrationId); - if (!integration) throw IntegrationNotFoundError(); + const integration = await Integration.findById(integrationId); + if (!integration) throw IntegrationNotFoundError(); - const integrationAuth = await IntegrationAuth - .findById(integration.integrationAuth) - .select( - "+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt" - ); - - if (!integrationAuth) throw IntegrationAuthNotFoundError(); + const integrationAuth = await IntegrationAuth.findById(integration.integrationAuth).select( + "+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt metadata" + ); + + if (!integrationAuth) throw IntegrationAuthNotFoundError(); - const accessToken = (await IntegrationService.getIntegrationAuthAccess({ - integrationAuthId: integrationAuth._id, - })).accessToken; - - switch (authData.actor.type) { - case ActorType.USER: - await validateUserClientForWorkspace({ - user: authData.authPayload as IUser, - workspaceId: integration.workspace, - acceptedRoles, - }); - - return ({ integration, accessToken }); - case ActorType.SERVICE: - throw UnauthorizedRequestError({ - message: "Failed service token authorization for integration", - }); - } -} \ No newline at end of file + const accessToken = ( + await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id + }) + ).accessToken; + + switch (authData.actor.type) { + case ActorType.USER: + await validateUserClientForWorkspace({ + user: authData.authPayload as IUser, + workspaceId: integration.workspace, + acceptedRoles + }); + + return { integration, accessToken }; + case ActorType.SERVICE: + throw UnauthorizedRequestError({ + message: "Failed service token authorization for integration" + }); + } +}; + +export const CreateIntegrationV1 = z.object({ + body: z.object({ + integrationAuthId: z.string().trim(), + app: z.string().trim().optional(), + isActive: z.boolean(), + appId: z.string().trim().optional(), + secretPath: z.string().trim().default("/"), + sourceEnvironment: z.string().trim(), + targetEnvironment: z.string().trim().optional(), + targetEnvironmentId: z.string().trim().optional(), + targetService: z.string().trim().optional(), + targetServiceId: z.string().trim().optional(), + owner: z.string().trim().optional(), + path: z.string().trim().optional(), + region: z.string().trim().optional(), + scope: z.string().trim().optional(), + metadata: z.object({ + secretPrefix: z.string().optional(), + secretSuffix: z.string().optional(), + secretGCPLabel: z.object({ + labelName: z.string(), + labelValue: z.string() + }).optional(), + }).optional() + }) +}); + +export const UpdateIntegrationV1 = z.object({ + params: z.object({ + integrationId: z.string().trim() + }), + body: z.object({ + app: z.string().trim(), + appId: z.string().trim(), + isActive: z.boolean(), + secretPath: z.string().trim().default("/"), + targetEnvironment: z.string().trim(), + owner: z.string().trim(), + environment: z.string().trim() + }) +}); + +export const DeleteIntegrationV1 = z.object({ + params: z.object({ + integrationId: z.string().trim() + }) +}); + +export const ManualSyncV1 = z.object({ + body: z.object({ + environment: z.string().trim(), + workspaceId: z.string().trim() + }) +}); diff --git a/backend/src/validation/integrationAuth.ts b/backend/src/validation/integrationAuth.ts index 676184324..feeee4931 100644 --- a/backend/src/validation/integrationAuth.ts +++ b/backend/src/validation/integrationAuth.ts @@ -1,17 +1,11 @@ import { Types } from "mongoose"; -import { - IUser, - IWorkspace, - IntegrationAuth, -} from "../models"; -import { - IntegrationAuthNotFoundError, - UnauthorizedRequestError, -} from "../utils/errors"; +import { IUser, IWorkspace, IntegrationAuth } from "../models"; +import { IntegrationAuthNotFoundError, UnauthorizedRequestError } from "../utils/errors"; import { IntegrationService } from "../services"; import { validateUserClientForWorkspace } from "./user"; import { AuthData } from "../interfaces/middleware"; import { ActorType } from "../ee/models"; +import { z } from "zod"; /** * Validate authenticated clients for integration authorization with id [integrationAuthId] based @@ -22,53 +16,182 @@ import { ActorType } from "../ee/models"; * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint */ - const validateClientForIntegrationAuth = async ({ - authData, - integrationAuthId, - acceptedRoles, - attachAccessToken, +const validateClientForIntegrationAuth = async ({ + authData, + integrationAuthId, + acceptedRoles, + attachAccessToken }: { - authData: AuthData; - integrationAuthId: Types.ObjectId; - acceptedRoles: Array<"admin" | "member">; - attachAccessToken?: boolean; + authData: AuthData; + integrationAuthId: Types.ObjectId; + acceptedRoles: Array<"admin" | "member">; + attachAccessToken?: boolean; }) => { - - const integrationAuth = await IntegrationAuth - .findById(integrationAuthId) - .populate<{ workspace: IWorkspace }>("workspace") - .select( - "+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt" - ); + const integrationAuth = await IntegrationAuth.findById(integrationAuthId) + .populate<{ workspace: IWorkspace }>("workspace") + .select( + "+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt metadata" + ); - if (!integrationAuth) throw IntegrationAuthNotFoundError(); - - let accessToken, accessId; - if (attachAccessToken) { - const access = (await IntegrationService.getIntegrationAuthAccess({ - integrationAuthId: integrationAuth._id, - })); - - accessToken = access.accessToken; - accessId = access.accessId; - } - - switch (authData.actor.type) { - case ActorType.USER: - await validateUserClientForWorkspace({ - user: authData.authPayload as IUser, - workspaceId: integrationAuth.workspace._id, - acceptedRoles, - }); + if (!integrationAuth) throw IntegrationAuthNotFoundError(); - return ({ integrationAuth, accessToken, accessId }); - case ActorType.SERVICE: - throw UnauthorizedRequestError({ - message: "Failed service token authorization for integration authorization", - }); - } -} + let accessToken, accessId; + if (attachAccessToken) { + const access = await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id + }); -export { - validateClientForIntegrationAuth, -}; \ No newline at end of file + accessToken = access.accessToken; + accessId = access.accessId; + } + + switch (authData.actor.type) { + case ActorType.USER: + await validateUserClientForWorkspace({ + user: authData.authPayload as IUser, + workspaceId: integrationAuth.workspace._id, + acceptedRoles + }); + + return { integrationAuth, accessToken, accessId }; + case ActorType.SERVICE: + throw UnauthorizedRequestError({ + message: "Failed service token authorization for integration authorization" + }); + } +}; + +export const GetIntegrationAuthV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }) +}); + +export const OauthExchangeV1 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + code: z.string().trim(), + integration: z.string().trim(), + url: z.string().trim().url().optional(), + }) +}); + +export const SaveIntegrationAccessTokenV1 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + integration: z.string().trim(), + accessId: z.string().trim().optional(), + accessToken: z.string().trim().optional(), + url: z.string().url().trim().optional(), + namespace: z.string().trim().optional(), + refreshToken:z.string().trim().optional() + }) +}); + +export const GetIntegrationAuthAppsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + teamId: z.string().trim().optional(), + workspaceSlug: z.string().trim().optional() + }) +}); + +export const GetIntegrationAuthTeamsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }) +}); + +export const GetIntegrationAuthVercelBranchesV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + appId: z.string().trim() + }) +}); + +export const GetIntegrationAuthQoveryOrgsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }) +}); + +export const GetIntegrationAuthQoveryProjectsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + orgId: z.string().trim() + }) +}); + +export const GetIntegrationAuthQoveryEnvironmentsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + projectId: z.string().trim() + }) +}); + +export const GetIntegrationAuthQoveryScopesV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + environmentId: z.string().trim() + }) +}); + +export const GetIntegrationAuthRailwayEnvironmentsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + appId: z.string().trim() + }) +}); + +export const GetIntegrationAuthRailwayServicesV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + appId: z.string().trim() + }) +}); + +export const GetIntegrationAuthBitbucketWorkspacesV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }) +}); + +export const GetIntegrationAuthNorthflankSecretGroupsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + appId: z.string().trim() + }) +}); + +export const DeleteIntegrationAuthV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }) +}); + +export const GetIntegrationAuthTeamCityBuildConfigsV1 = z.object({ + params: z.object({ + integrationAuthId:z.string().trim() + }), + query: z.object({ + appId:z.string().trim() + }) +}) + +export { validateClientForIntegrationAuth }; diff --git a/backend/src/validation/key.ts b/backend/src/validation/key.ts new file mode 100644 index 000000000..a2d4ab92d --- /dev/null +++ b/backend/src/validation/key.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +export const UploadKeyV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + key: z.object({ + encryptedKey: z.string().trim(), + nonce: z.string().trim(), + userId: z.string().trim() + }) + }) +}); + +export const GetLatestKeyV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); diff --git a/backend/src/validation/membership.ts b/backend/src/validation/membership.ts index 6c40aa0aa..233805545 100644 --- a/backend/src/validation/membership.ts +++ b/backend/src/validation/membership.ts @@ -1,16 +1,11 @@ import { Types } from "mongoose"; -import { - IServiceTokenData, - IUser, - Membership, -} from "../models"; +import { IServiceTokenData, IUser, Membership } from "../models"; import { validateUserClientForWorkspace } from "./user"; import { validateServiceTokenDataClientForWorkspace } from "./serviceTokenData"; -import { - MembershipNotFoundError, -} from "../utils/errors"; +import { MembershipNotFoundError } from "../utils/errors"; import { AuthData } from "../interfaces/middleware"; import { ActorType } from "../ee/models"; +import { z } from "zod"; /** * Validate authenticated clients for membership with id [membershipId] based @@ -22,36 +17,64 @@ import { ActorType } from "../ee/models"; * @returns {Membership} - validated membership */ export const validateClientForMembership = async ({ - authData, - membershipId, - acceptedRoles, + authData, + membershipId, + acceptedRoles }: { - authData: AuthData; - membershipId: Types.ObjectId; - acceptedRoles: Array<"admin" | "member">; + authData: AuthData; + membershipId: Types.ObjectId; + acceptedRoles: Array<"admin" | "member">; }) => { - - const membership = await Membership.findById(membershipId); - - if (!membership) throw MembershipNotFoundError({ - message: "Failed to find membership", - }); - - switch (authData.actor.type) { - case ActorType.USER: - await validateUserClientForWorkspace({ - user: authData.authPayload as IUser, - workspaceId: membership.workspace, - acceptedRoles, - }); - - return membership; - case ActorType.SERVICE: - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(membership.workspace), - }); - - return membership; - } -} \ No newline at end of file + const membership = await Membership.findById(membershipId); + + if (!membership) + throw MembershipNotFoundError({ + message: "Failed to find membership" + }); + + switch (authData.actor.type) { + case ActorType.USER: + await validateUserClientForWorkspace({ + user: authData.authPayload as IUser, + workspaceId: membership.workspace, + acceptedRoles + }); + + return membership; + case ActorType.SERVICE: + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(membership.workspace) + }); + + return membership; + } +}; + +export const ValidateMembershipV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const DeleteMembershipV1 = z.object({ + params: z.object({ + membershipId: z.string().trim() + }) +}); + +export const ChangeMembershipRoleV1 = z.object({ + body: z.object({ + role: z.string().trim() + }), + params: z.object({ membershipId: z.string().trim() }) +}); + +export const DenyMembershipPermissionV1 = z.object({ + params: z.object({ + membershipId: z.string().trim() + }), + body: z.object({ + permissions: z.object({}).array() + }) +}); diff --git a/backend/src/validation/membershipOrg.ts b/backend/src/validation/membershipOrg.ts index b0ada6a61..02f9cac08 100644 --- a/backend/src/validation/membershipOrg.ts +++ b/backend/src/validation/membershipOrg.ts @@ -1,16 +1,10 @@ import { Types } from "mongoose"; -import { - MembershipOrg, -} from "../models"; -import { - validateMembershipOrg, -} from "../helpers/membershipOrg"; -import { - MembershipOrgNotFoundError, - UnauthorizedRequestError, -} from "../utils/errors"; +import { MembershipOrg } from "../models"; +import { validateMembershipOrg } from "../helpers/membershipOrg"; +import { MembershipOrgNotFoundError, UnauthorizedRequestError } from "../utils/errors"; import { AuthData } from "../interfaces/middleware"; import { ActorType } from "../ee/models"; +import { z } from "zod"; /** * Validate authenticated clients for organization membership with id [membershipOrgId] based @@ -22,35 +16,57 @@ import { ActorType } from "../ee/models"; * @param {MembershipOrg} - validated organization membership */ export const validateClientForMembershipOrg = async ({ - authData, - membershipOrgId, - acceptedRoles, - acceptedStatuses, + authData, + membershipOrgId, + acceptedRoles, + acceptedStatuses }: { - authData: AuthData; - membershipOrgId: Types.ObjectId; - acceptedRoles: Array<"owner" | "admin" | "member">; - acceptedStatuses: Array<"invited" | "accepted">; + authData: AuthData; + membershipOrgId: Types.ObjectId; + acceptedRoles: Array<"owner" | "admin" | "member">; + acceptedStatuses: Array<"invited" | "accepted">; }) => { - const membershipOrg = await MembershipOrg.findById(membershipOrgId); + const membershipOrg = await MembershipOrg.findById(membershipOrgId); - if (!membershipOrg) throw MembershipOrgNotFoundError({ - message: "Failed to find organization membership ", - }); - - switch (authData.actor.type) { - case ActorType.USER: - await validateMembershipOrg({ - userId: authData.authPayload._id, - organizationId: membershipOrg.organization, - acceptedRoles, - acceptedStatuses, - }); - - return membershipOrg; - case ActorType.SERVICE: - throw UnauthorizedRequestError({ - message: "Failed service account client authorization for organization membership", - }); - } -} \ No newline at end of file + if (!membershipOrg) + throw MembershipOrgNotFoundError({ + message: "Failed to find organization membership " + }); + + switch (authData.actor.type) { + case ActorType.USER: + await validateMembershipOrg({ + userId: authData.authPayload._id, + organizationId: membershipOrg.organization, + acceptedRoles, + acceptedStatuses + }); + + return membershipOrg; + case ActorType.SERVICE: + throw UnauthorizedRequestError({ + message: "Failed service account client authorization for organization membership" + }); + } +}; + +export const DelOrgMembershipv1 = z.object({ + params: z.object({ + membershipOrgId: z.string().trim() + }) +}); + +export const InviteUserToOrgv1 = z.object({ + body: z.object({ + inviteeEmail: z.string().trim().email(), + organizationId: z.string().trim() + }) +}); + +export const VerifyUserToOrgv1 = z.object({ + body: z.object({ + email: z.string().trim().email(), + organizationId: z.string().trim(), + code: z.string().trim() + }) +}); diff --git a/backend/src/validation/organization.ts b/backend/src/validation/organization.ts index 4ca9811c5..9aa13cbb6 100644 --- a/backend/src/validation/organization.ts +++ b/backend/src/validation/organization.ts @@ -1,12 +1,7 @@ import { Types } from "mongoose"; -import { - IUser, - Organization, -} from "../models"; -import { - OrganizationNotFoundError, - UnauthorizedRequestError, -} from "../utils/errors"; +import { z } from "zod"; +import { IUser, Organization } from "../models"; +import { OrganizationNotFoundError, UnauthorizedRequestError } from "../utils/errors"; import { validateUserClientForOrganization } from "./user"; import { AuthData } from "../interfaces/middleware"; import { ActorType } from "../ee/models"; @@ -21,7 +16,7 @@ export const validateClientForOrganization = async ({ authData, organizationId, acceptedRoles, - acceptedStatuses, + acceptedStatuses }: { authData: AuthData; organizationId: Types.ObjectId; @@ -32,10 +27,10 @@ export const validateClientForOrganization = async ({ if (!organization) { throw OrganizationNotFoundError({ - message: "Failed to find organization", + message: "Failed to find organization" }); } - + let membershipOrg; switch (authData.actor.type) { case ActorType.USER: @@ -43,13 +38,170 @@ export const validateClientForOrganization = async ({ user: authData.authPayload as IUser, organization, acceptedRoles, - acceptedStatuses, + acceptedStatuses }); - return { organization, membershipOrg }; + return { organization, membershipOrg }; case ActorType.SERVICE: throw UnauthorizedRequestError({ - message: "Failed service token authorization for organization", + message: "Failed service token authorization for organization" }); } -}; \ No newline at end of file +}; + +export const GetOrgPlansTablev1 = z.object({ + query: z.object({ billingCycle: z.enum(["monthly", "yearly"]) }), + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgPlanv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + query: z.object({ workspaceId: z.string().trim().optional() }) +}); + +export const StartOrgTrailv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ success_url: z.string().trim() }) +}); + +export const GetOrgPlanBillingInfov1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + query: z.object({ workspaceId: z.string().trim().optional() }) +}); + +export const GetOrgPlanTablev1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + query: z.object({ workspaceId: z.string().trim().optional() }) +}); + +export const GetOrgBillingDetailsv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const UpdateOrgBillingDetailsv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ + email: z.string().trim().email().optional(), + name: z.string().trim().optional() + }) +}); + +export const GetOrgPmtMethodsv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const CreateOrgPmtMethodv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ + success_url: z.string().trim(), + cancel_url: z.string().trim() + }) +}); + +export const DelOrgPmtMethodv1 = z.object({ + params: z.object({ + organizationId: z.string().trim(), + pmtMethodId: z.string().trim() + }) +}); + +export const GetOrgTaxIdsv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const CreateOrgTaxId = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ + type: z.string().trim(), + value: z.string().trim() + }) +}); + +export const DelOrgTaxIdv1 = z.object({ + params: z.object({ + organizationId: z.string().trim(), + taxId: z.string().trim() + }) +}); + +export const GetOrgInvoicesv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgLicencesv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const CreateOrgv1 = z.object({ + body: z.object({ + organizationName: z.string().trim() + }) +}); + +export const GetOrgv1 = z.object({ + params: z.object({ + organizationId: z.string().trim() + }) +}); + +export const GetOrgMembersv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgWorkspacesv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const ChangeOrgNamev1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ name: z.string().trim() }) +}); + +export const GetOrgIncidentContactv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const CreateOrgIncideContact = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ email: z.string().email().trim() }) +}); + +export const DelOrgIncideContact = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ email: z.string().email().trim() }) +}); + +export const CreateOrgPortalSessionv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgMembersAndWsv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgMembersv2 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const UpdateOrgMemberv2 = z.object({ + params: z.object({ organizationId: z.string().trim(), membershipId: z.string().trim() }), + body: z.object({ + role: z.string().trim() + }) +}); + +export const DeleteOrgMemberv2 = z.object({ + params: z.object({ organizationId: z.string().trim(), membershipId: z.string().trim() }) +}); + +export const GetOrgWorkspacesv2 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const VerfiyUserToOrganizationV1 = z.object({ + body: z.object({ + email: z.string().trim().email(), + organizationId: z.string().trim(), + code: z.string().trim() + }) +}); diff --git a/backend/src/validation/secretImports.ts b/backend/src/validation/secretImports.ts new file mode 100644 index 000000000..92867a67b --- /dev/null +++ b/backend/src/validation/secretImports.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +export const CreateSecretImportV1 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + folderId: z.string().trim().default("root"), + secretImport: z.object({ + environment: z.string().trim(), + secretPath: z.string().trim() + }) + }) +}); + +export const UpdateSecretImportV1 = z.object({ + params: z.object({ + id: z.string().trim() + }), + body: z.object({ + secretImports: z + .object({ + environment: z.string().trim(), + secretPath: z.string().trim() + }) + .array() + }) +}); + +export const DeleteSecretImportV1 = z.object({ + params: z.object({ + id: z.string().trim() + }), + body: z.object({ + secretImportPath: z.string().trim(), + secretImportEnv: z.string().trim() + }) +}); + +export const GetSecretImportsV1 = z.object({ + query: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + folderId: z.string().trim().default("root") + }) +}); + +export const GetAllSecretsFromImportV1 = z.object({ + query: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + folderId: z.string().trim().default("root") + }) +}); diff --git a/backend/src/validation/secretScanning.ts b/backend/src/validation/secretScanning.ts new file mode 100644 index 000000000..e16c67c5c --- /dev/null +++ b/backend/src/validation/secretScanning.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const CreateInstalLSessionv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const LinkInstallationToOrgv1 = z.object({ + body: z.object({ + installationId: z.number(), + sessionId: z.string().trim() + }) +}); + +export const GetOrgInstallStatusv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgRisksv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const UpdateRiskStatusv1 = z.object({ + params: z.object({ organizationId: z.string().trim(), riskId: z.string().trim() }), + body: z.object({ status: z.string().trim() }) +}); diff --git a/backend/src/validation/secretSnapshot.ts b/backend/src/validation/secretSnapshot.ts new file mode 100644 index 000000000..b431547e8 --- /dev/null +++ b/backend/src/validation/secretSnapshot.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const GetSecretSnapshotV1 = z.object({ + params: z.object({ + secretSnapshotId: z.string().trim() + }) +}); diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index 6c0ac3084..18579aa27 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -1,18 +1,15 @@ import { Types } from "mongoose"; -import { - ISecret, - IServiceTokenData, - IUser, - Secret, -} from "../models"; +import { ISecret, IServiceTokenData, IUser, Secret } from "../models"; import { validateUserClientForSecret, validateUserClientForSecrets } from "./user"; -import { validateServiceTokenDataClientForSecrets, validateServiceTokenDataClientForWorkspace } from "./serviceTokenData"; import { - BadRequestError, - SecretNotFoundError, -} from "../utils/errors"; + validateServiceTokenDataClientForSecrets, + validateServiceTokenDataClientForWorkspace +} from "./serviceTokenData"; +import { BadRequestError, SecretNotFoundError } from "../utils/errors"; import { AuthData } from "../interfaces/middleware"; import { ActorType } from "../ee/models"; +import { z } from "zod"; +import { SECRET_PERSONAL, SECRET_SHARED } from "../variables"; /** * Validate authenticated clients for secrets with id [secretId] based @@ -24,42 +21,43 @@ import { ActorType } from "../ee/models"; * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint */ export const validateClientForSecret = async ({ - authData, - secretId, - acceptedRoles, - requiredPermissions, + authData, + secretId, + acceptedRoles, + requiredPermissions }: { - authData: AuthData; - secretId: Types.ObjectId; - acceptedRoles: Array<"admin" | "member">; - requiredPermissions: string[]; + authData: AuthData; + secretId: Types.ObjectId; + acceptedRoles: Array<"admin" | "member">; + requiredPermissions: string[]; }) => { - const secret = await Secret.findById(secretId); + const secret = await Secret.findById(secretId); - if (!secret) throw SecretNotFoundError({ - message: "Failed to find secret", + if (!secret) + throw SecretNotFoundError({ + message: "Failed to find secret" }); - - switch (authData.actor.type) { - case ActorType.USER: - await validateUserClientForSecret({ - user: authData.authPayload as IUser, - secret, - acceptedRoles, - requiredPermissions, - }); - return secret; - case ActorType.SERVICE: - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: authData.authPayload as IServiceTokenData, - workspaceId: secret.workspace, - environment: secret.environment, - }); - - return secret; - } -} + switch (authData.actor.type) { + case ActorType.USER: + await validateUserClientForSecret({ + user: authData.authPayload as IUser, + secret, + acceptedRoles, + requiredPermissions + }); + + return secret; + case ActorType.SERVICE: + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: authData.authPayload as IServiceTokenData, + workspaceId: secret.workspace, + environment: secret.environment + }); + + return secret; + } +}; /** * Validate authenticated clients for secrets with ids [secretIds] based @@ -72,43 +70,301 @@ export const validateClientForSecret = async ({ * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint */ export const validateClientForSecrets = async ({ - authData, - secretIds, - requiredPermissions, + authData, + secretIds, + requiredPermissions }: { - authData: AuthData; - secretIds: Types.ObjectId[]; - requiredPermissions: string[]; + authData: AuthData; + secretIds: Types.ObjectId[]; + requiredPermissions: string[]; }) => { + let secrets: ISecret[] = []; - let secrets: ISecret[] = []; - - secrets = await Secret.find({ - _id: { - $in: secretIds, - }, - }); + secrets = await Secret.find({ + _id: { + $in: secretIds + } + }); - if (secrets.length != secretIds.length) { - throw BadRequestError({ message: "Failed to validate non-existent secrets" }) - } - - switch (authData.actor.type) { - case ActorType.USER: - await validateUserClientForSecrets({ - user: authData.authPayload as IUser, - secrets, - requiredPermissions, - }); - - return secrets; - case ActorType.SERVICE: - await validateServiceTokenDataClientForSecrets({ - serviceTokenData: authData.authPayload as IServiceTokenData, - secrets, - requiredPermissions, - }); - - return secrets; - } -} \ No newline at end of file + if (secrets.length != secretIds.length) { + throw BadRequestError({ message: "Failed to validate non-existent secrets" }); + } + + switch (authData.actor.type) { + case ActorType.USER: + await validateUserClientForSecrets({ + user: authData.authPayload as IUser, + secrets, + requiredPermissions + }); + + return secrets; + case ActorType.SERVICE: + await validateServiceTokenDataClientForSecrets({ + serviceTokenData: authData.authPayload as IServiceTokenData, + secrets, + requiredPermissions + }); + + return secrets; + } +}; + +export const GetSecretVersionsV1 = z.object({ + params: z.object({ + secretId: z.string().trim() + }), + query: z.object({ + offset: z.coerce.number(), + limit: z.coerce.number() + }) +}); + +export const RollbackSecretVersionV1 = z.object({ + params: z.object({ + secretId: z.string().trim() + }), + body: z.object({ + version: z.number() + }) +}); + +export const PushSecretsV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + secrets: z.object({}).array(), + keys: z.object({}).array(), + environment: z.string().trim(), + channel: z.string().trim() + }) +}); + +export const PullSecretsV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + query: z.object({ + channel: z.string().optional(), + environment: z.string().trim() + }) +}); + +export const PullSecretsServiceTokenV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + query: z.object({ + channel: z.string().optional(), + environment: z.string().trim() + }) +}); + +const batchUpdateRequestV2 = z.object({ + _id: z.string(), + folderId: z.string().trim().optional(), + type: z.enum(["shared", "personal"]), + secretName: z.string().trim(), + secretKeyCiphertext: z.string().trim(), + secretKeyIV: z.string().trim(), + secretKeyTag: z.string().trim(), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + tags: z + .object({ + _id: z.string().trim(), + name: z.string().trim(), + slug: z.string().trim() + }) + .array() +}); + +export const BatchSecretsV2 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + folderId: z.string().trim().default("root"), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + requests: z + .discriminatedUnion("method", [ + z.object({ + method: z.literal("POST"), + secret: batchUpdateRequestV2.omit({ _id: true }) + }), + z.object({ + method: z.literal("PATCH"), + secret: batchUpdateRequestV2 + }), + z.object({ + method: z.literal("DELETE"), + secret: z.object({ _id: z.string().trim(), secretName: z.string().trim() }) + }) + ]) + .array() + }) +}); + +export const GetSecretsV2 = z.object({ + query: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + tagSlugs: z.string().trim().optional(), + folderId: z.string().trim().default("root"), + secretPath: z.string().trim().optional(), + include_imports: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + }) +}); + +export const GetSecretsRawV3 = z.object({ + query: z.object({ + workspaceId: z.string().trim().optional(), + environment: z.string().trim().optional(), + secretPath: z.string().trim().default("/"), + folderId: z.string().trim().optional(), + include_imports: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + }) +}); + +export const GetSecretByNameRawV3 = z.object({ + params: z.object({ + secretName: z.string().trim() + }), + query: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).optional(), + include_imports: z + .enum(["true", "false"]) + .default("true") + .transform((value) => value === "true") + }) +}); + +export const CreateSecretRawV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secretValue: z.string().trim(), + secretComment: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]) + }), + params: z.object({ + secretName: z.string().trim() + }) +}); + +export const UpdateSecretByNameRawV3 = z.object({ + params: z.object({ + secretName: z.string().trim() + }), + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretValue: z.string().trim(), + secretPath: z.string().trim().default("/"), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).default(SECRET_SHARED) + }) +}); + +export const DeleteSecretByNameRawV3 = z.object({ + params: z.object({ + secretName: z.string().trim() + }), + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).default(SECRET_SHARED) + }) +}); + +export const GetSecretsV3 = z.object({ + query: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + folderId: z.string().trim().optional(), + include_imports: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + }) +}); + +export const GetSecretByNameV3 = z.object({ + query: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).optional(), + include_imports: z + .enum(["true", "false"]) + .default("true") + .transform((value) => value === "true") + }), + params: z.object({ + secretName: z.string().trim() + }) +}); + +export const CreateSecretV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), + secretPath: z.string().trim().default("/"), + secretKeyCiphertext: z.string().trim(), + secretKeyIV: z.string().trim(), + secretKeyTag: z.string().trim(), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + metadata: z.record(z.string()).optional() + }), + params: z.object({ + secretName: z.string().trim() + }) +}); + +export const UpdateSecretByNameV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), + secretPath: z.string().trim().default("/"), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim() + }), + params: z.object({ + secretName: z.string() + }) +}); + +export const DeleteSecretByNameV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), + secretPath: z.string().trim().default("/") + }), + params: z.object({ + secretName: z.string() + }) +}); diff --git a/backend/src/validation/serviceTokenData.ts b/backend/src/validation/serviceTokenData.ts index fe8e3bd0b..1379b8f97 100644 --- a/backend/src/validation/serviceTokenData.ts +++ b/backend/src/validation/serviceTokenData.ts @@ -1,14 +1,11 @@ import { Types } from "mongoose"; -import { - ISecret, - IServiceTokenData, - IUser, - ServiceTokenData, -} from "../models"; +import { ISecret, IServiceTokenData, IUser, ServiceTokenData } from "../models"; import { ServiceTokenDataNotFoundError, UnauthorizedRequestError } from "../utils/errors"; import { validateUserClientForWorkspace } from "./user"; import { ActorType } from "../ee/models"; import { AuthData } from "../interfaces/middleware"; +import { z } from "zod"; +import { isValidScope } from "../helpers"; /** * Validate authenticated clients for service token with id [serviceTokenId] based @@ -31,10 +28,11 @@ export const validateClientForServiceTokenData = async ({ .select("+encryptedKey +iv +tag") .populate<{ user: IUser }>("user"); - if (!serviceTokenData) throw ServiceTokenDataNotFoundError({ - message: "Failed to find service token data" - }); - + if (!serviceTokenData) + throw ServiceTokenDataNotFoundError({ + message: "Failed to find service token data" + }); + switch (authData.actor.type) { case ActorType.USER: await validateUserClientForWorkspace({ @@ -65,11 +63,13 @@ export const validateServiceTokenDataClientForWorkspace = async ({ serviceTokenData, workspaceId, environment, + secretPath = "/", requiredPermissions }: { serviceTokenData: IServiceTokenData; workspaceId: Types.ObjectId; environment?: string; + secretPath?: string; requiredPermissions?: string[]; }) => { if (!serviceTokenData.workspace.equals(workspaceId)) { @@ -81,7 +81,6 @@ export const validateServiceTokenDataClientForWorkspace = async ({ if (environment) { // case: environment is specified - if (!serviceTokenData.scopes.find(({ environment: tkEnv }) => tkEnv === environment)) { // case: invalid environment passed throw UnauthorizedRequestError({ @@ -89,6 +88,10 @@ export const validateServiceTokenDataClientForWorkspace = async ({ }); } + if (!isValidScope(serviceTokenData, environment, secretPath)) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + requiredPermissions?.forEach((permission) => { if (!serviceTokenData.permissions.includes(permission)) { throw UnauthorizedRequestError({ @@ -140,3 +143,28 @@ export const validateServiceTokenDataClientForSecrets = async ({ }); }); }; + +export const CreateServiceTokenV2 = z.object({ + body: z.object({ + name: z.string().trim(), + workspaceId: z.string().trim(), + scopes: z + .object({ + environment: z.string().trim(), + secretPath: z.string().trim() + }) + .array() + .min(1), + encryptedKey: z.string().trim(), + iv: z.string().trim(), + tag: z.string().trim(), + expiresIn: z.number(), + permissions: z.enum(["read", "write"]).array() + }) +}); + +export const DeleteServiceTokenV2 = z.object({ + params: z.object({ + serviceTokenDataId: z.string().trim() + }) +}); diff --git a/backend/src/validation/sso.ts b/backend/src/validation/sso.ts new file mode 100644 index 000000000..275ae611e --- /dev/null +++ b/backend/src/validation/sso.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { AuthProvider } from "../ee/models"; + +export const GetSsoConfigv1 = z.object({ + query: z.object({ organizationId: z.string().trim() }) +}); + +export const CreateSsoConfigv1 = z.object({ + body: z.object({ + organizationId: z.string().trim(), + authProvider: z.nativeEnum(AuthProvider), + isActive: z.boolean(), + entryPoint: z.string().trim(), + issuer: z.string().trim(), + cert: z.string().trim() + }) +}); + +export const UpdateSsoConfigv1 = z.object({ + body: z.object({ + organizationId: z.string().trim(), + authProvider: z.nativeEnum(AuthProvider).optional(), + isActive: z.boolean().optional(), + entryPoint: z.string().trim().optional(), + issuer: z.string().trim().optional(), + cert: z.string().trim().optional() + }) +}); diff --git a/backend/src/validation/tags.ts b/backend/src/validation/tags.ts new file mode 100644 index 000000000..0631e9a9a --- /dev/null +++ b/backend/src/validation/tags.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +export const GetWorkspaceTagsV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const DeleteWorkspaceTagsV2 = z.object({ + params: z.object({ + tagId: z.string().trim() + }) +}); + +export const CreateWorkspaceTagsV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + name: z.string().trim(), + slug: z.string().trim() + }) +}); diff --git a/backend/src/validation/user.ts b/backend/src/validation/user.ts index f1edad3bf..9f1176f96 100644 --- a/backend/src/validation/user.ts +++ b/backend/src/validation/user.ts @@ -1,39 +1,30 @@ import fs from "fs"; import path from "path"; import { Types } from "mongoose"; -import { - IOrganization, - ISecret, - IServiceAccount, - IUser, - Membership, -} from "../models"; +import { IOrganization, ISecret, IServiceAccount, IUser, Membership } from "../models"; import { validateMembership } from "../helpers/membership"; import _ from "lodash"; import { BadRequestError, UnauthorizedRequestError, ValidationError } from "../utils/errors"; -import { - validateMembershipOrg, -} from "../helpers/membershipOrg"; -import { - PERMISSION_READ_SECRETS, - PERMISSION_WRITE_SECRETS, -} from "../variables"; +import { validateMembershipOrg } from "../helpers/membershipOrg"; +import { PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS } from "../variables"; +import { AuthMethod } from "../models"; +import { z } from "zod"; /** * Validate that email [email] is not disposable * @param email - email to validate */ export const validateUserEmail = (email: string) => { - const emailDomain = email.split("@")[1]; - const disposableEmails = fs.readFileSync( - path.resolve(__dirname, "../data/" + "disposable_emails.txt"), - "utf8" - ).split("\n"); - - if (disposableEmails.includes(emailDomain)) throw ValidationError({ - message: "Failed to validate email as non-disposable", - }); -} + const emailDomain = email.split("@")[1]; + const disposableEmails = fs + .readFileSync(path.resolve(__dirname, "../data/" + "disposable_emails.txt"), "utf8") + .split("\n"); + + if (disposableEmails.includes(emailDomain)) + throw ValidationError({ + message: "Failed to validate email as non-disposable" + }); +}; /** * Validate that user (client) can access workspace @@ -46,48 +37,53 @@ export const validateUserEmail = (email: string) => { * @param {String[]} requiredPermissions - required permissions as part of the endpoint */ export const validateUserClientForWorkspace = async ({ - user, - workspaceId, - environment, - acceptedRoles, - requiredPermissions, + user, + workspaceId, + environment, + acceptedRoles, + requiredPermissions }: { - user: IUser; - workspaceId: Types.ObjectId; - environment?: string; - acceptedRoles: Array<"admin" | "member">; - requiredPermissions?: string[]; + user: IUser; + workspaceId: Types.ObjectId; + environment?: string; + acceptedRoles: Array<"admin" | "member">; + requiredPermissions?: string[]; }) => { - - // validate user membership in workspace - const membership = await validateMembership({ - userId: user._id, - workspaceId, - acceptedRoles, - }); - - let runningIsDisallowed = false; - requiredPermissions?.forEach((requiredPermission: string) => { - switch (requiredPermission) { - case PERMISSION_READ_SECRETS: - runningIsDisallowed = _.some(membership.deniedPermissions, { environmentSlug: environment, ability: PERMISSION_READ_SECRETS }); - break; - case PERMISSION_WRITE_SECRETS: - runningIsDisallowed = _.some(membership.deniedPermissions, { environmentSlug: environment, ability: PERMISSION_WRITE_SECRETS }); - break; - default: - break; - } - - if (runningIsDisallowed) { - throw UnauthorizedRequestError({ - message: `Failed permissions authorization for workspace environment action : ${requiredPermission}`, - }); - } - }); - - return membership; -} + // validate user membership in workspace + const membership = await validateMembership({ + userId: user._id, + workspaceId, + acceptedRoles + }); + + let runningIsDisallowed = false; + requiredPermissions?.forEach((requiredPermission: string) => { + switch (requiredPermission) { + case PERMISSION_READ_SECRETS: + runningIsDisallowed = _.some(membership.deniedPermissions, { + environmentSlug: environment, + ability: PERMISSION_READ_SECRETS + }); + break; + case PERMISSION_WRITE_SECRETS: + runningIsDisallowed = _.some(membership.deniedPermissions, { + environmentSlug: environment, + ability: PERMISSION_WRITE_SECRETS + }); + break; + default: + break; + } + + if (runningIsDisallowed) { + throw UnauthorizedRequestError({ + message: `Failed permissions authorization for workspace environment action : ${requiredPermission}` + }); + } + }); + + return membership; +}; /** * Validate that user (client) can access secret [secret] @@ -98,32 +94,35 @@ export const validateUserClientForWorkspace = async ({ * @param {String[]} requiredPermissions - required permissions as part of the endpoint */ export const validateUserClientForSecret = async ({ - user, - secret, - acceptedRoles, - requiredPermissions, + user, + secret, + acceptedRoles, + requiredPermissions }: { - user: IUser; - secret: ISecret; - acceptedRoles?: Array<"admin" | "member">; - requiredPermissions?: string[]; + user: IUser; + secret: ISecret; + acceptedRoles?: Array<"admin" | "member">; + requiredPermissions?: string[]; }) => { - const membership = await validateMembership({ - userId: user._id, - workspaceId: secret.workspace, - acceptedRoles, - }); - - if (requiredPermissions?.includes(PERMISSION_WRITE_SECRETS)) { - const isDisallowed = _.some(membership.deniedPermissions, { environmentSlug: secret.environment, ability: PERMISSION_WRITE_SECRETS }); + const membership = await validateMembership({ + userId: user._id, + workspaceId: secret.workspace, + acceptedRoles + }); - if (isDisallowed) { - throw UnauthorizedRequestError({ - message: "You do not have the required permissions to perform this action", - }); - } - } -} + if (requiredPermissions?.includes(PERMISSION_WRITE_SECRETS)) { + const isDisallowed = _.some(membership.deniedPermissions, { + environmentSlug: secret.environment, + ability: PERMISSION_WRITE_SECRETS + }); + + if (isDisallowed) { + throw UnauthorizedRequestError({ + message: "You do not have the required permissions to perform this action" + }); + } + } +}; /** * Validate that user (client) can access secrets [secrets] @@ -134,41 +133,44 @@ export const validateUserClientForSecret = async ({ * @param {String[]} requiredPermissions - required permissions as part of the endpoint */ export const validateUserClientForSecrets = async ({ - user, - secrets, - requiredPermissions, + user, + secrets, + requiredPermissions }: { - user: IUser; - secrets: ISecret[]; - requiredPermissions?: string[]; + user: IUser; + secrets: ISecret[]; + requiredPermissions?: string[]; }) => { - - // TODO: add acceptedRoles? + // TODO: add acceptedRoles? - const userMemberships = await Membership.find({ user: user._id }) - const userMembershipById = _.keyBy(userMemberships, "workspace"); - const workspaceIdsSet = new Set(userMemberships.map((m) => m.workspace.toString())); + const userMemberships = await Membership.find({ user: user._id }); + const userMembershipById = _.keyBy(userMemberships, "workspace"); + const workspaceIdsSet = new Set(userMemberships.map((m) => m.workspace.toString())); - // for each secret check if the secret belongs to a workspace the user is a member of - secrets.forEach((secret: ISecret) => { - if (!workspaceIdsSet.has(secret.workspace.toString())) { - throw BadRequestError({ - message: "Failed authorization for the secret", - }); - } + // for each secret check if the secret belongs to a workspace the user is a member of + secrets.forEach((secret: ISecret) => { + if (!workspaceIdsSet.has(secret.workspace.toString())) { + throw BadRequestError({ + message: "Failed authorization for the secret" + }); + } - if (requiredPermissions?.includes(PERMISSION_WRITE_SECRETS)) { - const deniedMembershipPermissions = userMembershipById[secret.workspace.toString()].deniedPermissions; - const isDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: secret.environment, ability: PERMISSION_WRITE_SECRETS }); + if (requiredPermissions?.includes(PERMISSION_WRITE_SECRETS)) { + const deniedMembershipPermissions = + userMembershipById[secret.workspace.toString()].deniedPermissions; + const isDisallowed = _.some(deniedMembershipPermissions, { + environmentSlug: secret.environment, + ability: PERMISSION_WRITE_SECRETS + }); - if (isDisallowed) { - throw UnauthorizedRequestError({ - message: "You do not have the required permissions to perform this action", - }); - } - } - }); -} + if (isDisallowed) { + throw UnauthorizedRequestError({ + message: "You do not have the required permissions to perform this action" + }); + } + } + }); +}; /** * Validate that user (client) can access service account [serviceAccount] @@ -179,25 +181,25 @@ export const validateUserClientForSecrets = async ({ * @param {String[]} requiredPermissions - required permissions as part of the endpoint */ export const validateUserClientForServiceAccount = async ({ - user, - serviceAccount, - requiredPermissions, + user, + serviceAccount, + requiredPermissions }: { - user: IUser; - serviceAccount: IServiceAccount; - requiredPermissions?: string[]; + user: IUser; + serviceAccount: IServiceAccount; + requiredPermissions?: string[]; }) => { - if (!serviceAccount.user.equals(user._id)) { - // case: user who created service account is not the - // same user that is on the request - await validateMembershipOrg({ - userId: user._id, - organizationId: serviceAccount.organization, - acceptedRoles: [], - acceptedStatuses: [], - }); - } -} + if (!serviceAccount.user.equals(user._id)) { + // case: user who created service account is not the + // same user that is on the request + await validateMembershipOrg({ + userId: user._id, + organizationId: serviceAccount.organization, + acceptedRoles: [], + acceptedStatuses: [] + }); + } +}; /** * Validate that user (client) can access organization [organization] @@ -206,22 +208,54 @@ export const validateUserClientForServiceAccount = async ({ * @param {Organization} obj.organization - organization to validate against */ export const validateUserClientForOrganization = async ({ - user, - organization, - acceptedRoles, - acceptedStatuses, + user, + organization, + acceptedRoles, + acceptedStatuses }: { - user: IUser; - organization: IOrganization; - acceptedRoles: Array<"owner" | "admin" | "member">; - acceptedStatuses: Array<"invited" | "accepted">; + user: IUser; + organization: IOrganization; + acceptedRoles: Array<"owner" | "admin" | "member">; + acceptedStatuses: Array<"invited" | "accepted">; }) => { - const membershipOrg = await validateMembershipOrg({ - userId: user._id, - organizationId: organization._id, - acceptedRoles, - acceptedStatuses, - }); - - return membershipOrg; -} \ No newline at end of file + const membershipOrg = await validateMembershipOrg({ + userId: user._id, + organizationId: organization._id, + acceptedRoles, + acceptedStatuses + }); + + return membershipOrg; +}; + +export const UpdateMyMfaEnabledV2 = z.object({ + body: z.object({ + isMfaEnabled: z.boolean() + }) +}); + +export const UpdateNameV2 = z.object({ + body: z.object({ + firstName: z.string().trim(), + lastName: z.string().trim() + }) +}); + +export const UpdateAuthMethodsV2 = z.object({ + body: z.object({ + authMethods: z.nativeEnum(AuthMethod).array().min(1) + }) +}); + +export const CreateApiKeyV2 = z.object({ + body: z.object({ + name: z.string().trim(), + expiresIn: z.number() + }) +}); + +export const DeleteApiKeyV2 = z.object({ + params: z.object({ + apiKeyDataId: z.string().trim() + }) +}); diff --git a/backend/src/validation/webhooks.ts b/backend/src/validation/webhooks.ts new file mode 100644 index 000000000..907f90633 --- /dev/null +++ b/backend/src/validation/webhooks.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +export const CreateWebhookV1 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + webhookUrl: z.string().url().trim(), + webhookSecretKey: z.string().trim().optional(), + secretPath: z.string().trim().default("/") + }) +}); + +export const UpdateWebhookV1 = z.object({ + params: z.object({ + webhookId: z.string().trim() + }), + body: z.object({ + isDisabled: z.boolean().default(false) + }) +}); + +export const TestWebhookV1 = z.object({ + params: z.object({ + webhookId: z.string().trim() + }) +}); + +export const DeleteWebhookV1 = z.object({ + params: z.object({ + webhookId: z.string().trim() + }) +}); + +export const ListWebhooksV1 = z.object({ + query: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim().optional(), + secretPath: z.string().trim().optional() + }) +}); diff --git a/backend/src/validation/workspace.ts b/backend/src/validation/workspace.ts index 3be8d0dad..e0f7c156b 100644 --- a/backend/src/validation/workspace.ts +++ b/backend/src/validation/workspace.ts @@ -1,25 +1,12 @@ -import net from "net"; import { Types } from "mongoose"; -import { - IServiceTokenData, - IUser, - SecretBlindIndexData, - Workspace, -} from "../models"; -import { - ActorType, - TrustedIP -} from "../ee/models"; +import { IServiceTokenData, IUser, Workspace } from "../models"; +import { ActorType } from "../ee/models"; import { validateUserClientForWorkspace } from "./user"; import { validateServiceTokenDataClientForWorkspace } from "./serviceTokenData"; -import { - BadRequestError, - UnauthorizedRequestError, - WorkspaceNotFoundError, -} from "../utils/errors"; -import { BotService } from "../services"; +import { WorkspaceNotFoundError } from "../utils/errors"; import { AuthData } from "../interfaces/middleware"; -import { extractIPDetails } from "../utils/ip"; +import { z } from "zod"; +import { EventType, UserAgentType } from "../ee/models"; /** * Validate authenticated clients for workspace with id [workspaceId] based @@ -32,106 +19,283 @@ import { extractIPDetails } from "../utils/ip"; * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint */ export const validateClientForWorkspace = async ({ - authData, - workspaceId, - environment, - acceptedRoles, - requiredPermissions, - requireBlindIndicesEnabled, - requireE2EEOff, - checkIPAllowlist + authData, + workspaceId, + environment, + acceptedRoles, + requiredPermissions }: { - authData: AuthData; - workspaceId: Types.ObjectId; - environment?: string; - acceptedRoles: Array<"admin" | "member">; - requiredPermissions?: string[]; - requireBlindIndicesEnabled: boolean; - requireE2EEOff: boolean; - checkIPAllowlist: boolean; + authData: AuthData; + workspaceId: Types.ObjectId; + environment?: string; + acceptedRoles: Array<"admin" | "member">; + requiredPermissions?: string[]; }) => { - const workspace = await Workspace.findById(workspaceId); + const workspace = await Workspace.findById(workspaceId); - if (!workspace) throw WorkspaceNotFoundError({ - message: "Failed to find workspace", - }); + if (!workspace) throw WorkspaceNotFoundError({ + message: "Failed to find workspace" + }); - if (requireBlindIndicesEnabled) { - // case: blind indices are not enabled for secrets in this workspace - // (i.e. workspace was created before blind indices were introduced - // and no admin has enabled it) - - const secretBlindIndexData = await SecretBlindIndexData.exists({ - workspace: new Types.ObjectId(workspaceId), - }); - - if (!secretBlindIndexData) throw UnauthorizedRequestError({ - message: "Failed workspace authorization due to blind indices not being enabled", - }); - } - - if (requireE2EEOff) { - const isWorkspaceE2EE = await BotService.getIsWorkspaceE2EE(workspaceId); - - if (isWorkspaceE2EE) throw BadRequestError({ - message: "Failed workspace authorization due to end-to-end encryption not being disabled", - }); - } - - let membership; - switch (authData.actor.type) { - case ActorType.USER: - membership = await validateUserClientForWorkspace({ - user: authData.authPayload as IUser, - workspaceId, - environment, - acceptedRoles, - requiredPermissions, - }); - - return ({ membership, workspace }); - case ActorType.SERVICE: - if (checkIPAllowlist) { - const trustedIps = await TrustedIP.find({ - workspace: workspaceId - }); - - if (trustedIps.length > 0) { - // case: check the IP address of the inbound request against trusted IPs + let membership; + switch (authData.actor.type) { + case ActorType.USER: + membership = await validateUserClientForWorkspace({ + user: authData.authPayload as IUser, + workspaceId, + environment, + acceptedRoles, + requiredPermissions + }); - const blockList = new net.BlockList(); - - for (const trustedIp of trustedIps) { - if (trustedIp.prefix !== undefined) { - blockList.addSubnet( - trustedIp.ipAddress, - trustedIp.prefix, - trustedIp.type - ); - } else { - blockList.addAddress( - trustedIp.ipAddress, - trustedIp.type - ); - } - } - - const { type } = extractIPDetails(authData.ipAddress); - const check = blockList.check(authData.ipAddress, type); - - if (!check) throw UnauthorizedRequestError({ - message: "Failed workspace authorization" - }); - } - } + return { membership, workspace }; + case ActorType.SERVICE: + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: authData.authPayload as IServiceTokenData, + workspaceId, + environment, + requiredPermissions + }); - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: authData.authPayload as IServiceTokenData, - workspaceId, - environment, - requiredPermissions, - }); - - return {}; - } -} + return {}; + } +}; + +export const GetWorkspaceSecretSnapshotsV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + query: z.object({ + environment: z.string().trim(), + folderId: z.string().trim().default("root"), + offset: z.coerce.number(), + limit: z.coerce.number() + }) +}); + +export const GetWorkspaceSecretSnapshotsCountV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + query: z.object({ + environment: z.string().trim(), + folderId: z.string().trim().default("root") + }) +}); + +export const RollbackWorkspaceSecretSnapshotV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + environment: z.string().trim(), + folderId: z.string().trim().default("root"), + version: z.number() + }) +}); + +export const GetWorkspaceLogsV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + query: z.object({ + offset: z.coerce.number(), + limit: z.coerce.number(), + sortBy: z.string().trim().optional(), + userId: z.string().trim().optional(), + actionNames: z.string().trim().optional() + }) +}); + +export const GetWorkspaceAuditLogsV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + query: z.object({ + eventType: z.nativeEnum(EventType).nullable().optional(), + userAgentType: z.nativeEnum(UserAgentType).nullable().optional(), + startDate: z.string().datetime().nullable().optional(), + endDate: z.string().datetime().nullable().optional(), + offset: z.coerce.number(), + limit: z.coerce.number(), + actor: z.string().nullish().optional() + }) +}); + +export const GetWorkspaceAuditLogActorFilterOptsV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const GetWorkspaceTrustedIpsV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const AddWorkspaceTrustedIpV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + ipAddress: z.string().trim(), + comment: z.string().trim().default(""), + isActive: z.boolean() + }) +}); + +export const UpdateWorkspaceTrustedIpV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim(), + trustedIpId: z.string().trim() + }), + body: z.object({ + ipAddress: z.string().trim(), + comment: z.string().trim().default("") + }) +}); + +export const DeleteWorkspaceTrustedIpV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim(), + trustedIpId: z.string().trim() + }) +}); + +export const GetWorkspacePublicKeysV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const GetWorkspaceMembershipsV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const GetWorkspaceV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const CreateWorkspaceV1 = z.object({ + body: z.object({ + workspaceName: z.string().trim(), + organizationId: z.string().trim() + }) +}); + +export const DeleteWorkspaceV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const ChangeWorkspaceNameV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + name: z.string().trim() + }) +}); + +export const InviteUserToWorkspaceV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + email: z.string().trim() + }) +}); + +export const GetWorkspaceIntegrationsV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const GetWorkspaceIntegrationAuthorizationsV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const GetWorkspaceServiceTokensV1 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const GetWorkspaceServiceTokenDataV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const GetWorkspaceKeyV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const GetWorkspaceMembershipsV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const UpdateWorkspaceMembershipsV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim(), + membershipId: z.string().trim() + }), + body: z.object({ + role: z.string().trim() + }) +}); + +export const DeleteWorkspaceMembershipsV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim(), + membershipId: z.string().trim() + }) +}); + +export const ToggleAutoCapitalizationV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + autoCapitalization: z.boolean() + }) +}); + +export const GetWorkspaceBlinkIndexStatusV3 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const GetWorkspaceSecretsV3 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const NameWorkspaceSecretsV3 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + secretsToUpdate: z + .object({ + secretName: z.string().trim(), + _id: z.string().trim() + }) + .array() + }) +}); diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index ed69cf5fc..3adfad4a8 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -28,6 +28,7 @@ export const INTEGRATION_TRAVISCI = "travisci"; export const INTEGRATION_TEAMCITY = "teamcity"; export const INTEGRATION_SUPABASE = "supabase"; export const INTEGRATION_CHECKLY = "checkly"; +export const INTEGRATION_QOVERY = "qovery"; export const INTEGRATION_TERRAFORM_CLOUD = "terraform-cloud"; export const INTEGRATION_HASHICORP_VAULT = "hashicorp-vault"; export const INTEGRATION_CLOUDFLARE_PAGES = "cloudflare-pages"; @@ -53,6 +54,7 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_TEAMCITY, INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, + INTEGRATION_QOVERY, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, @@ -68,7 +70,7 @@ export const INTEGRATION_SET = new Set([ export const INTEGRATION_OAUTH2 = "oauth2"; // integration oauth endpoints -export const INTEGRATION_GCP_TOKEN_URL = "https://accounts.google.com/o/oauth2/token"; +export const INTEGRATION_GCP_TOKEN_URL = "https://oauth2.googleapis.com/token"; export const INTEGRATION_AZURE_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; export const INTEGRATION_HEROKU_TOKEN_URL = "https://id.heroku.com/oauth/token"; export const INTEGRATION_VERCEL_TOKEN_URL = @@ -83,6 +85,7 @@ export const INTEGRATION_BITBUCKET_TOKEN_URL = "https://bitbucket.org/site/oauth export const INTEGRATION_GCP_API_URL = "https://cloudresourcemanager.googleapis.com"; export const INTEGRATION_HEROKU_API_URL = "https://api.heroku.com"; export const INTEGRATION_GITLAB_API_URL = "https://gitlab.com/api"; +export const INTEGRATION_GITHUB_API_URL = "https://api.github.com"; export const INTEGRATION_VERCEL_API_URL = "https://api.vercel.com"; export const INTEGRATION_NETLIFY_API_URL = "https://api.netlify.com"; export const INTEGRATION_RENDER_API_URL = "https://api.render.com"; @@ -93,6 +96,7 @@ export const INTEGRATION_TRAVISCI_API_URL = "https://api.travis-ci.com"; export const INTEGRATION_SUPABASE_API_URL = "https://api.supabase.com"; export const INTEGRATION_LARAVELFORGE_API_URL = "https://forge.laravel.com"; export const INTEGRATION_CHECKLY_API_URL = "https://api.checklyhq.com"; +export const INTEGRATION_QOVERY_API_URL = "https://api.qovery.com"; export const INTEGRATION_TERRAFORM_CLOUD_API_URL = "https://app.terraform.io"; export const INTEGRATION_CLOUDFLARE_PAGES_API_URL = "https://api.cloudflare.com"; export const INTEGRATION_BITBUCKET_API_URL = "https://api.bitbucket.org"; @@ -105,6 +109,7 @@ export const INTEGRATION_NORTHFLANK_API_URL = "https://api.northflank.com"; export const INTEGRATION_GCP_SECRET_MANAGER_SERVICE_NAME = "secretmanager.googleapis.com" export const INTEGRATION_GCP_SECRET_MANAGER_URL = `https://${INTEGRATION_GCP_SECRET_MANAGER_SERVICE_NAME}`; export const INTEGRATION_GCP_SERVICE_USAGE_URL = "https://serviceusage.googleapis.com"; +export const INTEGRATION_GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; export const getIntegrationOptions = async () => { const INTEGRATION_OPTIONS = [ @@ -191,7 +196,7 @@ export const getIntegrationOptions = async () => { docsLink: "", }, { - name: "AWS Secret Manager", + name: "AWS Secrets Manager", slug: "aws-secret-manager", image: "Amazon Web Services.png", isAvailable: true, @@ -271,6 +276,15 @@ export const getIntegrationOptions = async () => { clientId: "", docsLink: "", }, + { + name: "Qovery", + slug: "qovery", + image: "Qovery.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "", + }, { name: "HashiCorp Vault", slug: "hashicorp-vault", diff --git a/backend/src/variables/organization.ts b/backend/src/variables/organization.ts index 4f5620236..5e796e357 100644 --- a/backend/src/variables/organization.ts +++ b/backend/src/variables/organization.ts @@ -1,10 +1,12 @@ // membership roles -export const OWNER = "owner"; +export const OWNER = "owner"; // depreciated export const ADMIN = "admin"; export const MEMBER = "member"; +export const VIEWER = "viewer"; +export const CUSTOM = "custom"; // membership statuses export const INVITED = "invited"; // -- organization -export const ACCEPTED = "accepted"; \ No newline at end of file +export const ACCEPTED = "accepted"; diff --git a/backend/swagger/index.ts b/backend/swagger/index.ts index 35ed20790..707abbfde 100644 --- a/backend/swagger/index.ts +++ b/backend/swagger/index.ts @@ -223,7 +223,8 @@ const generateOpenAPISpec = async () => { const endpointsFiles = ["../src/index.ts"]; const spec = await swaggerAutogen(outputJSONFile, endpointsFiles, doc); + await fs.writeFile(outputYAMLFile, yaml.dump(spec.data)); } -generateOpenAPISpec(); +generateOpenAPISpec(); \ No newline at end of file diff --git a/cli/go.mod b/cli/go.mod index b8f5aa20e..c67ccb1e5 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -1,6 +1,6 @@ module github.com/Infisical/infisical-merge -go 1.19 +go 1.21 require ( github.com/charmbracelet/lipgloss v0.5.0 @@ -75,4 +75,4 @@ require ( github.com/zalando/go-keyring v0.2.3 ) -replace github.com/zalando/go-keyring => github.com/Infisical/go-keyring v1.0.1 +replace github.com/zalando/go-keyring => github.com/Infisical/go-keyring v1.0.2 diff --git a/cli/go.sum b/cli/go.sum index 114125554..fd46e8656 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -39,8 +39,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Infisical/go-keyring v1.0.1 h1:E8XpqoT0H1G9C1kgxU+NeReXOeobmH7LbBHNpcOI380= -github.com/Infisical/go-keyring v1.0.1/go.mod h1:LWOnn/sw9FxDW/0VY+jHFAfOFEe03xmwBVSfJnBowto= +github.com/Infisical/go-keyring v1.0.2 h1:dWOkI/pB/7RocfSJgGXbXxLDcVYsdslgjEPmVhb+nl8= +github.com/Infisical/go-keyring v1.0.2/go.mod h1:LWOnn/sw9FxDW/0VY+jHFAfOFEe03xmwBVSfJnBowto= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 0e42eab8e..251ffde3e 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -198,7 +198,7 @@ var secretsSetCmd = &cobra.Command{ } // Key and value from argument - key := strings.ToUpper(splitKeyValueFromArg[0]) + key := splitKeyValueFromArg[0] value := splitKeyValueFromArg[1] hashedKey := fmt.Sprintf("%x", sha256.Sum256([]byte(key))) @@ -402,7 +402,12 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs}) + secretsPath, err := cmd.Flags().GetString("path") + if err != nil { + util.HandleError(err, "Unable to parse path flag") + } + + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath}) if err != nil { util.HandleError(err, "To fetch all secrets") } @@ -412,7 +417,7 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { secretsMap := getSecretsByKeys(secrets) for _, secretKeyFromArg := range args { - if value, ok := secretsMap[strings.ToUpper(secretKeyFromArg)]; ok { + if value, ok := secretsMap[secretKeyFromArg]; ok { requestedSecrets = append(requestedSecrets, value) } else { requestedSecrets = append(requestedSecrets, models.SingleEnvironmentVariable{ @@ -620,7 +625,7 @@ func generateExampleEnv(cmd *cobra.Command, args []string) { func CenterString(s string, numStars int) string { stars := strings.Repeat("*", numStars) padding := (numStars - len(s)) / 2 - cenetredTextWithStar := stars[:padding] + " " + strings.ToUpper(s) + " " + stars[padding:] + cenetredTextWithStar := stars[:padding] + " " + s + " " + stars[padding:] hashes := strings.Repeat("#", len(cenetredTextWithStar)+2) return fmt.Sprintf("%s \n# %s \n%s", hashes, cenetredTextWithStar, hashes) @@ -651,10 +656,11 @@ func init() { secretsGetCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") secretsCmd.AddCommand(secretsGetCmd) + secretsGetCmd.Flags().String("path", "/", "get secrets within a folder path") secretsCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") secretsCmd.AddCommand(secretsSetCmd) - secretsSetCmd.Flags().String("path", "/", "get secrets within a folder path") + secretsSetCmd.Flags().String("path", "/", "set secrets within a folder path") secretsSetCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { util.RequireLogin() diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index e0eaa084e..a8d248a3b 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -220,11 +220,11 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters) ([]models workspaceFile.WorkspaceId = params.WorkspaceId } - // Verify environment - err = ValidateEnvironmentName(params.Environment, workspaceFile.WorkspaceId, loggedInUserDetails.UserCredentials) - if err != nil { - return nil, fmt.Errorf("unable to validate environment name because [err=%s]", err) - } + // // Verify environment + // err = ValidateEnvironmentName(params.Environment, workspaceFile.WorkspaceId, loggedInUserDetails.UserCredentials) + // if err != nil { + // return nil, fmt.Errorf("unable to validate environment name because [err=%s]", err) + // } secretsToReturn, errorToReturn = GetPlainTextSecretsViaJTW(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceFile.WorkspaceId, params.Environment, params.TagSlugs, params.SecretsPath, params.IncludeImport) @@ -253,32 +253,32 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters) ([]models return secretsToReturn, errorToReturn } -func ValidateEnvironmentName(environmentName string, workspaceId string, userLoggedInDetails models.UserCredentials) error { - httpClient := resty.New() - httpClient.SetAuthToken(userLoggedInDetails.JTWToken). - SetHeader("Accept", "application/json") +// func ValidateEnvironmentName(environmentName string, workspaceId string, userLoggedInDetails models.UserCredentials) error { +// httpClient := resty.New() +// httpClient.SetAuthToken(userLoggedInDetails.JTWToken). +// SetHeader("Accept", "application/json") - response, err := api.CallGetAccessibleEnvironments(httpClient, api.GetAccessibleEnvironmentsRequest{WorkspaceId: workspaceId}) - if err != nil { - return err - } +// response, err := api.CallGetAccessibleEnvironments(httpClient, api.GetAccessibleEnvironmentsRequest{WorkspaceId: workspaceId}) +// if err != nil { +// return err +// } - listOfEnvSlugs := []string{} - mapOfEnvSlugs := make(map[string]interface{}) +// listOfEnvSlugs := []string{} +// mapOfEnvSlugs := make(map[string]interface{}) - for _, environment := range response.AccessibleEnvironments { - listOfEnvSlugs = append(listOfEnvSlugs, environment.Slug) - mapOfEnvSlugs[environment.Slug] = environment - } +// for _, environment := range response.AccessibleEnvironments { +// listOfEnvSlugs = append(listOfEnvSlugs, environment.Slug) +// mapOfEnvSlugs[environment.Slug] = environment +// } - _, exists := mapOfEnvSlugs[environmentName] - if !exists { - HandleError(fmt.Errorf("the environment [%s] does not exist in project with [id=%s]. Only [%s] are available", environmentName, workspaceId, strings.Join(listOfEnvSlugs, ","))) - } +// _, exists := mapOfEnvSlugs[environmentName] +// if !exists { +// HandleError(fmt.Errorf("the environment [%s] does not exist in project with [id=%s]. Only [%s] are available", environmentName, workspaceId, strings.Join(listOfEnvSlugs, ","))) +// } - return nil +// return nil -} +// } func getExpandedEnvVariable(secrets []models.SingleEnvironmentVariable, variableWeAreLookingFor string, hashMapOfCompleteVariables map[string]string, hashMapOfSelfRefs map[string]string) string { if value, found := hashMapOfCompleteVariables[variableWeAreLookingFor]; found { diff --git a/docs/api-reference/endpoints/folders/create.mdx b/docs/api-reference/endpoints/folders/create.mdx new file mode 100644 index 000000000..397f43cb5 --- /dev/null +++ b/docs/api-reference/endpoints/folders/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/folders/" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/folders/delete.mdx b/docs/api-reference/endpoints/folders/delete.mdx new file mode 100644 index 000000000..2147443e5 --- /dev/null +++ b/docs/api-reference/endpoints/folders/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/folders/{folderId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/folders/list.mdx b/docs/api-reference/endpoints/folders/list.mdx new file mode 100644 index 000000000..c467c5975 --- /dev/null +++ b/docs/api-reference/endpoints/folders/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/folders/" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/folders/update.mdx b/docs/api-reference/endpoints/folders/update.mdx new file mode 100644 index 000000000..fe8dd6cd8 --- /dev/null +++ b/docs/api-reference/endpoints/folders/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/folders/{folderId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-imports/create.mdx b/docs/api-reference/endpoints/secret-imports/create.mdx new file mode 100644 index 000000000..2c823e528 --- /dev/null +++ b/docs/api-reference/endpoints/secret-imports/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-imports/" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-imports/delete.mdx b/docs/api-reference/endpoints/secret-imports/delete.mdx new file mode 100644 index 000000000..c7da4f6d0 --- /dev/null +++ b/docs/api-reference/endpoints/secret-imports/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-imports/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-imports/list.mdx b/docs/api-reference/endpoints/secret-imports/list.mdx new file mode 100644 index 000000000..2de41b5d7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-imports/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-imports/" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-imports/update.mdx b/docs/api-reference/endpoints/secret-imports/update.mdx new file mode 100644 index 000000000..76c8a8feb --- /dev/null +++ b/docs/api-reference/endpoints/secret-imports/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PUT /api/v1/secret-imports/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secrets/create.mdx b/docs/api-reference/endpoints/secrets/create.mdx index 85d8ed350..9f6b65875 100644 --- a/docs/api-reference/endpoints/secrets/create.mdx +++ b/docs/api-reference/endpoints/secrets/create.mdx @@ -5,7 +5,6 @@ openapi: "POST /api/v3/secrets/{secretName}" Using this route requires understanding Infisical's system and cryptography. - It may be helpful to read through the - [introduction](/api-reference/overview/introduction) and [guide for creating - secrets](/api-reference/overview/examples/create-secret). + You should consult the [examples](https://infisical.com/docs/api-reference/overview/examples/note) for how to use + this endpoint. diff --git a/docs/api-reference/endpoints/secrets/delete.mdx b/docs/api-reference/endpoints/secrets/delete.mdx index d4084a465..0ab8e1309 100644 --- a/docs/api-reference/endpoints/secrets/delete.mdx +++ b/docs/api-reference/endpoints/secrets/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" openapi: "DELETE /api/v3/secrets/{secretName}" ---- +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secrets/list.mdx b/docs/api-reference/endpoints/secrets/list.mdx index eaa4597fb..377c6db86 100644 --- a/docs/api-reference/endpoints/secrets/list.mdx +++ b/docs/api-reference/endpoints/secrets/list.mdx @@ -5,7 +5,7 @@ openapi: "GET /api/v3/secrets/" Using this route requires understanding Infisical's system and cryptography. - It may be helpful to read through the - [introduction](/api-reference/overview/introduction) and [guide for retrieving - secrets](/api-reference/overview/examples/retrieve-secret). + You should consult the [examples](https://infisical.com/docs/api-reference/overview/examples/note) for how to use + this endpoint. + diff --git a/docs/api-reference/endpoints/secrets/read.mdx b/docs/api-reference/endpoints/secrets/read.mdx index 6dbbcd726..e472efccd 100644 --- a/docs/api-reference/endpoints/secrets/read.mdx +++ b/docs/api-reference/endpoints/secrets/read.mdx @@ -5,7 +5,6 @@ openapi: "GET /api/v3/secrets/{secretName}" Using this route requires understanding Infisical's system and cryptography. - It may be helpful to read through the - [introduction](/api-reference/overview/introduction) and [guide for retrieving - secrets](/api-reference/overview/examples/retrieve-secret). + You should consult the [examples](https://infisical.com/docs/api-reference/overview/examples/note) for how to use + this endpoint. diff --git a/docs/api-reference/endpoints/secrets/update.mdx b/docs/api-reference/endpoints/secrets/update.mdx index 594d8201c..c0bdcb94f 100644 --- a/docs/api-reference/endpoints/secrets/update.mdx +++ b/docs/api-reference/endpoints/secrets/update.mdx @@ -5,7 +5,7 @@ openapi: "PATCH /api/v3/secrets/{secretName}" Using this route requires understanding Infisical's system and cryptography. - It may be helpful to read through the - [introduction](/api-reference/overview/introduction) and [guide for updating - secrets](/api-reference/overview/examples/update-secret). + You should consult the [examples](https://infisical.com/docs/api-reference/overview/examples/note) for how to use + this endpoint. + diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index db2958b32..0e0b4b11e 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -4,6 +4,12 @@ title: "Changelog" The changelog below reflects new product developments and updates on a monthly basis. +## September + +- Released an update to access controls; every user role now clearly defines and enforces a certain set of conditions across Infisical. +- Updated UI/UX for integrations. +- Added a native integration with [Qovery](https://infisical.com/docs/integrations/cloud/qovery). + ## August 2023 - Release Audit Logs V2. @@ -34,7 +40,7 @@ The changelog below reflects new product developments and updates on a monthly b - Released the [Terraform Provider](https://infisical.com/docs/integrations/frameworks/terraform#5-run-terraform). - Updated the usage and billing page. Added the free trial for the professional tier. -- Added native intergations with [Checkly](https://infisical.com/docs/integrations/cloud/checkly), [Hashicorp Vault](https://infisical.com/docs/integrations/cloud/hashicorp-vault), and [Cloudflare Pages](https://infisical.com/docs/integrations/cloud/cloudflare-pages). +- Added native integrations with [Checkly](https://infisical.com/docs/integrations/cloud/checkly), [Hashicorp Vault](https://infisical.com/docs/integrations/cloud/hashicorp-vault), and [Cloudflare Pages](https://infisical.com/docs/integrations/cloud/cloudflare-pages). - Completed a penetration test with a `very good` result. - Added support for multi-line secrets. @@ -70,13 +76,13 @@ The changelog below reflects new product developments and updates on a monthly b ## Feb 2023 - Upgraded private key encryption/decryption mechanism to use Argon2id and 256-bit protected keys. -- Added preliminary emai-based 2FA capability. +- Added preliminary email-based 2FA capability. - Added suspicious login alerting if user logs in via new device or IP address. - Added documentation for PM2 integration. - Added secret backups support for the CLI; it now fetches and caches secrets locally to be used in the event of future failed fetch. - Added support for comparing secret values across environments on each secret. - Added native AWS Parameter Store integration. -- Added native AWS Secret Manager integration. +- Added native AWS Secrets Manager integration. - Added native GitLab integration. - Added native CircleCI integration. - Added native Travis CI integration. @@ -120,7 +126,7 @@ The changelog below reflects new product developments and updates on a monthly b ## Sep 2022 - Added capability to change user roles in projects. -- Added capabilty to delete projects. +- Added capability to delete projects. - Added Stripe. - Added default environments (development, staging, production) for new users with example key-pairs. - Added loading indicators. diff --git a/docs/cli/token.mdx b/docs/cli/token.mdx index c6cc6dcdd..b5a8dc6a9 100644 --- a/docs/cli/token.mdx +++ b/docs/cli/token.mdx @@ -1,11 +1,11 @@ --- title: "Infisical Token" -description: "How to use Infical service token within the CLI." +description: "How to use Infisical service token within the CLI." --- Prerequisite: [Infisical Token and How to Generate One](/documentation/platform/token). -It's possible to use the CLI to sync environment varialbes without manually entering login credentials by using a service token in the prerequisite link above. +It's possible to use the CLI to sync environment variables without manually entering login credentials by using a service token in the prerequisite link above. ## Feeding Infisical Token to the CLI diff --git a/docs/contributing/faq.mdx b/docs/contributing/faq.mdx new file mode 100644 index 000000000..31863687f --- /dev/null +++ b/docs/contributing/faq.mdx @@ -0,0 +1,93 @@ +--- +title: "FAQ" +description: "Frequently Asked Questions about contributing to Infisical" +--- + +Frequently asked questions about contributing to Infisical can be found on this page. +If you can't find the answer you are looking for, please create an issue on our GitHub repository or join our Slack channel for additional support. + + +The Alpine Linux CDN may be unavailable/down in your region infrequently (eg. there is an unplanned outage). One possible fix is to add a retry mechanism and a fallback mirrors array to the Dockerfile. You can also use this as an opportunity to pin the Alpine Linux version for Docker to use in case there are issues with the latest version. Ensure to use https for the mirrors. + +#### Make the following changes to the backend Dockerfile +```bash +# Pin Alpine version from list: https://dl-cdn.alpinelinux.org/alpine/ +ARG ALPINE_VERSION=3.17 +ARG ALPINE_APPEND=v3.17/main + +# Specify number of retries for each mirror +ARG MAX_RETRIES=3 + +# Define base Alpine mirror URLs in attempt order from list: https://dl-cdn.alpinelinux.org/alpine/MIRRORS.txt +ARG BASE_ALPINE_MIRRORS="https://dl-cdn.alpinelinux.org/alpine https://ftp.halifax.rwth-aachen.de/alpine https://uk.alpinelinux.org/alpine" + +# Build stage +# Add the Alpine version arg +FROM node:16-alpine$ALPINE_VERSION AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --only-production + +COPY . . +RUN npm run build + +# Production stage +# Add the Alpine version arg +FROM node:16-alpine$ALPINE_VERSION + +WORKDIR /app + +ENV npm_config_cache /home/node/.npm + +COPY package*.json ./ +RUN npm ci --only-production + +COPY --from=build /app . + +# Add retry mechanism and loop through the specified mirrors +RUN retries_left=$MAX_RETRIES; \ + for mirror in $ALPINE_MIRRORS; do \ + full_mirror="$mirror/$ALPINE_APPEND"; \ + echo "Trying mirror: $full_mirror"; \ + echo >>/etc/apk/repositories "$full_mirror"; \ + for i in $(seq $retries_left); do \ + echo "Retrying... Attempt $i (Retries Left: $((retries_left - i)))"; \ + if apk add --no-cache bash curl git && \ + curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash && \ + apk add --no-cache infisical=0.8.1; then \ + break; \ + fi; \ + sleep 10; \ + done; \ + if [ $? -eq 0 ]; then \ + break; \ + fi; \ + done + +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \ + CMD node healthcheck.js + +EXPOSE 4000 + +CMD ["npm", "run", "start"] + ``` + + + [Alpine Linux (mirrors) - official site](https://dl-cdn.alpinelinux.org/alpine/MIRRORS.txt) + + + + [Alpine Linux (mirrors) - archived site](https://web.archive.org/web/20230914123159/https://dl-cdn.alpinelinux.org/alpine/MIRRORS.txt) + + + + [Alpine Linux (versions) - official site](https://dl-cdn.alpinelinux.org/alpine/) + + + + [Alpine Linux (versions) - archived site](https://web.archive.org/web/20230914123455/https://dl-cdn.alpinelinux.org/alpine/) + + + diff --git a/docs/contributing/pull-requests.mdx b/docs/contributing/pull-requests.mdx index a06d9f164..15b95926c 100644 --- a/docs/contributing/pull-requests.mdx +++ b/docs/contributing/pull-requests.mdx @@ -29,7 +29,7 @@ Feel free to add a short video or screenshots of what your PR achieves. ## Getting your PR reviewed -Once your PR is reviewed, one or two relevent members of the Infisical team should review and approve the PR before it is merged. You should coordinate and ping the team member closest to the submitted functionality via our [Slack](https://infisical.com/slack) to review your PR. +Once your PR is reviewed, one or two relevant members of the Infisical team should review and approve the PR before it is merged. You should coordinate and ping the team member closest to the submitted functionality via our [Slack](https://infisical.com/slack) to review your PR. - Vlad: Frontend, Web UI - Tony: Backend, SDKs, Security @@ -44,4 +44,4 @@ Once everything is good, the team member(s) will approve the PR to be merged int reviewing PRs once they are fully complete and well-tested. In the past, we've often had to review low-quality PRs up to 10 times which severely restricts our capacity to address other issues, PRs, and initiatives in the pipeline. As such, we ask of the community to submit higher-quality PRs that, for example, don't break existing code; in return we'll prioritize the first 3 reviews for PRs. - \ No newline at end of file + diff --git a/docs/documentation/getting-started/docker.mdx b/docs/documentation/getting-started/docker.mdx index 567745ea4..4a3c79ee8 100644 --- a/docs/documentation/getting-started/docker.mdx +++ b/docs/documentation/getting-started/docker.mdx @@ -15,7 +15,7 @@ Prerequisites: ## Dockerfile Modification - Follow the instruction for your specific Linux distrubtion to add the Infisical CLI to your Dockerfile. + Follow the instructions for your specific Linux distribution to add the Infisical CLI to your Dockerfile. @@ -87,7 +87,7 @@ Prerequisites: ## Dockerfile Modifications - Follow the instruction for your specific Linux distributions to add the Infisical CLI to your Dockerfiles. + Follow the instructions for your specific Linux distributions to add the Infisical CLI to your Dockerfiles. @@ -182,4 +182,4 @@ Prerequisites: See also: - [Documentation for Docker](/integrations/platforms/docker) -- [Documentation for Docker Compose](/integrations/platforms/docker-compose) \ No newline at end of file +- [Documentation for Docker Compose](/integrations/platforms/docker-compose) diff --git a/docs/documentation/guides/node.mdx b/docs/documentation/guides/node.mdx index 323d95072..d182840b4 100644 --- a/docs/documentation/guides/node.mdx +++ b/docs/documentation/guides/node.mdx @@ -28,7 +28,7 @@ Now that we've created a project and added a secret to its development environme ## Create a Node app -For this demonstration, we use a minimal Express application. However, the same principles will apply for any Node application such as those built on Koa or Fastify. +For this demonstration, we use a minimal Express application. However, the same principles will apply to any Node application such as those built on Koa or Fastify. ### Create an Express app @@ -107,7 +107,7 @@ At this stage, you know how to fetch secrets from Infisical back to your Node ap Although the SDK requires you to pass in a token, it enables greater efficiency and security than if you managed dozens of secrets yourself without it. Here're some benefits: - - You always pull in the right secrets because they're fetched on demand from a centralize source that is Infisical. + - You always pull in the right secrets because they're fetched on demand from a centralized source that is Infisical. - You can use the Infisical which comes with tons of benefits like secret versioning, access controls, audit logs, etc. - You now risk leaking one token that can be revoked instead of dozens of raw secrets. @@ -118,4 +118,4 @@ At this stage, you know how to fetch secrets from Infisical back to your Node ap See also: -- Explore the [Node SDK](https://github.com/Infisical/infisical-node) \ No newline at end of file +- Explore the [Node SDK](https://github.com/Infisical/infisical-node) diff --git a/docs/documentation/guides/python.mdx b/docs/documentation/guides/python.mdx index 2c558a69c..50ee44a86 100644 --- a/docs/documentation/guides/python.mdx +++ b/docs/documentation/guides/python.mdx @@ -27,7 +27,7 @@ Now that we've created a project and added a secret to its development environme ## Create a Python app -For this demonstration, we use a minimal Flask application. However, the same principles will apply for any Python application such as those built with Django. +For this demonstration, we use a minimal Flask application. However, the same principles will apply to any Python application such as those built with Django. ### Create a Flask app @@ -103,7 +103,7 @@ At this stage, you know how to fetch secrets from Infisical back to your Python Although the SDK requires you to pass in a token, it enables greater efficiency and security than if you managed dozens of secrets yourself without it. Here're some benefits: - - You always pull in the right secrets because they're fetched on demand from a centralize source that is Infisical. + - You always pull in the right secrets because they're fetched on demand from a centralized source that is Infisical. - You can use the Infisical which comes with tons of benefits like secret versioning, access controls, audit logs, etc. - You now risk leaking one token that can be revoked instead of dozens of raw secrets. diff --git a/docs/documentation/platform/folder.mdx b/docs/documentation/platform/folder.mdx index b112b5787..506b30da2 100644 --- a/docs/documentation/platform/folder.mdx +++ b/docs/documentation/platform/folder.mdx @@ -4,7 +4,7 @@ description: "Organize your secrets with folders" --- Folders provide a powerful and intuitive way to structure your secrets. -They offer a system to keep your secrets organized and easily accessible, which becomes increasingly important as your collection of secrets grow. +They offer a system to keep your secrets organized and easily accessible, which becomes increasingly important as your collection of secrets grows. With folders in Infisical, you can now create a hierarchy of folders to organize your secrets, mirroring your application's architecture or any logical grouping that suits your needs. Whether you follow a microservices architecture or work with monorepos, folders make it simpler to locate, manage and collaborate between teams. diff --git a/docs/documentation/platform/project.mdx b/docs/documentation/platform/project.mdx index 065835fb3..38a677ab0 100644 --- a/docs/documentation/platform/project.mdx +++ b/docs/documentation/platform/project.mdx @@ -27,10 +27,10 @@ In most cases, environment variables belong to specific environments: developmen ### Personal overrides -Every environment variable value can be overriden with a custom value. +Every environment variable value can be overridden with a custom value. -- An overriden value can only be read and accesssed by the user that overrode the original shared value. -- A (default) shared value can be read and accesssed by other users in a project. +- An overridden value can only be read and accessed by the user that overrode the original shared value. +- A (default) shared value can be read and accessed by other users in a project. You can turn overrides on/off by toggling the override/branch icon: diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index 380facd94..d0d750330 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -1,10 +1,9 @@ --- -title: "Reference/Import Secrets" +title: "Reference and Import Secrets" description: "How to use reference secrets in Infisical" --- -Secret referencing is a powerful feature that allows you to create a secret whose value is linked to one or more other secrets. -This is useful when you need to use a single secret's value across multiple other secrets. +Secret referencing is a powerful feature that allows you to values of other secrets. This way, you just need to update the secret value once for it to be propagated to all the references. Consider a scenario where you have a database password. In order to utilize this password, you may need to incorporate it into a database connection string. With secret referencing, you can easily construct these more intricate secrets by directly referencing the base secret. @@ -34,7 +33,7 @@ For instance, to access a secret 'A' composed of secrets 'B' and 'C' from differ When using [service tokens](./token) to fetch referenced secrets, ensure the service token has read access to all referenced environments and folders. Without proper permissions, the final secret value may be incomplete. -## Import entire folders +## Import entire folders/environments While secret referencing effectively minimizes duplication, there might be instances where you need to import or replicate an entire folder's secrets into another. This can be achieved using the 'Import' feature. @@ -50,3 +49,5 @@ Additionally, any secrets you define directly in your environment will override You can modify the order of folders to control overrides using the `Change Order` drag handle. ![secret import change order](../../images/secret-import-change-order.png) + + diff --git a/docs/documentation/platform/sso/azure.mdx b/docs/documentation/platform/sso/azure.mdx index 9169c9f6f..7012e9d48 100644 --- a/docs/documentation/platform/sso/azure.mdx +++ b/docs/documentation/platform/sso/azure.mdx @@ -3,6 +3,13 @@ title: "Azure SAML" description: "Configure Azure SAML for Infisical SSO" --- + + Azure SAML SSO feature is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + 1. In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**. Next, copy the **Reply URL (Assertion Consumer Service URL)** and **Identifier (Entity ID)** to use when configuring the Azure SAML application. diff --git a/docs/documentation/platform/sso/github.mdx b/docs/documentation/platform/sso/github.mdx new file mode 100644 index 000000000..2b4dd8c85 --- /dev/null +++ b/docs/documentation/platform/sso/github.mdx @@ -0,0 +1,37 @@ +--- +title: "GitHub SSO" +description: "Configure GitHub SSO for Infisical" +--- + +Using GitHub SSO on a self-hosted instance of Infisical requires configuring an OAuth2 application in GitHub and registering your instance with it. + +## Create an OAuth application in GitHub + +Navigate to your user Settings > Developer settings > OAuth Apps to create a new GitHub OAuth application. + +![GitHub settings](../../../images/sso/github/settings.png) +![GitHub developer settings](../../../images/sso/github/dev-settings.png) +![GitHub create new OAuth application](../../../images/sso/github/new-app.png) + +Create the OAuth application. As part of the form, set the **Homepage URL** to your self-hosted domain `https://your-domain.com` +and the **Authorization callback URL** to `https://your-domain.com/api/v1/sso/github`. + +![GitHub create new OAuth application form](../../../images/sso/github/new-app-form.png) + + + If you have a GitHub organization, you can create an OAuth application under it + in your organization Settings > Developer settings > OAuth Apps > New Org OAuth App. + + +## Add your OAuth application credentials to Infisical + +Obtain the **Client ID** and generate a new **Client Secret** for your GitHub OAuth application. + +![GCP obtain OAuth2 credentials](../../../images/sso/github/credentials.png) + +Back in your Infisical instance, add two new environment variables for the credentials of your GitHub OAuth application: + +- `CLIENT_ID_GITHUB_LOGIN`: The **Client ID** of your GitHub OAuth application. +- `CLIENT_SECRET_GITHUB_LOGIN`: The **Client Secret** of your GitHub OAuth application. + +Once added, restart your Infisical instance and log in with GitHub. \ No newline at end of file diff --git a/docs/documentation/platform/sso/google.mdx b/docs/documentation/platform/sso/google.mdx new file mode 100644 index 000000000..61da9188c --- /dev/null +++ b/docs/documentation/platform/sso/google.mdx @@ -0,0 +1,30 @@ +--- +title: "Google SSO" +description: "Configure Google SSO for Infisical" +--- + +Using Google SSO on a self-hosted instance of Infisical requires configuring an OAuth2 application in GCP and registering your instance with it. + +## Create an OAuth2 application in GCP + +Navigate to your project API & Services > Credentials to create a new OAuth2 application. + +![GCP API services](../../../images/sso/google/api-services.png) +![GCP create new OAuth2 application](../../../images/sso/google/new-app.png) + +Create the application. As part of the form, add to **Authorized redirect URIs**: `https://your-domain.com/api/v1/sso/google`. + +![GCP create new OAuth2 application form](../../../images/sso/google/new-app-form.png) + +## Add your OAuth2 application credentials to Infisical + +Obtain the **Client ID** and **Client Secret** for your GCP OAuth2 application. + +![GCP obtain OAuth2 credentials](../../../images/sso/google/credentials.png) + +Back in your Infisical instance, add two new environment variables for the credentials of your GCP OAuth2 application: + +- `CLIENT_ID_GOOGLE_LOGIN`: The **Client ID** of your GCP OAuth2 application. +- `CLIENT_SECRET_GOOGLE_LOGIN`: The **Client Secret** of your GCP OAuth2 application. + + Once added, restart your Infisical instance and log in with Google \ No newline at end of file diff --git a/docs/documentation/platform/sso/jumpcloud.mdx b/docs/documentation/platform/sso/jumpcloud.mdx index f22d173d4..ef17156da 100644 --- a/docs/documentation/platform/sso/jumpcloud.mdx +++ b/docs/documentation/platform/sso/jumpcloud.mdx @@ -3,6 +3,13 @@ title: "JumpCloud SAML" description: "Configure JumpCloud SAML for Infisical SSO" --- + + JumpCloud SAML SSO feature is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + 1. In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**. Next, copy the **ACS URL** and **SP Entity ID** to use when configuring the JumpCloud SAML application. diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx index 36293be14..4595de019 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -3,6 +3,13 @@ title: "Okta SAML" description: "Configure Okta SAML 2.0 for Infisical SSO" --- + + Okta SAML SSO feature is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + 1. In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**. Next, copy the **Single sign-on URL** and **Audience URI (SP Entity ID)** to use when configuring the Okta SAML 2.0 application. diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index 9a9afadef..359f09fb3 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -4,9 +4,11 @@ description: "Log in to Infisical via SSO protocols" --- - Infisical currently has confirmed support for SAML SSO authentication with - Okta, Azure AD, and JumpCloud. We're expanding support for other IdPs in the - coming months, so stay tuned and feel free to request a IdP at this + Infisical offers Google SSO and GitHub SSO for free across both Infisical Cloud and Infisical Self-hosted. + + Infisical also offers SAML SSO authentication but as paid features that can be unlocked on Infisical Cloud's **Pro** tier + or via enterprise license on self-hosted instances of Infisical. On this front, we currently support Okta, Azure AD, and JumpCloud and + are expanding support for other IdPs in the coming months; stay tuned and feel free to request a IdP at this [issue](https://github.com/Infisical/infisical/issues/442). @@ -15,6 +17,8 @@ You can configure your organization in Infisical to have members authenticate wi To note, configuring SSO retains the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps. In all login with SSO implementations, your IdP cannot and will not have access to the decryption key needed to decrypt your secrets. +- [Google SSO](/documentation/platform/sso/google) +- [GitHub SSO](/documentation/platform/sso/github) - [Okta SAML](/documentation/platform/sso/okta) - [Azure SAML](/documentation/platform/sso/azure) -- [JumpCloud SAML](/documentation/platform/sso/jumpcloud) +- [JumpCloud SAML](/documentation/platform/sso/jumpcloud) \ No newline at end of file diff --git a/docs/images/email-resend-create-domain.png b/docs/images/email-resend-create-domain.png new file mode 100644 index 000000000..9a469e87a Binary files /dev/null and b/docs/images/email-resend-create-domain.png differ diff --git a/docs/images/email-resend-create-key.png b/docs/images/email-resend-create-key.png new file mode 100644 index 000000000..b0b4265a5 Binary files /dev/null and b/docs/images/email-resend-create-key.png differ diff --git a/docs/images/email-resend-smtp-settings.png b/docs/images/email-resend-smtp-settings.png new file mode 100644 index 000000000..df678259e Binary files /dev/null and b/docs/images/email-resend-smtp-settings.png differ diff --git a/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth-options.png b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth-options.png new file mode 100644 index 000000000..9b2f665a1 Binary files /dev/null and b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth-options.png differ diff --git a/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create-options.png b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create-options.png new file mode 100644 index 000000000..d084ebb6a Binary files /dev/null and b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create-options.png differ diff --git a/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create.png b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create.png index 9e1719a59..afc4724cb 100644 Binary files a/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create.png and b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create.png differ diff --git a/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam-key.png b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam-key.png new file mode 100644 index 000000000..242a8ae35 Binary files /dev/null and b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam-key.png differ diff --git a/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam.png b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam.png new file mode 100644 index 000000000..35af1251c Binary files /dev/null and b/docs/images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam.png differ diff --git a/docs/images/integrations/gitlab/integrations-gitlab-create-options.png b/docs/images/integrations/gitlab/integrations-gitlab-create-options.png new file mode 100644 index 000000000..abb507997 Binary files /dev/null and b/docs/images/integrations/gitlab/integrations-gitlab-create-options.png differ diff --git a/docs/images/integrations/gitlab/integrations-gitlab-create.png b/docs/images/integrations/gitlab/integrations-gitlab-create.png index 4d13658c7..ae3ca7141 100644 Binary files a/docs/images/integrations/gitlab/integrations-gitlab-create.png and b/docs/images/integrations/gitlab/integrations-gitlab-create.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery-auth.png b/docs/images/integrations/qovery/integrations-qovery-auth.png new file mode 100644 index 000000000..3619bc87b Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery-auth.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery-create-1.png b/docs/images/integrations/qovery/integrations-qovery-create-1.png new file mode 100644 index 000000000..cfbc05895 Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery-create-1.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery-create-2.png b/docs/images/integrations/qovery/integrations-qovery-create-2.png new file mode 100644 index 000000000..7c186b6b5 Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery-create-2.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery-token.png b/docs/images/integrations/qovery/integrations-qovery-token.png new file mode 100644 index 000000000..aa1b81b7d Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery-token.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery.png b/docs/images/integrations/qovery/integrations-qovery.png new file mode 100644 index 000000000..4a8f5c400 Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery.png differ diff --git a/docs/images/internals/architecture.png b/docs/images/internals/architecture.png new file mode 100644 index 000000000..4f955b93a Binary files /dev/null and b/docs/images/internals/architecture.png differ diff --git a/docs/images/sso/github/credentials.png b/docs/images/sso/github/credentials.png new file mode 100644 index 000000000..82b44c03b Binary files /dev/null and b/docs/images/sso/github/credentials.png differ diff --git a/docs/images/sso/github/dev-settings.png b/docs/images/sso/github/dev-settings.png new file mode 100644 index 000000000..e95e94321 Binary files /dev/null and b/docs/images/sso/github/dev-settings.png differ diff --git a/docs/images/sso/github/new-app-form.png b/docs/images/sso/github/new-app-form.png new file mode 100644 index 000000000..c162fee55 Binary files /dev/null and b/docs/images/sso/github/new-app-form.png differ diff --git a/docs/images/sso/github/new-app.png b/docs/images/sso/github/new-app.png new file mode 100644 index 000000000..64f552564 Binary files /dev/null and b/docs/images/sso/github/new-app.png differ diff --git a/docs/images/sso/github/settings.png b/docs/images/sso/github/settings.png new file mode 100644 index 000000000..27547dbfe Binary files /dev/null and b/docs/images/sso/github/settings.png differ diff --git a/docs/images/sso/google/api-services.png b/docs/images/sso/google/api-services.png new file mode 100644 index 000000000..59bd43fcd Binary files /dev/null and b/docs/images/sso/google/api-services.png differ diff --git a/docs/images/sso/google/credentials.png b/docs/images/sso/google/credentials.png new file mode 100644 index 000000000..af32df88a Binary files /dev/null and b/docs/images/sso/google/credentials.png differ diff --git a/docs/images/sso/google/new-app-form.png b/docs/images/sso/google/new-app-form.png new file mode 100644 index 000000000..834ed181f Binary files /dev/null and b/docs/images/sso/google/new-app-form.png differ diff --git a/docs/images/sso/google/new-app.png b/docs/images/sso/google/new-app.png new file mode 100644 index 000000000..b950d8070 Binary files /dev/null and b/docs/images/sso/google/new-app.png differ diff --git a/docs/integrations/cicd/gitlab.mdx b/docs/integrations/cicd/gitlab.mdx index d7b2637c6..9c3df9720 100644 --- a/docs/integrations/cicd/gitlab.mdx +++ b/docs/integrations/cicd/gitlab.mdx @@ -32,6 +32,16 @@ Press on the GitLab tile and grant Infisical access to your GitLab account. Select which Infisical environment secrets you want to sync to which GitLab repository and press create integration to start syncing secrets to GitLab. ![integrations gitlab](../../images/integrations/gitlab/integrations-gitlab-create.png) + +Note that the GitLab integration supports a few options in the **Options** tab: + +- Secret Prefix: If inputted, the prefix is appended to the front of every secret name prior to being synced. +- Secret Suffix: If inputted, the suffix to appended to the back of every name of every secret prior to being synced. + +Setting a secret prefix or suffix ensures that existing secrets in GCP Secret Manager are not overwritten during the sync. As part of this process, Infisical abstains from mutating any secrets in GitLab without the specified prefix or suffix. + +![integrations gitlab options](../../images/integrations/gitlab/integrations-gitlab-create-options.png) + ![integrations gitlab](../../images/integrations/gitlab/integrations-gitlab.png) diff --git a/docs/integrations/cloud/aws-parameter-store.mdx b/docs/integrations/cloud/aws-parameter-store.mdx index 63fd1e1c6..42b41ac73 100644 --- a/docs/integrations/cloud/aws-parameter-store.mdx +++ b/docs/integrations/cloud/aws-parameter-store.mdx @@ -16,7 +16,7 @@ Navigate to your IAM user permissions and add a permission policy to grant acces ![integration IAM 2](../../images/integrations-aws-parameter-store-iam-2.png) ![integrations IAM 3](../../images/integrations-aws-parameter-store-iam-3.png) -For better security, here's a custom policy containing the minimum permissions required by Infisical to sync secrets to AWS Parameter Store for the IAM user that you can use: +For enhanced security, here's a custom policy containing the minimum permissions required by Infisical to sync secrets to AWS Parameter Store for the IAM user that you can use: ```json { diff --git a/docs/integrations/cloud/aws-secret-manager.mdx b/docs/integrations/cloud/aws-secret-manager.mdx index a798ae820..4df054077 100644 --- a/docs/integrations/cloud/aws-secret-manager.mdx +++ b/docs/integrations/cloud/aws-secret-manager.mdx @@ -1,6 +1,6 @@ --- -title: "AWS Secret Manager" -description: "How to sync secrets from Infisical to AWS Secret Manager" +title: "AWS Secrets Manager" +description: "How to sync secrets from Infisical to AWS Secrets Manager" --- Prerequisites: @@ -8,15 +8,15 @@ Prerequisites: - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - Set up AWS and have/create an IAM user -## Grant the IAM user permissions to access AWS Secret Manager +## Grant the IAM user permissions to access AWS Secrets Manager -Navigate to your IAM user permissions and add a permission policy to grant access to AWS Secret Manager. +Navigate to your IAM user permissions and add a permission policy to grant access to AWS Secrets Manager. ![integration IAM 1](../../images/integrations-aws-iam-1.png) ![integration IAM 2](../../images/integrations-aws-secret-manager-iam-2.png) ![integrations IAM 3](../../images/integrations-aws-secret-manager-iam-3.png) -For better security, here's a custom policy containing the minimum permissions required by Infisical to sync secrets to AWS Secret Manager for the IAM user that you can use: +For better security, here's a custom policy containing the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager for the IAM user that you can use: ```json { @@ -40,7 +40,7 @@ For better security, here's a custom policy containing the minimum permissions r ![integrations](../../images/integrations.png) -## Authorize Infisical for AWS Secret Manager +## Authorize Infisical for AWS Secrets Manager Obtain a AWS access key ID and secret access key for your IAM user in IAM > Users > User > Security credentials > Access keys @@ -48,7 +48,7 @@ Obtain a AWS access key ID and secret access key for your IAM user in IAM > User ![access key 2](../../images/integrations-aws-access-key-2.png) ![access key 3](../../images/integrations-aws-access-key-3.png) -Press on the AWS Secret Manager tile and input your AWS access key ID and secret access key from the previous step. +Press on the AWS Secrets Manager tile and input your AWS access key ID and secret access key from the previous step. ![integration auth](../../images/integrations-aws-secret-manager-auth.png) @@ -61,12 +61,12 @@ Press on the AWS Secret Manager tile and input your AWS access key ID and secret ## Start integration -Select which Infisical environment secrets you want to sync to which AWS Secret Manager region and under which secret name. Then, press create integration to start syncing secrets to AWS Secret Manager. +Select which Infisical environment secrets you want to sync to which AWS Secrets Manager region and under which secret name. Then, press create integration to start syncing secrets to AWS Secrets Manager. ![integration create](../../images/integrations-aws-secret-manager-create.png) - Infisical currently syncs environment variables to AWS Secret Manager as + Infisical currently syncs environment variables to AWS Secrets Manager as key-value pairs under one secret. We're actively exploring ways to help users group environment variable key-pairs under multiple secrets for greater control. diff --git a/docs/integrations/cloud/gcp-secret-manager.mdx b/docs/integrations/cloud/gcp-secret-manager.mdx index 6033f7e64..505475009 100644 --- a/docs/integrations/cloud/gcp-secret-manager.mdx +++ b/docs/integrations/cloud/gcp-secret-manager.mdx @@ -5,17 +5,24 @@ description: "How to sync secrets from Infisical to GCP Secret Manager" + + + + Prerequisites: - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - -## Navigate to your project's integrations tab + ## Navigate to your project's integrations tab ![integrations](../../images/integrations.png) ## Authorize Infisical for GCP -Press on the GCP Secret Manager tile and grant Infisical access to GCP. +Press on the GCP Secret Manager tile and select **Continue with OAuth** + +![integrations GCP authorization options](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth-options.png) + +Grant Infisical access to GCP. ![integrations GCP authorization](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth.png) @@ -28,18 +35,92 @@ Press on the GCP Secret Manager tile and grant Infisical access to GCP. ## Start integration -Select which Infisical environment secrets you want to sync to which GCP secret manager project. Lastly, press create integration to start syncing secrets to GCP secret manager. +In the **Connection** tab, select which Infisical environment secrets you want to sync to which GCP secret manager project. Lastly, press create integration to start syncing secrets to GCP secret manager. ![integrations GCP secret manager](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create.png) + +Note that the GCP Secret Manager integration supports a few options in the **Options** tab: + +- Secret Prefix: If inputted, the prefix is appended to the front of every secret name prior to being synced. +- Secret Suffix: If inputted, the suffix to appended to the back of every name of every secret prior to being synced. +- Label in GCP Secret Manager: If selected, every secret will be labeled in GCP Secret Manager (e.g. as `managed-by:infisical`); labels can be customized. + +Setting a secret prefix, suffix, or enabling the labeling option ensures that existing secrets in GCP Secret Manager are not overwritten during the sync. As part of this process, Infisical abstains from mutating any secrets in GCP Secret Manager without the specified prefix, suffix, or attached label. + +![integrations GCP secret manager options](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create-options.png) + ![integrations GCP secret manager](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager.png) Using Infisical to sync secrets to GCP Secret Manager requires that you enable - the Service Usage API in the Google Cloud project you want to sync secrets to. More on that [here](https://cloud.google.com/service-usage/docs/set-up-development-environment). + the Service Usage API and Cloud Resource Manager API in the Google Cloud project you want to sync secrets to. More on that [here](https://cloud.google.com/service-usage/docs/set-up-development-environment). + + + Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) +- Have a GCP project and have/create a [service account](https://cloud.google.com/iam/docs/service-account-overview) in it + +## Grant the service account permissions for GCP Secret Manager + +Navigate to **IAM & Admin** page in GCP and add the **Secret Manager Admin** and **Service Usage Admin** roles to the service account. + +![integrations GCP secret manager IAM](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam.png) + + + For enhanced security, you may want to assign more granular permissions to the service account. At minimum, + the service account should be able to read/write secrets from/to GCP Secret Manager (e.g. **Secret Manager Admin** role) + and list which GCP services are enabled/disabled (e.g. **Service Usage Admin** role). + + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Authorize Infisical for GCP + +Press on the GCP Secret Manager tile and paste in your **GCP Service Account JSON** (you can create and download the JSON for your +service account in IAM & Admin > Service Accounts > Service Account > Keys). + +![integrations GCP authorization IAM key](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-iam-key.png) + +![integrations GCP authorization options](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-auth-options.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + +## Start integration + +In the **Connection** tab, select which Infisical environment secrets you want to sync to the GCP secret manager project. Lastly, press create integration to start syncing secrets to GCP secret manager. + +![integrations GCP secret manager](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create.png) + +Note that the GCP Secret Manager integration supports a few options in the **Options** tab: + +- Secret Prefix: If inputted, the prefix is appended to the front of every secret name prior to being synced. +- Secret Suffix: If inputted, the suffix to appended to the back of every name of every secret prior to being synced. +- Label in GCP Secret Manager: If selected, every secret will be labeled in GCP Secret Manager (e.g. as `managed-by:infisical`); labels can be customized. + +Setting a secret prefix, suffix, or enabling the labeling option ensures that existing secrets in GCP Secret Manager are not overwritten during the sync. As part of this process, Infisical abstains from mutating any secrets in GCP Secret Manager without the specified prefix, suffix, or attached label. + +![integrations GCP secret manager options](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-create-options.png) + +![integrations GCP secret manager](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager.png) + + + Using Infisical to sync secrets to GCP Secret Manager requires that you enable + the Service Usage API and Cloud Resource Manager API in the Google Cloud project you want to sync secrets to. More on that [here](https://cloud.google.com/service-usage/docs/set-up-development-environment). + + + - Using the GCP Secret Manager integration on a self-hosted instance of Infisical requires configuring an OAuth2 application in GCP + Using the GCP Secret Manager integration (via the OAuth2 method) on a self-hosted instance of Infisical requires configuring an OAuth2 application in GCP and registering your instance with it. ## Create an OAuth2 application in GCP @@ -49,7 +130,7 @@ Select which Infisical environment secrets you want to sync to which GCP secret ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-api-services.png) ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app.png) - Create the application. As part of the form, add to **Authorized redirect URIs**: `https://your-domain.com/integrations/gitlab/oauth2/callback`. + Create the application. As part of the form, add to **Authorized redirect URIs**: `https://your-domain.com/integrations/gcp-secret-manager/oauth2/callback`. ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app-form.png) diff --git a/docs/integrations/cloud/qovery.mdx b/docs/integrations/cloud/qovery.mdx new file mode 100644 index 000000000..98539dc8f --- /dev/null +++ b/docs/integrations/cloud/qovery.mdx @@ -0,0 +1,43 @@ +--- +title: "Qovery" +description: "How to sync secrets from Infisical to Qovery" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Enter your Qovery API Token + +Obtain a Qovery API Token in Settings > API Token. + +![integrations qovery api token](../../images/integrations/qovery/integrations-qovery-token.png) + +Press on the Qovery tile and input your Qovery API Token to grant Infisical access to your Qovery account. + +![integrations qovery authorization](../../images/integrations/qovery/integrations-qovery-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it is necessary for Infisical to sync the environment variables to + the cloud platform. + + +## Start integration + +Select which Infisical environment secrets you want to sync to Qovery and press create integration to start syncing secrets. + +![integrations qovery create](../../images/integrations/qovery/integrations-qovery-create-1.png) + +![integrations qovery create](../../images/integrations/qovery/integrations-qovery-create-2.png) + + + Infisical supports syncing secrets to various Qovery scopes including applications, jobs, or containers. + + +![integrations qovery settings](../../images/integrations/qovery/integrations-qovery.png) \ No newline at end of file diff --git a/docs/integrations/cloud/windmill.mdx b/docs/integrations/cloud/windmill.mdx index 594d483b0..c79931b70 100644 --- a/docs/integrations/cloud/windmill.mdx +++ b/docs/integrations/cloud/windmill.mdx @@ -13,7 +13,7 @@ Prerequisites: ## Enter your Windmill Access Token -Obtain a Windmill access token in Access Tokens +Obtain a [Windmill](https://www.windmill.dev/) access token in Access Tokens ![integrations windmill dashboard](../../images/integrations-windmill-dashboard.png) ![integrations windmill token](../../images/integrations-windmill-token.png) diff --git a/docs/integrations/frameworks/bun.mdx b/docs/integrations/frameworks/bun.mdx new file mode 100644 index 000000000..a5085920a --- /dev/null +++ b/docs/integrations/frameworks/bun.mdx @@ -0,0 +1,34 @@ +--- +title: "Bun" +description: "How to use Infisical to inject environment variables and secrets into a Bun app." +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) +- [Install the CLI](/cli/overview) + +## Initialize Infisical for your [Bun](https://bun.sh) + +```bash +# navigate to the root of your of your project +cd /path/to/project + +# then initialize infisical +infisical init +``` + +## Start your application as usual but with Infisical + +```bash +infisical run -- + +# Example +infisical run -- bun run dev +``` + + + Bun environment variables can be called as either `Bun.env.SECRET` or `process.env.SECRET`. We also recommend you check this more in-depth [guide to environment variables in Bun](https://infisical.com/blog/bun-environment-variables). + + + \ No newline at end of file diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index a464aa9cb..b90cf7c36 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -27,9 +27,10 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [Northflank](/integrations/cloud/northflank) | Cloud | Available | | [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | | [Checkly](/integrations/cloud/checkly) | Cloud | Available | +| [Qovery](/integrations/cloud/qovery) | Cloud | Available | | [HashiCorp Vault](/integrations/cloud/hashicorp-vault) | Cloud | Available | | [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | -| [AWS Secret Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | +| [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | | [Azure Key Vault](/integrations/cloud/azure-key-vault) | Cloud | Available | | [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | | [Windmill](/integrations/cloud/windmill) | Cloud | Available | diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 3d26536ba..942a60100 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -452,3 +452,8 @@ The managed secret created by the operator will not be deleted when the operator ``` + + +## Useful Articles + +- [Managing secrets in OpenShift with Infisical](https://xphyr.net/post/infisical_ocp/) diff --git a/docs/internals/components.mdx b/docs/internals/components.mdx new file mode 100644 index 000000000..3ec9adb77 --- /dev/null +++ b/docs/internals/components.mdx @@ -0,0 +1,30 @@ +--- +title: "Components" +description: "Infisical's components span multiple clients, an API, and a storage backend" +--- + +## Infisical API + +The Infisical API (sometimes referred to as the **backend**) contains the core platform logic. + +## Storage backend + +Infisical relies on a storage backend to store data including users and secrets. + +Currently, the only supported storage backend is [MongoDB](https://www.mongodb.com) but we plan to add support for other options including PostgreSQL in Q1 2024. + +## Redis + +Infisical uses [Redis](https://redis.com) to enable more complex workflows including a queuing system to manage long running asynchronous tasks, cron jobs, as well as reliable cache for frequently used resources. + +## Infisical Web UI + +The Web UI is the browser-based portal that connects to the Infisical API. + +## Infisical clients + +Clients are any application or infrastructure that connecting to the Infisical API using one of the below methods: +- Public API: Making API requests directly to the Infisical API. +- Client SDK: A platform-specific library with method abstractions for working with secrets. Currently, there are two official SDKs: [Node SDK](https://github.com/Infisical/infisical-node) and [Python SDK](https://github.com/Infisical/infisical-python). +- CLI: A terminal-based interface for interacting with the Infisical API. +- Kubernetes Operator: This operator retrieves secrets from Infisical and securely store \ No newline at end of file diff --git a/docs/internals/flows.mdx b/docs/internals/flows.mdx new file mode 100644 index 000000000..e18a37a31 --- /dev/null +++ b/docs/internals/flows.mdx @@ -0,0 +1,97 @@ +--- +title: "Flows" +description: "Infisical's core flows have strong cryptographic underpinnings" +--- + +## Signup + +When a user signs up for an account using email/password, they verify their email by correctly entering the 6-digit OTP code sent to it. + +After this procedure, the user creates a password that is checked against strict requirements to ensure that it has sufficient entropy; this is critical because passwords have both authentication-related and cryptographic implications in Infisical. In accordance to the [secure remote password protocol (SRP)](https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol), the password is used to generate a salt and X; this is kept handy on the client side. + +Next, a few user-associated symmetric keys are generated for subsequent use: + +- The password is transformed into a 256-bit symmetric key, called the generated key, using the [Argon2id](https://en.wikipedia.org/wiki/Argon2) key derivation function. +- A 256-bit symmetric key, called the protected key, is generated. +- A public-private key pair is generated. + +The symmetric keys are used in sequence to encrypt the user’s private key: + +- The protected key is used to encrypt the private key. +- The generated key is used to encrypt the protected key. + +Finally, the encrypted private key, the protected key, salt, and X are sent to the Infisical API to be stored in the storage backend. Note that the top-level secret used to secure the user’s account and private key is their password. Therefore, it must be unknown to the Infisical API and strong by nature. + +## Login + +When a user logs in, they enter their password to authenticate with Infisical via SRP. If successful, the encrypted protected key and encrypted private key are returned to the client side. + +The password is then used in reverse sequence to decrypt the private key: + +- The password is transformed back into the generated key. +- The generated key is used to decrypt the encrypted protected key. +- The protected key is used to decrypt the encrypted private key. + +The private key is stored on the client side and kept handy. + +## Single sign-on + +When a SSO authentication method like Google, GitHub, or SAML SSO is used to login or signup to Infisical, the process is identical to logging in with email/password except that it is contingent on first successfully logging in via the authentication provider. This means, for example, a user with Google SSO enabled must first log in with Google and then enter their password for Infisical to complete logging into the platform. + +This approach implies that the user’s password assumes only the role of a master decryption key or secret. It also ensures that the authentication provider does not know this top-level secret, keeping the platform zero-knowledge as intended. + +## Account recovery + +When a user signs up for Infisical, they are issued a backup PDF containing a symmetric key that can be used to recover their account by decrypting a copy of that user’s private key; using the backup PDF is the only way to recover a user’s account in the event of a lockout - this is intentional by design of Infisical’s zero-knowledge architecture. + +We strongly encourage all users to download, print, and keep their backup PDFs in a secure location. + +## Secrets + +In Infisical, secrets belong to environments in projects, and projects belong to organizations. Each project can be thought of as a vault and has its own symmetric key, called the project key. The project key is used to encrypt the secrets contained in that project. + +Similar to each user’s private key, the project key is sensitive and must remain unknown to the server to preserve the zero-knowledge aspect of Infisical; knowledge of the project key would allow the server to decrypt the secrets of that project which would be undesirable if the server is compromised. + +In order to preserve the zero-knowledge aspect of Infisical, each project key is encrypted on the client side before being sent to the server. More specifically, for each project, we make copies of its project key for each member of that project; each copy is encrypted under that member’s public key and only then sent off to the server for storage. A few relevant sequences: + +- The initial member of a project generates its project key, encrypts it under their public key, and uploads it to the server for storage. +- When a new member is added to the project, an existing member of the project (e.g. the initial member) fetches their copy of the project key, decrypts that copy, encrypts it under the public key of the new member, and uploads it to the server for storage. +- When a member is removed from a project, their copy of the project key is hard deleted from the storage backend. + +When dealing with secrets, this implies a specific sequence of decryption/encryption steps to fetch and create/update them. Assuming that we’re dealing with the Infisical Web UI, let’s start with fetching secrets which happens after the user logs in and selects a project: + +- The user fetches encrypted secrets back to the client side. +- The user also fetches the encrypted project key, encrypted under their public key, for these secrets. +- The encrypted project key is decrypted by the user’s private key which is kept handy on the client side. +- The project key is finally used to decrypt the secrets belonging to the project. +- The secrets are displayed to the user in the Infisical Web UI. + +Similarly, when a user creates/updates a secret, the reverse sequence is performed: + +- The user fetches the encrypted project key, encrypted under the user’s public key. +- The project key is decrypted by the user’s private key which is kept handy on the client side. +- The user encrypts the new/updated secret under the project key. +- The user sends the new/updated secret to the server for storage. + +These sequences are performed across various Infisical clients including the web UI, CLI, SDKs, and K8s operators when dealing with the Infisical API. They are also relevant in the implementations of Infisical’s versioning features like secret versions and snapshots. + +## Native integrations + +Previously, we mentioned that Infisical is zero-knowledge; this is partly true because Infisical can be used this way. Under certain circumstances, however, a user can explicitly share their copy of the project key with the server to enable more advanced features like native integrations. + +The way a project key is shared with Infisical is via an abstraction that we call a bot. Each project has a bot with a public-private key pair generated on the server; the private key of each bot is symmetrically encrypted by the root encryption key of the server. This implies a few things: + +- The server may partake in the sharing of project keys via its own public-private keys bound to each project bot. +- The server root encryption key must be kept secure. + +With that, let’s discuss native integrations. A native integrations is a connection between Infisical and a target platform like GitHub, GitLab, or Vercel that allows secrets to be synced from Infisical to the target platform using its API. Since native integrations require secrets to be sent over in plaintext, they require the server to have access to the secrets. The sequence for how integrations are implemented is fairly simple: + +- A user explicitly shares copy of the project key with the server via the Infisical Web UI. In this step, the user fetches the public key of the bot assigned to that project, encrypts the project key under that public key, and sends it back to the server. +- The user selects a target platform to integrate with their project and enters details such as the source environment within the project to send secrets from as well as the project and environment in the target platform to sync secrets to. +- The user creates the integration, triggering the first sync wherein Infisical decrypts the project’s key, uses it to decrypt the secrets of that project, and sends the secrets to the target platform. +- Finally, on any subsequent mutations applied to the source environment of an active integration, Infisical automatically triggers a re-sync to the target platform. This keeps Infisical as a ground source-of-truth for a team’s secrets. + +## Resources + +- For in depth details, consult the code. +- To get started with Infisical, try out the [Getting Started](https://infisical.com/docs/documentation/getting-started/introduction) overview. \ No newline at end of file diff --git a/docs/internals/overview.mdx b/docs/internals/overview.mdx new file mode 100644 index 000000000..d4ec9a564 --- /dev/null +++ b/docs/internals/overview.mdx @@ -0,0 +1,37 @@ +--- +title: "Overview" +description: "How Infisical works under the hood" +--- + +This section covers the internals of Infisical including its technical underpinnings, architecture, and security properties. + + + Knowledge of this section is recommended but not required to use Infisical. However, if you're operating Infisical, we recommend understanding the internals. + + +## Learn More + + + + Learn about the fundamental parts of Infisical + + + Find out more about the structure of core user flows in Infisical + + + Read about most common security-related topics and questions + + + Learn best practices for utilizing Infisical sevrice tokens + + diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx new file mode 100644 index 000000000..1da0bc73d --- /dev/null +++ b/docs/internals/security.mdx @@ -0,0 +1,175 @@ +--- +title: "Security" +description: "Infisical's security model includes many considerations and initiatives" +--- + +Given that Infisical is a secret management platform that manages sensitive data, the Infisical security model is very important. +The goal of Infisical's security model is to ensure the security and integrity of all of its managed data as well as all associated operations. + +This means that data at rest and in transit must be secure from eavesdropping or tampering. All clients must be authenticated and authorized to access data. Additionally, all interactions must be auditable and traced uniquely back to their source. + +## Threat model + +Infisical’s threat model spans communication, storage, response mechanisms, failover strategies, and more. + +- Eavesdropping on communications: Infisical ensures end-to-end encryption for all client interactions with the Infisical API. +- Tampering with data (at rest or in transit): Infisical implements data integrity checks to detect tampering. If inconsistencies are found, Infisical aborts transactions and raises alerts. +- Unauthorized access (lacking authentication/authorization): Infisical mandates rigorous authentication and authorization checks for all inbound requests; it also offers multi-factor authentication and role-based access controls. +- Actions without accountability: Infisical logs all project-level events, including policy updates, queries/mutations applied to secrets, and more. Every event is timestamped and information about actor, source (i.e. IP address, user-agent, etc.), and relevant metadata is included. +- Breach of data storage confidentiality: Infisical encrypts all stored secrets using proven cryptographic techniques such as AES-256-GCM for symmetric encryption. +- Loss of service availability or secret data due to failures: Infisical leverages the robust container orchestration capabilities of Kubernetes and the inherent high availability features of Bitnami MongoDB to ensure resilience and fault tolerance. By deploying multiple replicas of Infisical application on Kubernetes, operations can continue even if a single instance fails. +- Unrecognized suspicious activities: Infisical monitors for any anomalous activities such as authentication attempts from previously unseen sources. +- Unidentified system vulnerabilities: Infisical undergoes penetration tests and vulnerability assessments twice a year; we act on findings to bolster the system's defense mechanisms. + +That said, Infisical does not consider the following as part of its threat model: + +- Uncontrolled access to the storage mechanism: An attacker with unfettered access to the storage system can manipulate data in unpredictable ways, including erasing or tampering with stored secrets. Furthermore, the attacker could potentially implement state rollbacks to favor their objectives. +- Disclosure of secret presence: If an adversary gains read access to the storage backend, they might discern the existence of certain secrets, even if the actual contents remain encrypted and concealed. +- Runtime memory intrusion: An attacker with capabilities to probe the memory state of a live instance of Infisical can potentially compromise data confidentiality. +- Vulnerabilities in affiliated systems: Some functionality may rely on third-party services and dependencies. Security lapses in these dependencies can indirectly jeopardize the confidentiality or integrity of the secrets. +- Breaches via compromised clients: If a system or application accessing Infisical is compromised, and its credentials to the platform are exposed, an attacker might gain access at the privilege level of that compromised entity. +- Configuration tampering by administrators: Any configuration data, whether supplied through admin interfaces or configuration files, needs scrutiny. If an attacker can manipulate these configurations, it poses risks to data confidentiality and integrity. +- Physical access to deployment infrastructure: An attacker with physical access to the servers or infrastructure where Infisical is deployed can potentially compromise the system in ways that are challenging to guard against, such as direct hardware tampering or booting from malicious media. +- Social engineering attacks on personnel: Attacks that target personnel, tricking them into divulging sensitive information or performing compromising actions, fall outside the platform's direct defensive purview. + +It's essential to note that while these points fall outside the platform's direct threat model, they still form crucial considerations for an overarching security strategy. + +## External threat overview + +Infisical's architecture consists of various systems: + +- Infisical API +- Storage backend +- Redis +- Infisical Web UI +- Infisical clients + +The Infisical API requires that the Infisical Web UI and all Infisical clients are authenticated and authorized for every inbound request. If using [Infisical Cloud](https://app.infisical.com), all traffic is routed through [Cloudflare](https://www.cloudflare.com) which enforces TLS and requires a minimum of TLS 1.2. + +The Infisical API is untrusted by design when dealing with secrets. All secrets are encrypted/decrypted on the client-side before reaching the Infisical API by default; granting Infisical access to secrets afterward is optional and up to your organization. + +The storage backend used by Infisical is also untrusted by design. All sensitive data is encrypted either symmetrically with AES-256-GCM or asymmetrically with x25519-xsalsa20-poly1305 prior to entering the storage backend, depending on the context either on the client-side or server-side. Moreover, Infisical communicates with the storage backend over TLS to provide an added layer of security. + +## Internal threat overview + +Within Infisical, a critical security concern is an attacker gaining access to sensitive data that they are not permitted to, especially if they already has some degree of access to the system. There are currently two authentication methods categories used by clients for where we apply robust authentication and authorization logic. + +### JWT / API Key + +This token category is used by users and included in requests made from the Infisical Web UI or elsewhere to the Infisical API. + +Each token is authenticated against the API and mapped to an existing user in Infisical. If no existing user is found for the token, the request is rejected by the API. Each token assumes the permission set of the user that it is mapped to. For example, if a user corresponding to a token is not allowed access to a certain organization or project, then the token is also not be valid for any requests concerning those specific resources. + +In the event of compromise, an attacker could use the token to impersonate the associated user and perform actions within the permission set of that user. While they could retrieve secrets for a project that the user is part of, they could not, however, decrypt secrets if the project follows Infisical's default zero-knowlege architecture. In any case, it would be critical for the user to invalidate this token and change their password immediately to prevent further unintended actions and consequences. + +### Service token + +This token category is provisioned by users for applications and infrastructure to perform secret operations against the Infisical API. + +Each token is scoped to a project in Infisical and configurable with an expiration date and permission set (also known as **scopes**) for specific environment(s) and path(s) within them. For example, you may provision an application a service token to authenticate against the Infisical API and retrieve secrets from some `/environment-variables` path in the production environment of a project. If the token is tried for another project, environment, or path outside of its permission set, then it is rejected by the API. + +It should also be noted that projects in Infisical can be configured to restrict service token access to specific IP addresses or CIDR ranges; this can be useful for limiting access to traffic coming from corporate networks. + +In the event of compromise, an attacker could use a service token to access the secrets that it is provisioned for. It would be critical here for project administrator(s) to revoke the token immediately to prevent further unintended access to resources; it would also be advisable currently to transfer secrets to a new project where a new project key is created on the client-side. + +## Cryptography + +Infisical uses AES-256-GCM for symmetric encryption and x25519-xsalsa20-poly1305 for asymmetric encryption operations; asymmetric algorithms are implemented with the [TweetNaCl.js](https://tweetnacl.js.org/#/) library which has been well-audited and recommended for use by cybersecurity firm Cure53. Lastly, the secure remote password (SRP) implementation uses [jsrp](https://github.com/alax/jsrp) package for user authentication. + +By default, Infisical employs a zero-knowledge-first approach to securely storing and sharing secrets. + +- Each secret belongs to a project and is symmetrically encrypted by that project's unique key. Each member of a project is shared a copy of the project key, encrypted under their public key, when they are first invited to join the project. +Since these encryption operations occur on the client-side, the Infisical API is not able to view the value of any secret and the default zero-knowledge property of Infisical is retained; as you'd expect, it follows that decryption operations also occur on the client-side. +- An exception to the zero-knowledge property occurs when a member of a project explicitly shares that project's unique key with Infisical. It is often necessary to share the project key with Infisical in order to use features like native integrations and secret rotation that wouldn't be possible to offer otherwise. + + +## Infrastructure + +### High availability + +Infisical leverages the robust container orchestration capabilities of Kubernetes and the inherent high availability features of the storage backend (i.e. Bitnami MongoDB) to ensure resilience and fault tolerance. + +- Kubernetes: By deploying multiple replicas of Infisical application on Kubernetes, operations continue even if a single instance fails. Kubernetes Services facilitate load balancing, effectively distributing traffic across your application’s instances and ensuring optimal performance. +- Storage backend: Bitnami MongoDB supports replica sets, which provide data redundancy and automatic failover for the underlying database. +- If using [Infisical Cloud](https://app.infisical.com), data is stored in a Mongo Atlas cluster with storage autoscaling and cluster tier autoscaling enabled; as you'd expect, the cluster sits on a dedicated node. + +Together, Kubernetes’ self-healing mechanisms and Bitnami MongoDB’s failover capabilities work to create a highly available and fault-tolerant application capable of recovering gracefully from unexpected failures. + +### Snapshots + +A snapshot is a complete copy of data in the storage backend at a point in time. + +If using [Infisical Cloud](https://app.infisical.com), snapshots of MongoDB databases are taken regularly; this can be enabled on your own storage backend as well. + +### Offline usage + +Many teams and organizations use the [Infisical CLI](https://infisical.com/docs/cli/overview) to fetch and inject secrets back from Infisical into their applications and infrastructure locally; the CLI has offline fallback capabiltiies. + +If you have previously retrieved secrets for a specific project and environment, the `run/secret` command will utilize the saved secrets, even when offline, on subsequent fetch attempts to ensure that you always have access to secrets. + +## Platform + +### Web application + +Infisical utilizes the latest HTTP security headers and employs a strict Content-Security-Policy to mitigate XSS. + +JWT tokens are stored in browser memory and appended to outbound requests requiring authentication; refresh tokens are stored in `HttpOnly` cookies and included in future requests to `/api/token` for JWT token renewal. + +### User authentication + +Infisical supports several authentication methods including email/password, Google SSO, GitHub SSO, and SAML 2.0 (Okta, Azure, JumpCloud); Infisical also currently offers email-based 2FA with authenticator app methods coming in Q1 2024. + +Infisical uses the [secure remote password protocol](https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol#:~:text=The%20SRP%20protocol%20has%20a,the%20user%20to%20the%20server), commonly found in other zero-knowledge platform architectures, for authentication. +Put simply, the protocol enables Infisical to validate a user's knowledge of their password without ever seeing it by constructing a mutual secret; we use this protocol because each user's password is used to seed the generation of a master encryption/decryption key via KDF for that user which the platform +should not see. + +Lastly, Infisical enforces strong password requirements according to the guidance set forth in [NIST Special Publication 800–63B](https://pages.nist.gov/800-63-3/sp800-63b.html#appA). Since passwords in Infisical also has cryptographic implications, Infisical validates each password on client-side to meet minimum length and entropy requirements; Infisical also considers each password against the [Have I Been Pwned (HIBP) API](https://haveibeenpwned.com), which checks the password against around 700M breached passwords, in a privacy-preserving way. + + + Since Infisical's unique zero-knowledge architecture requires a master decryption key for every user account, users with Google SSO, GitHub SSO, or SAML 2.0 enabled must still enter a secret after the + authentication step to access their secrets in Infisical. In practice, this implies stronger security since users must successfully authenticate with a single sign-on provider and provide a master decryption key + to access the platform. + + We strongly encourage users to generate and store their passwords / master decryption key in a password manager, such as 1Password, Bitwarden, or Dashlane. + + +## Role-based access control (RBAC) + +Infisical's RBAC feature enables organization owners and administrators to manage fine-grained access policies for members of their organization in Infisical; with RBAC, administrators can define custom roles with permission sets to be conveniently assigned to other members. + +For example, you can define a role provisioning access to secrets in a specific project and environment in it with read-only permissions; the role can be assigned to members of an organization in Infisical. + +### Audit logging + +Infisical's audit logging feature spans 25+ events, tracking everything from permissioning changes to queries and mutations applied to secrets, for security and compliance teams at enterprises to monitor information access in the event of any suspicious activity or incident review. Every event is timestamped and information about actor, source (i.e. IP address, user-agent, etc.), and relevant metadata is included. + +### IP allowlisting + +Infisical's IP allowlisting feature can be configured to restrict client access to specific IP addresses or CIDR ranges. This applies to any client using service tokens and can be useful, for example, for limiting access to traffic coming from corporate networks. + +By default, each project is initialized with the `0.0.0.0/0` entry, representing all possible IPv4 addresses. For enhanced security, we strongly recommend replacing the default entry with your client IPs to tighten access to your secrets. + +## Penetration testing + +Infisical hires external third parties to perform regular security assessment and penetration testing of the platform. + +Most recently, Infisical commissioned cybersecurity firm [Oneleet](https://www.oneleet.com) to perform a full-coverage, gray box penetration test against the platform's entire attack surface to identify vulnerabilities according to industry standards (OWASP, ASVS, WSTG, TOP-10, etc.). + +Please email security@infisical.com to request any reports including a letter of attestation for the conducted penetration test. + +## Employee data access + +Whether or not Infisical or your employees can access data in the Infisical instance and/or storage backend depends on many factors how you use Infisical: + +- Infisical Self-Hosted: Self-hosting Infisical is common amongst organizations that prefer to keep data on their own infrastructure usually to adhere to strict regulatory and compliance requirements. In this option, organizations retain full control over their data and therefore govern the data access policy of their Infisical instance and storage backend. +- Infisical Cloud: Using Infisical's managed service, [Infisical Cloud](https://app.infisical.com) means delegating data oversight and management to Infisical. Under our policy controls, employees are only granted access to parts of infrastructure according to principle of least privilege; this is especially relevent to customer data can only be accessed currently by executive management of Infisical. Moreover, any changes to sensitive customer data is prohibited without explicit customer approval. + +It should be noted that, even on Infisical Cloud, it is physically impossible for employees of Infisical to view the values of secrets if users have not explicitly granted Infisical access to their project (i.e. opted out of zero-knowledge). + +Please email security@infisical.com if you have any specific inquiries about employee data access policies. + +## Get in touch + +If you have any concerns about Infisical or believe you have uncovered a vulnerability, please get in touch via the e-mail address security@infisical.com. In the message, try to provide a description of the issue and ideally a way of reproducing it. The security team will get back to you as soon as possible. + +Note that this security address should be used for undisclosed vulnerabilities. Please report any security problems to us before disclosing it publicly. \ No newline at end of file diff --git a/docs/internals/service-tokens.mdx b/docs/internals/service-tokens.mdx new file mode 100644 index 000000000..a04eccee7 --- /dev/null +++ b/docs/internals/service-tokens.mdx @@ -0,0 +1,49 @@ +--- +title: "Service tokens" +description: "Understanding service tokens and their best practices" +--- +​ +Many clients use service tokens to authenticate and read/write secrets from/to Infisical; they can be created in your project settings. + +## Anatomy + +A service token in Infisical consists of the token itself, a `string`, and a corresponding document in the storage backend containing its +properties and metadata. + +### Database model + +The storage backend model for a token contains the following information: + +- ID: The token identifier. +- Expiration: The date at which point the token is invalid. +- Project: The project that the token is part of. +- Scopes: The project environments and paths that the token has access to. +- Encrypted project key: An encrypted copy of the project key. + +### Token + +A service token itself consist of two parts used for authentication and decryption, separated by the delimiter `.`. + +Consider the token `st.abc.def.ghi`. Here, `st.abc.def` can be used to authenticate with the API, by including it in the `Authorization` header under `Bearer st.abc.def`, and retrieve (encrypted) secrets as well as a project key back. Meanwhile, `ghi`, a hex-string, can be used to decrypt the project key used to decrypt the secrets. + +Note that when using service tokens via select client methods like SDK or CLI, cryptographic operations are abstracted for you that is the token is parsed and encryption/decryption operations are handled. If using service tokens with the REST API and end-to-end encryption enabled, then you will have to handle the encryption/decryption operations yourself. +​ +## Recommendations + +### Issuance + +When creating a new service token, it’s important to consider the principle of least privilege(PoLP) when setting its scope and expiration date. For example, if the client using the token only requires access to a staging environment, then you should scope the token to that environment only; you can further scope tokens to path(s) within environment(s) if you happen to use path-based secret storage. Likewise, if the client does not intend to access secrets indefinitely, then you may consider setting a finite lifetime for the token such as 6 months or 1 year from now. Finally, you should consider carefully whether or not your client requires the ability to read and/or write secrets from/to Infisical. + +### Network access + +We recommend configuring the IP whitelist settings of each project to allow either single IP addresses or CIDR-notated range of addresses to read/write secrets to Infisical. With this feature, you can specify the IP range of your client servers to restrict access to your project in Infisical. + +### Storage + +Since service tokens grant access to your secrets, we recommend storing them securely across your development cycle whether it be in a .env file in local development or as an environment variable of your deployment platform. + +### Rotation + +We recommend periodically rotating the service token, even in the absence of compromise. Since service tokens are capable of decrypting project keys used to decrypt secrets, all of which use AES-256-GCM encryption, they should be rotated before approximately 2^32 encryptions have been performed; this follows the guidance set forth by [NIST publication 800-38D](https://csrc.nist.gov/pubs/sp/800/38/d/final). + +Note that Infisical keeps track of the number of times that service tokens are used and will alert you when you have reached 90% of the recommended capacity. \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index 05bb4ee98..19e26d54e 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -42,9 +42,9 @@ }, "anchors": [ { - "name": "Security", - "icon": "shield-halved", - "url": "security" + "name": "Internals", + "icon": "sitemap", + "url": "internals" }, { "name": "SDKs", @@ -126,6 +126,8 @@ "group": "SSO", "pages": [ "documentation/platform/sso/overview", + "documentation/platform/sso/google", + "documentation/platform/sso/github", "documentation/platform/sso/okta", "documentation/platform/sso/azure", "documentation/platform/sso/jumpcloud" @@ -150,6 +152,7 @@ "self-hosting/configuration/envars", "self-hosting/configuration/email", "self-hosting/configuration/redis", + "self-hosting/configuration/sso", "self-hosting/faq" ] }, @@ -235,6 +238,7 @@ "integrations/cloud/teamcity", "integrations/cloud/cloudflare-pages", "integrations/cloud/checkly", + "integrations/cloud/qovery", "integrations/cloud/hashicorp-vault", "integrations/cloud/azure-key-vault", "integrations/cloud/gcp-secret-manager", @@ -331,6 +335,15 @@ "api-reference/endpoints/environments/delete" ] }, + { + "group": "Folders", + "pages": [ + "api-reference/endpoints/folders/list", + "api-reference/endpoints/folders/create", + "api-reference/endpoints/folders/update", + "api-reference/endpoints/folders/delete" + ] + }, { "group": "Secrets", "pages": [ @@ -343,12 +356,31 @@ "api-reference/endpoints/secrets/rollback-version" ] }, + { + "group": "Secret imports", + "pages": [ + "api-reference/endpoints/secret-imports/list", + "api-reference/endpoints/secret-imports/create", + "api-reference/endpoints/secret-imports/update", + "api-reference/endpoints/secret-imports/delete" + ] + }, { "group": "Service Tokens", "pages": ["api-reference/endpoints/service-tokens/get"] } ] }, + { + "group": "Internals", + "pages": [ + "internals/overview", + "internals/components", + "internals/flows", + "internals/security", + "internals/service-tokens" + ] + }, { "group": "Security", "pages": [ @@ -368,7 +400,8 @@ "contributing/overview", "contributing/code-of-conduct", "contributing/developing", - "contributing/pull-requests" + "contributing/pull-requests", + "contributing/faq" ] } ], diff --git a/docs/security/mechanics.mdx b/docs/security/mechanics.mdx index d1c483d2c..06d5fedd9 100644 --- a/docs/security/mechanics.mdx +++ b/docs/security/mechanics.mdx @@ -11,7 +11,7 @@ The 256-bit key is used to encrypt the private key; the 256-bit key itself is th The encrypted private key, protected key, user identifier information, and SRP details are forwarded to the server. -Once authenticated via SRP, a user is issued a JWT and refresh token. The JWT token is stored in browser memory under a write-only class `SecurityClient` that appends the token to all future outbound requests requiring authentication. The refresh token is stored in an `HttpOnly` cookie and included in future requests to `/api/token` for JWT token renewal. This design side-steps potential XSS attacks on local storage. +Once authenticated via SRP, a user is issued a JWT and refresh token. The JWT token is stored in browser memory and is appended to all future outbound requests requiring authentication. The refresh token is stored in an `HttpOnly` cookie and included in future requests to `/api/token` for JWT token renewal. This design side-steps potential XSS attacks on local storage. Infisical authenticates users using the SRP protocol. With SRP, the server can diff --git a/docs/security/overview.mdx b/docs/security/overview.mdx index bddff608b..bd0ed0878 100644 --- a/docs/security/overview.mdx +++ b/docs/security/overview.mdx @@ -7,7 +7,9 @@ description: "Infisical's security statement." Infisical uses end-to-end encryption (E2EE) whenever possible to securely store and share secret values. It uses secure remote password (SRP) to handle authentication and public-key cryptography for secret sharing and syncing; secrets are symmetrically encrypted by keys decryptable only by members of the project. -Infisical uses AES256-GCM for symmetric encryption and x25519-xsalsa20-poly1305 for asymmetric encryption operations mentioned in this brief; key generation and asymmetric algorithms are implemented with the [TweetNaCl.js](https://tweetnacl.js.org/#/) library which has been well-audited and recommended for use by cybersecurity firm Cure53. Lastly, the secure remote password (SRP) implementation uses [jsrp](https://github.com/alax/jsrp) package for user authentication. As part of our commitment to user privacy and security, we aim to conduct formal security and compliance audits in the following year. +Infisical uses AES256-GCM for symmetric encryption and x25519-xsalsa20-poly1305 for asymmetric encryption operations mentioned in this brief; key generation and asymmetric algorithms are implemented with the [TweetNaCl.js](https://tweetnacl.js.org/#/) library which has been well-audited and recommended for use by cybersecurity firm Cure53. Lastly, the secure remote password (SRP) implementation uses [jsrp](https://github.com/alax/jsrp) package for user authentication. + +As part of our commitment to user privacy and security, we undergo penetration tests twice a year and are working to achieve SOC 2 (Type II) compliance in Fall 2023. ## Scope @@ -24,7 +26,8 @@ In subsequent sections, we refer: As a secrets manager, we are deeply committed to enforcing the privacy and security of all users and data on the platform but acknowledge that it is virtually impossible to guarantee perfect security; unfortunately, even the most secure systems have vulnerabilities. -As part of our commitment, we do our best to maintain platform privacy and security, notify users if anything goes wrong, and rectify adverse situations immediately if anything happens. As Infisical grows, we will be adding more opt-in security measures to ensure better data protection and maintain trust within the growing community. With that, let’s make the most simple and secure secrets management system out there! +As part of our commitment, we do our best to maintain platform privacy and security, notify users if anything goes wrong, and rectify adverse situations immediately if anything happens. +We are continuously adding more opt-in security measures to ensure better data protection and maintain trust within the growing community. With that, let’s make the most simple and secure secrets management system out there! Best, diff --git a/docs/self-hosting/configuration/email.mdx b/docs/self-hosting/configuration/email.mdx index 160e4f205..566265a2d 100644 --- a/docs/self-hosting/configuration/email.mdx +++ b/docs/self-hosting/configuration/email.mdx @@ -25,6 +25,36 @@ If you choose to setup email service, you need to configure the following SMTP [ Below you will find details on how to configure common email providers: + +1. Create an account on [Resend](https://resend.com). +2. Add a [Domain](https://resend.com/domains). + +![adding resend domain](../../images/email-resend-create-domain.png) + +3. Create an [API Key](https://resend.com/api-keys). + +![creating resend api key](../../images/email-resend-create-key.png) + +4. Go to the [SMTP page](https://resend.com/settings/smtp) and copy the values. + +![go to resend smtp settings](../../images/email-resend-smtp-settings.png) + +5. With the API Key, you can now set your SMTP environment variables variables: + +``` +SMTP_HOST=smtp.resend.com +SMTP_USERNAME=resend +SMTP_PASSWORD=YOUR_API_KEY +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` + + Remember that you will need to restart Infisical for this to work properly. + + + 1. Create an account and configure [SendGrid](https://sendgrid.com) to send emails. diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 0cddf3566..eeb0e6031 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -5,7 +5,7 @@ description: "Configure your environment variables when self-hosting Infisical." ## Backend environment variables -Depending on your choosen self hosted deployment method, you may need to configured at least the required environment variable listed below. +Depending on your chosen self hosted deployment method, you may need to configured at least the required environment variable listed below. Other environment variables are listed below to increase the functionality of your self hosted instance based on your use case. @@ -165,7 +165,7 @@ Other environment variables are listed below to increase the functionality of yo #### JWT - JWT token lifetime expressed in seconds or a string describing a time span + JWT token lifetime expressed in seconds or a string describing a time span diff --git a/docs/self-hosting/configuration/sso.mdx b/docs/self-hosting/configuration/sso.mdx new file mode 100644 index 000000000..2497e368b --- /dev/null +++ b/docs/self-hosting/configuration/sso.mdx @@ -0,0 +1,20 @@ +--- +title: "Configure SSO" +description: "How to configure SSO when self-hosting Infisical." +--- + + + Infisical offers Google SSO and GitHub SSO for free. + + Infisical also offers SAML SSO authentication but as paid features that can be unlocked via enterprise license; if this is of interest, please contact team@infisical.com. + On this front, we currently support Okta, Azure AD, and JumpCloud and are expanding support for other IdPs in the coming months; stay tuned and feel free to request a IdP at this + [issue](https://github.com/Infisical/infisical/issues/442). + + +You can view specific documentation for how to set up each SSO authentication method below: + +- [Google SSO](/documentation/platform/sso/google) +- [GitHub SSO](/documentation/platform/sso/github) +- [Okta SAML](/documentation/platform/sso/okta) +- [Azure SAML](/documentation/platform/sso/azure) +- [JumpCloud SAML](/documentation/platform/sso/jumpcloud) \ No newline at end of file diff --git a/docs/self-hosting/deployment-options/digital-ocean-marketplace.mdx b/docs/self-hosting/deployment-options/digital-ocean-marketplace.mdx index 8049cd3e5..f1e739f08 100644 --- a/docs/self-hosting/deployment-options/digital-ocean-marketplace.mdx +++ b/docs/self-hosting/deployment-options/digital-ocean-marketplace.mdx @@ -3,10 +3,10 @@ title: "Digital Ocean" description: "Learn to install Infisical on Digital Ocean" --- -Infisical can be deployed on a Kubernetes cluster with a single click through our Digital Ocean marketplace application. -The initiation of the installation process triggers the creation of a Kubernetes cluster, followed by the installation of Infisical onto that cluster. +Infisical can be deployed on a Kubernetes cluster with a single click through our Digital Ocean marketplace application. +The initiation of the installation process triggers the creation of a Kubernetes cluster, followed by the installation of Infisical onto that cluster. -This automated deploymnet method uses the same proccess under the hood as the manual [Kubernetes installation guide](./kubernetes-helm). +This automated deployment method uses the same process under the hood as the manual [Kubernetes installation guide](./kubernetes-helm). ### Initiate the installation @@ -23,5 +23,5 @@ Within this section, you'll find the newly created load balancer for Infisical. ### Adjusting configurations If you need to either upgrade or downgrade Infisical, or modify environment variables to alter its functionality, refer to our [Kubernetes installation](./kubernetes-helm) page for detailed instructions. -Because Digital Ocean deploys the same Helm application as described in our [Kubernetes installation](./kubernetes-helm) guide, you can utilize that guide to implement the required changes. -It's important to note that any modifications requires familiarly with Helm package manager. \ No newline at end of file +Because Digital Ocean deploys the same Helm application as described in our [Kubernetes installation](./kubernetes-helm) guide, you can utilize that guide to implement the required changes. +It's important to note that any modifications requires familiarly with Helm package manager. diff --git a/docs/self-hosting/deployment-options/kubernetes-helm.mdx b/docs/self-hosting/deployment-options/kubernetes-helm.mdx index 83f3c8f6e..37383fa82 100644 --- a/docs/self-hosting/deployment-options/kubernetes-helm.mdx +++ b/docs/self-hosting/deployment-options/kubernetes-helm.mdx @@ -43,14 +43,14 @@ frontend: replicaCount: 2 image: repository: infisical/frontend - tag: "v0.26.0" # <--- frontend version + tag: "v0.34.2" # <--- frontend version pullPolicy: Always backend: replicaCount: 2 image: repository: infisical/backend - tag: "v0.26.0" # <--- backend version + tag: "v0.34.2" # <--- backend version pullPolicy: Always ``` @@ -111,14 +111,14 @@ frontend: replicaCount: 2 image: repository: infisical/frontend - tag: "v0.26.0" # <--- frontend version + tag: "v0.34.2" # <--- frontend version pullPolicy: Always backend: replicaCount: 2 image: repository: infisical/backend - tag: "v0.26.0" # <--- backend version + tag: "v0.34.2" # <--- backend version pullPolicy: Always backendEnvironmentVariables: @@ -144,7 +144,7 @@ ingress: replicaCount: 4 image: repository: infisical/frontend - tag: "v0.1.3" + tag: "v0.34.2" # <--- frontend version pullPolicy: IfNotPresent kubeSecretRef: null service: @@ -160,7 +160,7 @@ ingress: replicaCount: 4 image: repository: infisical/backend - tag: "v0.1.3" + tag: "v0.34.2" # <--- backend version pullPolicy: IfNotPresent kubeSecretRef: null service: diff --git a/docs/spec.yaml b/docs/spec.yaml index 5cf129be3..40379087a 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -45,8 +45,8 @@ paths: items: $ref: '#/components/schemas/SecretVersion' description: Secret versions - '400': - description: Bad Request + security: + - apiKeyAuth: [] /api/v1/secret/{secretId}/secret-versions/rollback: post: summary: Roll back secret to a version. @@ -70,8 +70,8 @@ paths: type: object $ref: '#/components/schemas/Secret' description: Secret rolled back to - '400': - description: Bad Request + security: + - apiKeyAuth: [] requestBody: required: true content: @@ -94,8 +94,12 @@ paths: responses: '200': description: OK - '400': - description: Bad Request + /api/v1/users/me/ip: + get: + description: '' + responses: + '200': + description: OK /api/v1/workspace/{workspaceId}/secret-snapshots: get: summary: Return project secret snapshot ids @@ -132,8 +136,8 @@ paths: items: $ref: '#/components/schemas/SecretSnapshot' description: Project secret snapshots - '400': - description: Bad Request + security: + - apiKeyAuth: [] /api/v1/workspace/{workspaceId}/secret-snapshots/count: get: description: '' @@ -146,8 +150,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/workspace/{workspaceId}/secret-snapshots/rollback: post: summary: >- @@ -176,8 +178,8 @@ paths: items: $ref: '#/components/schemas/Secret' description: Secrets rolled back to - '400': - description: Bad Request + security: + - apiKeyAuth: [] requestBody: required: true content: @@ -245,6 +247,92 @@ paths: items: $ref: '#/components/schemas/Log' description: Project logs + security: + - apiKeyAuth: [] + /api/v1/workspace/{workspaceId}/audit-logs: + get: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/workspace/{workspaceId}/audit-logs/filters/actors: + get: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/workspace/{workspaceId}/trusted-ips: + get: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + post: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + '400': + description: Bad Request + /api/v1/workspace/{workspaceId}/trusted-ips/{trustedIpId}: + patch: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + - name: trustedIpId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + '400': + description: Bad Request + delete: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + - name: trustedIpId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK '400': description: Bad Request /api/v1/action/{actionId}: @@ -259,74 +347,309 @@ paths: responses: '200': description: OK + /api/v1/organizations/{organizationId}/plans/table: + get: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/organizations/{organizationId}/plan: + get: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/organizations/{organizationId}/session/trial: + post: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/organizations/{organizationId}/plan/billing: + get: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/organizations/{organizationId}/plan/table: + get: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/organizations/{organizationId}/billing-details: + get: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + patch: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/organizations/{organizationId}/billing-details/payment-methods: + get: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + post: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/organizations/{organizationId}/billing-details/payment-methods/{pmtMethodId}: + delete: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + - name: pmtMethodId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/organizations/{organizationId}/billing-details/tax-ids: + get: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + post: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/organizations/{organizationId}/billing-details/tax-ids/{taxId}: + delete: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + - name: taxId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/organizations/{organizationId}/invoices: + get: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/organizations/{organizationId}/licenses: + get: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/sso/redirect/google: + get: + description: '' + parameters: + - name: callback_port + in: query + schema: + type: string + responses: + default: + description: '' + /api/v1/sso/google: + get: + description: '' + responses: + default: + description: '' + /api/v1/sso/redirect/github: + get: + description: '' + parameters: + - name: callback_port + in: query + schema: + type: string + responses: + default: + description: '' + /api/v1/sso/github: + get: + description: '' + responses: + default: + description: '' + /api/v1/sso/redirect/saml2/{ssoIdentifier}: + get: + description: '' + parameters: + - name: ssoIdentifier + in: path + required: true + schema: + type: string + - name: callback_port + in: query + schema: + type: string + responses: + default: + description: '' + /api/v1/sso/saml2/{ssoIdentifier}: + post: + description: '' + parameters: + - name: ssoIdentifier + in: path + required: true + schema: + type: string + responses: + default: + description: '' + /api/v1/sso/config: + get: + description: '' + responses: + '200': + description: OK + post: + description: '' + responses: + '200': + description: OK + '400': + description: Bad Request + patch: + description: '' + responses: + '200': + description: OK + '400': + description: Bad Request + /api/v1/cloud-products/: + get: + description: '' + responses: + '200': + description: OK /api/v1/signup/email/signup: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request '403': description: Forbidden - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any /api/v1/signup/email/verify: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request '403': description: Forbidden - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any - code: - example: any /api/v1/auth/token: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request /api/v1/auth/login1: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any - clientPublicKey: - example: any /api/v1/auth/login2: post: description: '' @@ -340,16 +663,6 @@ paths: description: OK '400': description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any - clientProof: - example: any /api/v1/auth/logout: post: description: '' @@ -361,12 +674,15 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/auth/checkAuth: post: description: '' - parameters: [] + responses: + '200': + description: OK + /api/v1/auth/sessions: + delete: + description: '' responses: '200': description: OK @@ -382,8 +698,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/bot/{botId}/active: patch: description: '' @@ -398,77 +712,34 @@ paths: description: OK '400': description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - isActive: - example: any - botKey: - example: any /api/v1/user/: get: description: '' - parameters: [] responses: '200': description: OK /api/v1/user-action/: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - action: - example: any get: description: '' - parameters: - - name: action - in: query - schema: - type: string responses: '200': description: OK - '400': - description: Bad Request /api/v1/organization/: get: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - organizationName: - example: any /api/v1/organization/{organizationId}: get: description: '' @@ -481,8 +752,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/organization/{organizationId}/users: get: description: '' @@ -495,8 +764,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/organization/{organizationId}/my-workspaces: get: description: '' @@ -509,8 +776,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/organization/{organizationId}/name: patch: description: '' @@ -523,16 +788,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - name: - example: any /api/v1/organization/{organizationId}/incidentContactOrg: get: description: '' @@ -545,8 +800,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request post: description: '' parameters: @@ -558,16 +811,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any delete: description: '' parameters: @@ -579,16 +822,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any /api/v1/organization/{organizationId}/customer-portal-session: post: description: '' @@ -601,22 +834,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request - /api/v1/organization/{organizationId}/subscriptions: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request /api/v1/organization/{organizationId}/workspace-memberships: get: description: '' @@ -641,8 +858,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/workspace/{workspaceId}/users: get: description: '' @@ -655,35 +870,19 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/workspace/: get: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request post: description: '' - parameters: [] responses: '200': description: OK '400': description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceName: - example: any - organizationId: - example: any /api/v1/workspace/{workspaceId}: get: description: '' @@ -696,8 +895,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request delete: description: '' parameters: @@ -709,8 +906,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/workspace/{workspaceId}/name: post: description: '' @@ -723,16 +918,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - name: - example: any /api/v1/workspace/{workspaceId}/invite-signup: post: description: '' @@ -745,16 +930,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any /api/v1/workspace/{workspaceId}/integrations: get: description: '' @@ -767,8 +942,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/workspace/{workspaceId}/authorizations: get: description: '' @@ -781,8 +954,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/workspace/{workspaceId}/service-tokens: get: description: '' @@ -795,8 +966,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/membership-org/membershipOrg/{membershipOrgId}/change-role: post: description: '' @@ -819,8 +988,8 @@ paths: schema: type: string responses: - '400': - description: Bad Request + default: + description: '' /api/v1/membership/{workspaceId}/connect: get: description: '' @@ -833,8 +1002,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/membership/{membershipId}: delete: description: '' @@ -847,8 +1014,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/membership/{membershipId}/change-role: post: description: '' @@ -861,36 +1026,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - role: - example: any - /api/v1/membership/{membershipId}/deny-permissions: - post: - description: '' - parameters: - - name: membershipId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - permissions: - example: any /api/v1/key/{workspaceId}: post: description: '' @@ -903,16 +1038,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - key: - example: any /api/v1/key/{workspaceId}/latest: get: description: '' @@ -925,8 +1050,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/invite-org/signup: post: description: '' @@ -940,35 +1063,12 @@ paths: description: OK '400': description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - organizationId: - example: any - inviteeEmail: - example: any /api/v1/invite-org/verify: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any - code: - example: any /api/v1/secret/{workspaceId}: post: description: '' @@ -981,8 +1081,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request requestBody: content: application/json: @@ -1016,8 +1114,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/secret/{workspaceId}/service-token: get: description: '' @@ -1038,18 +1134,14 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v1/service-token/: get: description: '' - parameters: [] responses: '200': description: OK post: description: '' - parameters: [] responses: '200': description: OK @@ -1078,211 +1170,56 @@ paths: /api/v1/password/srp1: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - clientPublicKey: - example: any /api/v1/password/change-password: post: description: '' - parameters: [] responses: '200': description: OK '400': description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - clientProof: - example: any - protectedKey: - example: any - protectedKeyIV: - example: any - protectedKeyTag: - example: any - encryptedPrivateKey: - example: any - encryptedPrivateKeyIV: - example: any - encryptedPrivateKeyTag: - example: any - salt: - example: any - verifier: - example: any /api/v1/password/email/password-reset: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request - '403': - description: Forbidden - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any /api/v1/password/email/password-reset-verify: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request '403': description: Forbidden - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any - code: - example: any /api/v1/password/backup-private-key: get: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request post: description: '' - parameters: [] responses: '200': description: OK '400': description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - clientProof: - example: any - encryptedPrivateKey: - example: any - iv: - example: any - tag: - example: any - salt: - example: any - verifier: - example: any /api/v1/password/password-reset: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - protectedKey: - example: any - protectedKeyIV: - example: any - protectedKeyTag: - example: any - encryptedPrivateKey: - example: any - encryptedPrivateKeyIV: - example: any - encryptedPrivateKeyTag: - example: any - salt: - example: any - verifier: - example: any - /api/v1/stripe/webhook: - post: - description: '' - parameters: - - name: stripe-signature - in: header - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request /api/v1/integration/: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - integrationAuthId: - example: any - app: - example: any - appId: - example: any - isActive: - example: any - sourceEnvironment: - example: any - targetEnvironment: - example: any - targetEnvironmentId: - example: any - targetService: - example: any - targetServiceId: - example: any - owner: - example: any - path: - example: any - region: - example: any /api/v1/integration/{integrationId}: patch: description: '' @@ -1295,26 +1232,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - environment: - example: any - isActive: - example: any - app: - example: any - appId: - example: any - targetEnvironment: - example: any - owner: - example: any delete: description: '' parameters: @@ -1326,12 +1243,15 @@ paths: responses: '200': description: OK - '400': - description: Bad Request + /api/v1/integration/manual-sync: + post: + description: '' + responses: + '200': + description: OK /api/v1/integration-auth/integration-options: get: description: '' - parameters: [] responses: '200': description: OK @@ -1365,47 +1285,15 @@ paths: /api/v1/integration-auth/oauth-token: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - example: any - code: - example: any - integration: - example: any /api/v1/integration-auth/access-token: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - example: any - accessId: - example: any - accessToken: - example: any - integration: - example: any /api/v1/integration-auth/{integrationAuthId}/apps: get: description: '' @@ -1415,15 +1303,9 @@ paths: required: true schema: type: string - - name: teamId - in: query - schema: - type: string responses: '200': description: OK - '400': - description: Bad Request /api/v1/integration-auth/{integrationAuthId}/teams: get: description: '' @@ -1445,10 +1327,6 @@ paths: required: true schema: type: string - - name: appId - in: query - schema: - type: string responses: '200': description: OK @@ -1461,10 +1339,6 @@ paths: required: true schema: type: string - - name: appId - in: query - schema: - type: string responses: '200': description: OK @@ -1477,22 +1351,671 @@ paths: required: true schema: type: string - - name: appId + responses: + '200': + description: OK + /api/v1/integration-auth/{integrationAuthId}/bitbucket/workspaces: + get: + description: '' + parameters: + - name: integrationAuthId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/integration-auth/{integrationAuthId}/northflank/secret-groups: + get: + description: '' + parameters: + - name: integrationAuthId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/integration-auth/{integrationAuthId}/teamcity/build-configs: + get: + description: '' + parameters: + - name: integrationAuthId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/folders/: + post: + summary: Create a folder + description: Create a new folder in a specified workspace and environment + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + folder: + type: object + properties: + id: + type: string + example: someFolderId + name: + type: string + example: my_folder + description: Details of the created folder + '400': + description: >- + Bad Request. For example, 'Folder name cannot contain spaces. Only + underscore and dashes' + '401': + description: Unauthorized request. For example, 'Folder Permission Denied' + security: + - apiKeyAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + workspaceId: + type: string + description: ID of the workspace where the folder will be created + example: someWorkspaceId + environment: + type: string + description: Environment where the folder will reside + example: production + folderName: + type: string + description: Name of the folder to be created + example: my_folder + parentFolderId: + type: string + description: >- + ID of the parent folder under which this folder will be + created. If not specified, it will be created at the root + level. + example: someParentFolderId + required: + - workspaceId + - environment + - folderName + get: + summary: Retrieve folders based on specific conditions + description: >- + Fetches folders from the specified workspace and environment, optionally + providing either a parentFolderId or a parentFolderPath to narrow down + results + parameters: + - name: workspaceId + description: ID of the workspace from which the folders are to be fetched + required: true in: query schema: type: string + - name: environment + description: Environment where the folder is located + required: true + in: query + schema: + type: string + - name: parentFolderId + description: ID of the parent folder + required: false + in: query + schema: + type: string + - name: parentFolderPath + description: Path of the parent folder, like /folder1/folder2 + required: false + in: query + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + folders: + type: array + items: + type: object + properties: + id: + type: string + example: someFolderId + name: + type: string + example: someFolderName + description: List of folders + dir: + type: array + items: + type: object + properties: + name: + type: string + example: parentFolderName + id: + type: string + example: parentFolderId + description: List of directories + '400': + description: Bad Request. For instance, 'The folder doesn't exist' + '401': + description: Unauthorized request. For example, 'Folder Permission Denied' + security: + - apiKeyAuth: [] + /api/v1/folders/{folderId}: + patch: + summary: Update a folder by ID + description: >- + Update the name of a folder in a specified workspace and environment by + its ID + parameters: + - name: folderId + in: path + required: true + schema: + type: string + description: ID of the folder to be updated + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Successfully updated folder + folder: + type: object + properties: + name: + type: string + example: updated_folder_name + id: + type: string + example: someFolderId + description: Details of the updated folder + '400': + description: >- + Bad Request. Reasons can include 'The folder doesn't exist' or + 'Folder name cannot contain spaces. Only underscore and dashes' + '401': + description: Unauthorized request. For example, 'Folder Permission Denied' + security: + - apiKeyAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + workspaceId: + type: string + description: ID of the workspace where the folder is located + example: someWorkspaceId + environment: + type: string + description: Environment where the folder is located + example: production + name: + type: string + description: New name for the folder + example: updated_folder_name + required: + - workspaceId + - environment + - name + delete: + summary: Delete a folder by ID + description: >- + Delete the specified folder from a specified workspace and environment + using its ID + parameters: + - name: folderId + in: path + required: true + schema: + type: string + description: ID of the folder to be deleted + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: successfully deleted folders + folders: + type: array + items: + type: object + properties: + id: + type: string + example: someFolderId + name: + type: string + example: someFolderName + description: List of IDs and names of the deleted folders + '400': + description: Bad Request. Reasons can include 'The folder doesn't exist' + '401': + description: Unauthorized request. For example, 'Folder Permission Denied' + security: + - apiKeyAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + workspaceId: + type: string + description: ID of the workspace where the folder is located + example: someWorkspaceId + environment: + type: string + description: Environment where the folder is located + example: production + required: + - workspaceId + - environment + /api/v1/secret-scanning/create-installation-session/organization/{organizationId}: + post: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/secret-scanning/link-installation: + post: + description: '' + responses: + '200': + description: OK + /api/v1/secret-scanning/installation-status/organization/{organizationId}: + get: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/secret-scanning/organization/{organizationId}/risks: + get: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/secret-scanning/organization/{organizationId}/risks/{riskId}/status: + post: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + - name: riskId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/webhooks/: + post: + description: '' + responses: + '200': + description: OK + get: + description: '' + responses: + '200': + description: OK + /api/v1/webhooks/{webhookId}: + patch: + description: '' + parameters: + - name: webhookId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + delete: + description: '' + parameters: + - name: webhookId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/webhooks/{webhookId}/test: + post: + description: '' + parameters: + - name: webhookId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + '400': + description: Bad Request + /api/v1/secret-imports/: + post: + summary: Create secret import + description: Create a new secret import for a specified workspace and environment + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: successfully created secret import + description: Confirmation of secret import creation + '400': + description: Bad Request. For example, 'Secret import already exist' + '401': + description: Unauthorized request. For example, 'Folder Permission Denied' + '404': + description: Resource Not Found. For example, 'Failed to find folder' + requestBody: + content: + application/json: + schema: + type: object + properties: + workspaceId: + type: string + description: ID of the workspace where the secret import will be created + example: someWorkspaceId + environment: + type: string + description: Environment to import to + example: production + folderId: + type: string + description: Folder ID. Use root for the root folder. + example: my_folder + secretImport: + type: object + properties: + environment: + type: string + description: Import from environment + example: development + secretPath: + type: string + description: Import from secret path + example: /user/oauth + required: + - workspaceId + - environment + - folderName + get: + summary: Retrieve secret imports + description: >- + Fetches the secret imports based on the workspaceId, environment, and + folderId + parameters: + - name: workspaceId + in: query + description: ID of the workspace of secret imports to get + required: true + example: workspace12345 + schema: + type: string + - name: environment + in: query + description: Environment of secret imports to get + required: true + example: production + schema: + type: string + - name: folderId + in: query + description: 'ID of the folder containing the secret imports. Default: root' + required: false + example: folder12345 + schema: + type: string + responses: + '200': + description: Successfully retrieved secret import + content: + application/json: + schema: + type: object + properties: + secretImport: + type: object + description: Details of a secret import + '401': + description: Unauthorized access due to invalid token or scope + '403': + description: Forbidden access due to insufficient permissions + /api/v1/secret-imports/{id}: + put: + summary: Update a secret import + description: >- + Updates an existing secret import based on the provided ID and new + import details + parameters: + - name: id + in: path + required: true + schema: + type: string + description: ID of the secret import to be updated + example: import12345 + responses: + '200': + description: Successfully updated the secret import + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: successfully updated secret import + '400': + description: Bad Request - Import not found + '401': + description: Unauthorized access due to invalid token or scope + '403': + description: Forbidden access due to insufficient permissions + requestBody: + content: + application/json: + schema: + type: object + properties: + secretImports: + type: array + description: List of new secret imports + items: + type: object + properties: + environment: + type: string + description: Environment of the secret import + example: production + secretPath: + type: string + description: Path of the secret import + example: /path/to/secret + required: + - environment + - secretPath + required: + - secretImports + delete: + summary: Delete secret import + description: Delete secret import + parameters: + - name: id + in: path + required: true + schema: + type: string + description: ID of the secret import + example: 12345abcde + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: successfully delete secret import + description: Confirmation of secret import deletion + requestBody: + content: + application/json: + schema: + type: object + properties: + secretImportEnv: + type: string + description: Import from environment + example: someWorkspaceId + secretImportPath: + type: string + description: Import from secret path + example: production + required: + - id + - secretImportEnv + - secretImportPath + /api/v1/secret-imports/secrets: + get: + description: '' + responses: + '200': + description: OK + /api/v1/roles/: + post: + description: '' + responses: + '200': + description: OK + get: + description: '' + responses: + '200': + description: OK + /api/v1/roles/{id}: + patch: + description: '' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + delete: + description: '' + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/roles/organization/{orgId}/permissions: + get: + description: '' + parameters: + - name: orgId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/roles/workspace/{workspaceId}/permissions: + get: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string responses: '200': description: OK /api/v2/signup/complete-account/signup: post: description: '' - parameters: [] + parameters: + - name: user-agent + in: header + schema: + type: string responses: '200': description: OK - '400': - description: Bad Request '403': description: Forbidden requestBody: @@ -1530,12 +2053,14 @@ paths: /api/v2/signup/complete-account/invite: post: description: '' - parameters: [] + parameters: + - name: user-agent + in: header + schema: + type: string responses: '200': description: OK - '400': - description: Bad Request '403': description: Forbidden requestBody: @@ -1571,12 +2096,9 @@ paths: /api/v2/auth/login1: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request requestBody: content: application/json: @@ -1613,20 +2135,9 @@ paths: /api/v2/auth/mfa/send: post: description: '' - parameters: [] responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any /api/v2/auth/mfa/verify: post: description: '' @@ -1638,21 +2149,10 @@ paths: responses: '200': description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any - mfaToken: - example: any /api/v2/users/me: get: summary: Retrieve the current user on the request description: Retrieve the current user on the request - parameters: [] responses: '200': description: OK @@ -1665,30 +2165,32 @@ paths: type: object $ref: '#/components/schemas/CurrentUser' description: Current user on request - '400': - description: Bad Request + security: + - apiKeyAuth: [] /api/v2/users/me/mfa: patch: description: '' - parameters: [] + responses: + '200': + description: OK + /api/v2/users/me/name: + patch: + description: '' + responses: + '200': + description: OK + /api/v2/users/me/auth-methods: + put: + description: '' responses: '200': description: OK '400': description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - isMfaEnabled: - example: any /api/v2/users/me/organizations: get: summary: Return organizations that current user is part of description: Return organizations that current user is part of - parameters: [] responses: '200': description: OK @@ -1702,8 +2204,42 @@ paths: items: $ref: '#/components/schemas/Organization' description: Organizations that user is part of - '400': - description: Bad Request + security: + - apiKeyAuth: [] + /api/v2/users/me/api-keys: + get: + description: '' + responses: + '200': + description: OK + post: + description: '' + responses: + '200': + description: OK + /api/v2/users/me/api-keys/{apiKeyDataId}: + delete: + description: '' + parameters: + - name: apiKeyDataId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v2/users/me/sessions: + get: + description: '' + responses: + '200': + description: OK + delete: + description: '' + responses: + '200': + description: OK /api/v2/organizations/{organizationId}/memberships: get: summary: Return organization memberships @@ -1728,8 +2264,8 @@ paths: items: $ref: '#/components/schemas/MembershipOrg' description: Memberships of organization - '400': - description: Bad Request + security: + - apiKeyAuth: [] /api/v2/organizations/{organizationId}/memberships/{membershipId}: patch: summary: Update organization membership @@ -1758,8 +2294,8 @@ paths: membership: $ref: '#/components/schemas/MembershipOrg' description: Updated organization membership - '400': - description: Bad Request + security: + - apiKeyAuth: [] requestBody: required: true content: @@ -1799,8 +2335,8 @@ paths: membership: $ref: '#/components/schemas/MembershipOrg' description: Deleted organization membership - '400': - description: Bad Request + security: + - apiKeyAuth: [] /api/v2/organizations/{organizationId}/workspaces: get: summary: Return projects in organization that user is part of @@ -1825,6 +2361,8 @@ paths: items: $ref: '#/components/schemas/Project' description: Projects of organization + security: + - apiKeyAuth: [] /api/v2/organizations/{organizationId}/service-accounts: get: description: '' @@ -1839,18 +2377,43 @@ paths: description: OK /api/v2/workspace/{workspaceId}/environments: post: - description: '' + summary: Create environment + description: Create environment parameters: - name: workspaceId in: path required: true schema: type: string + description: ID of project responses: '200': description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Successfully created new environment + workspace: + type: string + example: someWorkspaceId + environment: + type: object + properties: + name: + type: string + example: someEnvironmentName + slug: + type: string + example: someEnvironmentSlug + description: Response after creating a new environment '400': description: Bad Request + security: + - apiKeyAuth: [] requestBody: content: application/json: @@ -1858,22 +2421,50 @@ paths: type: object properties: environmentName: - example: any + type: string + description: Name of the environment + example: development environmentSlug: - example: any + type: string + description: Slug of the environment + example: dev-environment + required: + - environmentName + - environmentSlug put: - description: '' + summary: Rename workspace environment + description: Rename a specific environment within a workspace parameters: - name: workspaceId in: path required: true schema: type: string + description: ID of the workspace responses: '200': description: OK - '400': - description: Bad Request + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Successfully update environment + workspace: + type: string + example: someWorkspaceId + environment: + type: object + properties: + name: + type: string + example: Staging-Renamed + slug: + type: string + example: staging-renamed + description: Details of the renamed environment requestBody: content: application/json: @@ -1881,12 +2472,22 @@ paths: type: object properties: environmentName: - example: any + type: string + description: New name for the environment + example: Staging-Renamed environmentSlug: - example: any + type: string + description: New slug for the environment + example: staging-renamed oldEnvironmentSlug: - example: any - delete: + type: string + description: Current slug of the environment to rename + example: staging-old + required: + - environmentName + - environmentSlug + - oldEnvironmentSlug + patch: description: '' parameters: - name: workspaceId @@ -1897,8 +2498,36 @@ paths: responses: '200': description: OK - '400': - description: Bad Request + delete: + summary: Delete workspace environment + description: Delete a specific environment from a workspace + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + description: ID of the workspace + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Successfully deleted environment + workspace: + type: string + example: someWorkspaceId + environment: + type: string + example: dev-environment + description: Response after deleting an environment from a workspace + security: + - apiKeyAuth: [] requestBody: content: application/json: @@ -1906,18 +2535,53 @@ paths: type: object properties: environmentSlug: - example: any + type: string + description: Slug of the environment to delete + example: dev-environment + required: + - environmentSlug get: - description: '' + summary: Get all accessible environments of a workspace + description: >- + Fetch all environments that the user has access to in a specified + workspace parameters: - name: workspaceId in: path required: true schema: type: string + description: ID of the workspace responses: '200': description: OK + content: + application/json: + schema: + type: object + properties: + accessibleEnvironments: + type: array + items: + type: object + properties: + name: + type: string + example: Development + slug: + type: string + example: development + isWriteDenied: + type: boolean + example: false + isReadDenied: + type: boolean + example: false + description: >- + List of environments the user has access to in the specified + workspace + security: + - apiKeyAuth: [] /api/v2/workspace/{workspaceId}/tags: get: description: '' @@ -1941,18 +2605,6 @@ paths: responses: '200': description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - name: - example: any - tagColor: - example: any - slug: - example: any /api/v2/workspace/tags/{tagId}: delete: description: '' @@ -1977,8 +2629,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request requestBody: content: application/json: @@ -2012,8 +2662,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v2/workspace/{workspaceId}/encrypted-key: get: summary: Return encrypted project key @@ -2035,8 +2683,8 @@ paths: items: $ref: '#/components/schemas/ProjectKey' description: Encrypted project key for the given project - '400': - description: Bad Request + security: + - apiKeyAuth: [] /api/v2/workspace/{workspaceId}/service-token-data: get: description: '' @@ -2049,8 +2697,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request /api/v2/workspace/{workspaceId}/memberships: get: summary: Return project memberships @@ -2075,8 +2721,8 @@ paths: items: $ref: '#/components/schemas/Membership' description: Memberships of project - '400': - description: Bad Request + security: + - apiKeyAuth: [] /api/v2/workspace/{workspaceId}/memberships/{membershipId}: patch: summary: Update project membership @@ -2105,8 +2751,8 @@ paths: membership: $ref: '#/components/schemas/Membership' description: Updated membership - '400': - description: Bad Request + security: + - apiKeyAuth: [] requestBody: required: true content: @@ -2144,8 +2790,8 @@ paths: membership: $ref: '#/components/schemas/Membership' description: Deleted membership - '400': - description: Bad Request + security: + - apiKeyAuth: [] /api/v2/workspace/{workspaceId}/auto-capitalization: patch: description: '' @@ -2158,16 +2804,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - autoCapitalization: - example: any /api/v2/secret/batch-create/workspace/{workspaceId}/environment/{environment}: post: description: '' @@ -2343,18 +2979,6 @@ paths: responses: '200': description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - example: any - environment: - example: any - requests: - example: any /api/v2/secrets/: post: summary: Create new secret(s) @@ -2379,6 +3003,8 @@ paths: description: >- Newly-created secrets for the given project and environment + security: + - apiKeyAuth: [] requestBody: required: true content: @@ -2415,10 +3041,6 @@ paths: in: header schema: type: string - - name: content - in: query - schema: - type: string responses: '200': description: OK @@ -2432,10 +3054,11 @@ paths: items: $ref: '#/components/schemas/Secret' description: Secrets for the given project and environment + security: + - apiKeyAuth: [] patch: summary: Update secret(s) description: Update secret(s) - parameters: [] responses: '200': description: OK @@ -2449,6 +3072,8 @@ paths: items: $ref: '#/components/schemas/Secret' description: Updated secrets + security: + - apiKeyAuth: [] requestBody: required: true content: @@ -2480,6 +3105,8 @@ paths: items: $ref: '#/components/schemas/Secret' description: Deleted secrets + security: + - apiKeyAuth: [] requestBody: required: true content: @@ -2494,7 +3121,6 @@ paths: get: summary: Return Infisical Token data description: Return Infisical Token data - parameters: [] responses: '200': description: OK @@ -2511,32 +3137,9 @@ paths: - bearerAuth: [] post: description: '' - parameters: [] responses: '200': description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - name: - example: any - workspaceId: - example: any - environment: - example: any - encryptedKey: - example: any - iv: - example: any - tag: - example: any - expiresIn: - example: any - permissions: - example: any /api/v2/service-token/{serviceTokenDataId}: delete: description: '' @@ -2549,48 +3152,58 @@ paths: responses: '200': description: OK - /api/v2/service-accounts/me: - get: - description: '' - parameters: [] - responses: - '200': - description: OK - /api/v2/service-accounts/{serviceAccountId}: - get: - description: '' - parameters: - - name: serviceAccountId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - delete: - description: '' - parameters: - - name: serviceAccountId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v2/service-accounts/: + /api/v3/auth/login1: post: description: '' - parameters: [] responses: '200': description: OK - /api/v2/service-accounts/{serviceAccountId}/name: + /api/v3/auth/login2: + post: + description: '' + parameters: + - name: user-agent + in: header + schema: + type: string + responses: + '200': + description: OK + '400': + description: Bad Request + /api/v3/secrets/raw: + get: + description: '' + responses: + '200': + description: OK + /api/v3/secrets/raw/{secretName}: + get: + description: '' + parameters: + - name: secretName + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + post: + description: '' + parameters: + - name: secretName + in: path + required: true + schema: + type: string + responses: + '200': + description: OK patch: description: '' parameters: - - name: serviceAccountId + - name: secretName in: path required: true schema: @@ -2598,67 +3211,10 @@ paths: responses: '200': description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - name: - example: any - /api/v2/service-accounts/{serviceAccountId}/permissions/workspace: - get: - description: '' - parameters: - - name: serviceAccountId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - post: - description: '' - parameters: - - name: serviceAccountId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - environment: - example: any - workspaceId: - example: any - read: - example: any - write: - example: any - encryptedKey: - example: any - nonce: - example: any - /api/v2/service-accounts/{serviceAccountId}/permissions/workspace/{serviceAccountWorkspacePermissionId}: delete: description: '' parameters: - - name: serviceAccountId - in: path - required: true - schema: - type: string - - name: serviceAccountWorkspacePermissionId + - name: secretName in: path required: true schema: @@ -2666,75 +3222,9 @@ paths: responses: '200': description: OK - /api/v2/service-accounts/{serviceAccountId}/keys: - get: - description: '' - parameters: - - name: serviceAccountId - in: path - required: true - schema: - type: string - - name: workspaceId - in: query - schema: - type: string - responses: - '200': - description: OK - /api/v2/api-key/: - get: - description: '' - parameters: [] - responses: - '200': - description: OK - '400': - description: Bad Request - post: - description: '' - parameters: [] - responses: - '200': - description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - name: - example: any - expiresIn: - example: any - /api/v2/api-key/{apiKeyDataId}: - delete: - description: '' - parameters: - - name: apiKeyDataId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request /api/v3/secrets/: get: description: '' - parameters: - - name: workspaceId - in: query - schema: - type: string - - name: environment - in: query - schema: - type: string responses: '200': description: OK @@ -2750,36 +3240,6 @@ paths: responses: '200': description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - example: any - environment: - example: any - type: - example: any - secretKeyCiphertext: - example: any - secretKeyIV: - example: any - secretKeyTag: - example: any - secretValueCiphertext: - example: any - secretValueIV: - example: any - secretValueTag: - example: any - secretCommentCiphertext: - example: any - secretCommentIV: - example: any - secretCommentTag: - example: any get: description: '' parameters: @@ -2788,18 +3248,6 @@ paths: required: true schema: type: string - - name: workspaceId - in: query - schema: - type: string - - name: environment - in: query - schema: - type: string - - name: type - in: query - schema: - type: string responses: '200': description: OK @@ -2814,24 +3262,6 @@ paths: responses: '200': description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - example: any - environment: - example: any - type: - example: any - secretValueCiphertext: - example: any - secretValueIV: - example: any - secretValueTag: - example: any delete: description: '' parameters: @@ -2843,18 +3273,6 @@ paths: responses: '200': description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - example: any - environment: - example: any - type: - example: any /api/v3/workspaces/{workspaceId}/secrets/blind-index-status: get: description: '' @@ -2891,18 +3309,28 @@ paths: responses: '200': description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - secretsToUpdate: - example: any + /api/v3/signup/complete-account/signup: + post: + description: '' + parameters: + - name: authorization + in: header + schema: + type: string + - name: user-agent + in: header + schema: + type: string + responses: + '200': + description: OK + '400': + description: Bad Request + '403': + description: Forbidden /api/status: get: description: '' - parameters: [] responses: '200': description: OK diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 990b03377..28ea1859e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5,6 +5,8 @@ "packages": { "": { "dependencies": { + "@casl/ability": "^6.5.0", + "@casl/react": "^3.1.0", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^6.0.1", "@dnd-kit/sortable": "^7.0.2", @@ -19,7 +21,7 @@ "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^2.9.10", "@octokit/rest": "^19.0.7", - "@radix-ui/react-accordion": "^1.1.0", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.2", "@radix-ui/react-checkbox": "^1.0.1", "@radix-ui/react-dialog": "^1.0.2", @@ -39,6 +41,7 @@ "@stripe/stripe-js": "^1.46.0", "@tanstack/react-query": "^4.23.0", "@types/argon2-browser": "^1.18.1", + "@ucast/mongo2js": "^1.3.4", "add": "^2.0.6", "argon2-browser": "^1.18.0", "axios": "^0.27.2", @@ -63,12 +66,12 @@ "markdown-it": "^13.0.1", "next": "^12.3.4", "nprogress": "^0.2.0", + "picomatch": "^2.3.1", "posthog-js": "^1.58.0", "query-string": "^7.1.3", "react": "^17.0.2", "react-beautiful-dnd": "^13.1.1", "react-code-input": "^3.10.1", - "react-contenteditable": "^3.3.7", "react-day-picker": "^8.8.0", "react-dom": "^17.0.2", "react-grid-layout": "^1.3.4", @@ -88,7 +91,8 @@ "uuid": "^8.3.2", "uuidv4": "^6.2.13", "yaml": "^2.2.2", - "yup": "^0.32.11" + "yup": "^0.32.11", + "zod": "^3.22.0" }, "devDependencies": { "@storybook/addon-essentials": "^7.0.23", @@ -103,6 +107,7 @@ "@tailwindcss/typography": "^0.5.4", "@types/jsrp": "^0.2.4", "@types/node": "^18.11.9", + "@types/picomatch": "^2.3.0", "@types/react": "^18.0.26", "@types/sanitize-html": "^2.9.0", "@typescript-eslint/eslint-plugin": "^5.48.1", @@ -2458,6 +2463,26 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@casl/ability": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz", + "integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, + "node_modules/@casl/react": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@casl/react/-/react-3.1.0.tgz", + "integrity": "sha512-p4Xmex1Slxz/G0cBtZik+xyOkeOynBUe0UrMFTai6aYkYOb4NyUy3w+9rtnedjcuKijiow2HKJQjnSurLxdc/g==", + "peerDependencies": { + "@casl/ability": "^3.0.0 || ^4.0.0 || ^5.1.0 || ^6.0.0", + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -4235,24 +4260,230 @@ } }, "node_modules/@radix-ui/react-accordion": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.1.tgz", - "integrity": "sha512-TQtyyRubYe8DD6DYCovNLTjd2D+TFrNCpr99T5M3cYUbR7BsRxWsxfInjbQ1nHsdy2uPTcnJS5npyXPVfP0piw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz", + "integrity": "sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==", "dependencies": { "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-collapsible": "1.0.2", - "@radix-ui/react-collection": "1.0.2", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-direction": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-primitive": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.0" + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collapsible": "1.0.3", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" }, "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collection": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", + "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-direction": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", + "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@radix-ui/react-alert-dialog": { @@ -4307,23 +4538,210 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.2.tgz", - "integrity": "sha512-QNiDT6Au8jUU0K1WV+HEd4loH7C5CKQjeXxskwqyiyAkyCmW7qlQM5vSSJCIoQC+OVPyhgafSmGudRP8Qm1/gA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", + "integrity": "sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==", "dependencies": { "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-presence": "1.0.0", - "@radix-ui/react-primitive": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.0", - "@radix-ui/react-use-layout-effect": "1.0.0" + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" }, "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@radix-ui/react-collection": { @@ -7975,6 +8393,12 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "node_modules/@types/picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-O397rnSS9iQI4OirieAtsDqvCj4+3eY1J+EPdNTKuHuRWIfUoGyzX294o8C4KJYaLqgSrd2o60c5EqCU8Zv02g==", + "dev": true + }, "node_modules/@types/pretty-hrtime": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz", @@ -8457,6 +8881,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "node_modules/@ucast/js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz", + "integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", @@ -12884,7 +13339,8 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "node_modules/fast-diff": { "version": "1.3.0", @@ -19138,18 +19594,6 @@ "react-dom": ">=16.8.0" } }, - "node_modules/react-contenteditable": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz", - "integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "prop-types": "^15.7.1" - }, - "peerDependencies": { - "react": ">=16.3" - } - }, "node_modules/react-day-picker": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.8.0.tgz", @@ -23188,6 +23632,14 @@ "engines": { "node": ">=10" } + }, + "node_modules/zod": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.0.tgz", + "integrity": "sha512-y5KZY/ssf5n7hCGDGGtcJO/EBJEm5Pa+QQvFBeyMOtnFYOSflalxIFFvdaYevPhePcmcKC4aTbFkCcXN7D0O8Q==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -24852,6 +25304,20 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@casl/ability": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz", + "integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==", + "requires": { + "@ucast/mongo2js": "^1.3.0" + } + }, + "@casl/react": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@casl/react/-/react-3.1.0.tgz", + "integrity": "sha512-p4Xmex1Slxz/G0cBtZik+xyOkeOynBUe0UrMFTai6aYkYOb4NyUy3w+9rtnedjcuKijiow2HKJQjnSurLxdc/g==", + "requires": {} + }, "@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -26029,20 +26495,118 @@ } }, "@radix-ui/react-accordion": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.1.tgz", - "integrity": "sha512-TQtyyRubYe8DD6DYCovNLTjd2D+TFrNCpr99T5M3cYUbR7BsRxWsxfInjbQ1nHsdy2uPTcnJS5npyXPVfP0piw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz", + "integrity": "sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==", "requires": { "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-collapsible": "1.0.2", - "@radix-ui/react-collection": "1.0.2", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-direction": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-primitive": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.0" + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collapsible": "1.0.3", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "dependencies": { + "@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-collection": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", + "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + } + }, + "@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-direction": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", + "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + } + }, + "@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + } + }, + "@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + } + }, + "@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + } } }, "@radix-ui/react-alert-dialog": { @@ -26085,19 +26649,107 @@ } }, "@radix-ui/react-collapsible": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.2.tgz", - "integrity": "sha512-QNiDT6Au8jUU0K1WV+HEd4loH7C5CKQjeXxskwqyiyAkyCmW7qlQM5vSSJCIoQC+OVPyhgafSmGudRP8Qm1/gA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", + "integrity": "sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==", "requires": { "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-presence": "1.0.0", - "@radix-ui/react-primitive": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.0", - "@radix-ui/react-use-layout-effect": "1.0.0" + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "dependencies": { + "@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + } + }, + "@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + } + }, + "@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + } + }, + "@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + } } }, "@radix-ui/react-collection": { @@ -28759,6 +29411,12 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "@types/picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-O397rnSS9iQI4OirieAtsDqvCj4+3eY1J+EPdNTKuHuRWIfUoGyzX294o8C4KJYaLqgSrd2o60c5EqCU8Zv02g==", + "dev": true + }, "@types/pretty-hrtime": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz", @@ -29108,6 +29766,37 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "@ucast/js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz", + "integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==", + "requires": { + "@ucast/core": "^1.0.0" + } + }, + "@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "requires": { + "@ucast/core": "^1.4.1" + } + }, + "@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "requires": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "@webassemblyjs/ast": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", @@ -32571,7 +33260,8 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "fast-diff": { "version": "1.3.0", @@ -37106,15 +37796,6 @@ "dev": true, "requires": {} }, - "react-contenteditable": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz", - "integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==", - "requires": { - "fast-deep-equal": "^3.1.3", - "prop-types": "^15.7.1" - } - }, "react-day-picker": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.8.0.tgz", @@ -40082,6 +40763,11 @@ "property-expr": "^2.0.4", "toposort": "^2.0.2" } + }, + "zod": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.0.tgz", + "integrity": "sha512-y5KZY/ssf5n7hCGDGGtcJO/EBJEm5Pa+QQvFBeyMOtnFYOSflalxIFFvdaYevPhePcmcKC4aTbFkCcXN7D0O8Q==" } } } diff --git a/frontend/package.json b/frontend/package.json index fab8b7033..42cd32385 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,8 @@ "build-storybook": "storybook build" }, "dependencies": { + "@casl/ability": "^6.5.0", + "@casl/react": "^3.1.0", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^6.0.1", "@dnd-kit/sortable": "^7.0.2", @@ -27,7 +29,7 @@ "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^2.9.10", "@octokit/rest": "^19.0.7", - "@radix-ui/react-accordion": "^1.1.0", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.2", "@radix-ui/react-checkbox": "^1.0.1", "@radix-ui/react-dialog": "^1.0.2", @@ -47,6 +49,7 @@ "@stripe/stripe-js": "^1.46.0", "@tanstack/react-query": "^4.23.0", "@types/argon2-browser": "^1.18.1", + "@ucast/mongo2js": "^1.3.4", "add": "^2.0.6", "argon2-browser": "^1.18.0", "axios": "^0.27.2", @@ -71,12 +74,12 @@ "markdown-it": "^13.0.1", "next": "^12.3.4", "nprogress": "^0.2.0", + "picomatch": "^2.3.1", "posthog-js": "^1.58.0", "query-string": "^7.1.3", "react": "^17.0.2", "react-beautiful-dnd": "^13.1.1", "react-code-input": "^3.10.1", - "react-contenteditable": "^3.3.7", "react-day-picker": "^8.8.0", "react-dom": "^17.0.2", "react-grid-layout": "^1.3.4", @@ -96,7 +99,8 @@ "uuid": "^8.3.2", "uuidv4": "^6.2.13", "yaml": "^2.2.2", - "yup": "^0.32.11" + "yup": "^0.32.11", + "zod": "^3.22.0" }, "devDependencies": { "@storybook/addon-essentials": "^7.0.23", @@ -111,6 +115,7 @@ "@tailwindcss/typography": "^0.5.4", "@types/jsrp": "^0.2.4", "@types/node": "^18.11.9", + "@types/picomatch": "^2.3.0", "@types/react": "^18.0.26", "@types/sanitize-html": "^2.9.0", "@typescript-eslint/eslint-plugin": "^5.48.1", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 11adf2e7a..9e1bfb99d 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -5,7 +5,7 @@ interface Mapping { const integrationSlugNameMapping: Mapping = { "azure-key-vault": "Azure Key Vault", "aws-parameter-store": "AWS Parameter Store", - "aws-secret-manager": "AWS Secret Manager", + "aws-secret-manager": "AWS Secrets Manager", "heroku": "Heroku", "vercel": "Vercel", "netlify": "Netlify", @@ -19,6 +19,7 @@ const integrationSlugNameMapping: Mapping = { "travisci": "TravisCI", "supabase": "Supabase", "checkly": "Checkly", + "qovery": "Qovery", "terraform-cloud": "Terraform Cloud", "teamcity": "TeamCity", "hashicorp-vault": "Vault", diff --git a/frontend/public/images/integrations/Qovery.png b/frontend/public/images/integrations/Qovery.png new file mode 100644 index 000000000..17343046b Binary files /dev/null and b/frontend/public/images/integrations/Qovery.png differ diff --git a/frontend/src/components/AddTagPopoverContent/AddTagPopoverContent.tsx b/frontend/src/components/AddTagPopoverContent/AddTagPopoverContent.tsx index a8b965269..77ca8c24f 100644 --- a/frontend/src/components/AddTagPopoverContent/AddTagPopoverContent.tsx +++ b/frontend/src/components/AddTagPopoverContent/AddTagPopoverContent.tsx @@ -1,78 +1,92 @@ - import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Checkbox, PopoverContent } from "@app/components/v2"; +import { Button, Checkbox, PopoverContent } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { WsTag } from "../../hooks/api/tags/types"; +import { ProjectPermissionCan } from "../permissions"; interface Props { - wsTags: WsTag[] | undefined; - secKey: string; - selectedTagIds: Record; - handleSelectTag: (wsTag: WsTag) => void; - handleTagOnMouseEnter: (wsTag: WsTag) => void; - handleTagOnMouseLeave: () => void; - checkIfTagIsVisible: (wsTag: WsTag) => boolean; - handleOnCreateTagOpen: () => void + wsTags: WsTag[] | undefined; + secKey: string; + selectedTagIds: Record; + handleSelectTag: (wsTag: WsTag) => void; + handleTagOnMouseEnter: (wsTag: WsTag) => void; + handleTagOnMouseLeave: () => void; + checkIfTagIsVisible: (wsTag: WsTag) => boolean; + handleOnCreateTagOpen: () => void; } const AddTagPopoverContent = ({ - wsTags, - secKey, - selectedTagIds, - handleSelectTag, - handleTagOnMouseEnter, - handleTagOnMouseLeave, - checkIfTagIsVisible, - handleOnCreateTagOpen + wsTags, + secKey, + selectedTagIds, + handleSelectTag, + handleTagOnMouseEnter, + handleTagOnMouseLeave, + checkIfTagIsVisible, + handleOnCreateTagOpen }: Props) => { - return ( - -
- Add tags to {secKey || "this secret"} + return ( + +
+ Add tags to {secKey || "this secret"} +
+
+
+ {wsTags?.map((wsTag: WsTag) => ( +
handleSelectTag(wsTag)} + onMouseEnter={() => handleTagOnMouseEnter(wsTag)} + onMouseLeave={() => handleTagOnMouseLeave()} + tabIndex={0} + role="button" + onKeyDown={() => {}} + > + {(checkIfTagIsVisible(wsTag) || selectedTagIds?.[wsTag.slug]) && ( + + )} +
+
+ {" "} +
+ {wsTag.slug}
-
-
- {wsTags?.map((wsTag: WsTag) => ( -
handleSelectTag(wsTag)} - onMouseEnter={() => handleTagOnMouseEnter(wsTag)} - onMouseLeave={() => handleTagOnMouseLeave()} - tabIndex={0} role="button" - onKeyDown={() => { }}> - { +
+ ))} + + {(isAllowed) => ( + + )} + +
+ + ); +}; - (checkIfTagIsVisible(wsTag) || selectedTagIds?.[wsTag.slug]) && - } -
-
- - {wsTag.slug} - -
-
- ))} -
handleOnCreateTagOpen()} - tabIndex={0} role="button" - onKeyDown={() => { }}> - - Add new tag -
-
- - ) -} - -export default AddTagPopoverContent \ No newline at end of file +export default AddTagPopoverContent; diff --git a/frontend/src/components/basic/popups/GlobPatternExamples.tsx b/frontend/src/components/basic/popups/GlobPatternExamples.tsx new file mode 100644 index 000000000..7939804f0 --- /dev/null +++ b/frontend/src/components/basic/popups/GlobPatternExamples.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { faInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Tooltip } from "@app/components/v2/Tooltip"; + +const GlobPatternExamples = () => { + const [showTip, setShowTip] = useState(false); + + return ( + +

Here are some examples of glob patterns:

+
+
  • + / - Matches all files and directories in the + current directory +
  • +
  • + **/* - Matches all files and directories in the + current directory and its subdirectories +
  • +
  • + {"/{dir1,dir2}"} - Matches all files and + directories in dir1 and dir2 +
  • +
    +
    + } + position="right" + className="text-xs" + > +
    setShowTip(true)} + > + +
    + + ); +}; + +export default GlobPatternExamples; diff --git a/frontend/src/components/permissions/OrgPermissionCan.tsx b/frontend/src/components/permissions/OrgPermissionCan.tsx new file mode 100644 index 000000000..4366110a9 --- /dev/null +++ b/frontend/src/components/permissions/OrgPermissionCan.tsx @@ -0,0 +1,49 @@ +import { FunctionComponent, ReactNode } from "react"; +import { BoundCanProps, Can } from "@casl/react"; + +import { TOrgPermission, useOrgPermission } from "@app/context/OrgPermissionContext"; + +import { Tooltip } from "../v2"; + +type Props = { + label?: ReactNode; + // this prop is used when there exist already a tooltip as helper text for users + // so when permission is allowed same tooltip will be reused to show helpertext + renderTooltip?: boolean; + allowedLabel?: string; +} & BoundCanProps; + +export const OrgPermissionCan: FunctionComponent = ({ + label = "Access restricted", + children, + passThrough = true, + renderTooltip, + allowedLabel, + ...props +}) => { + const permission = useOrgPermission(); + + return ( + + {(isAllowed, ability) => { + // akhilmhdh: This is set as type due to error in casl react type. + const finalChild = + typeof children === "function" + ? children(isAllowed, ability as TOrgPermission) + : children; + + if (!isAllowed && passThrough) { + return {finalChild}; + } + + if (isAllowed && renderTooltip) { + return {finalChild}; + } + + if (!isAllowed) return null; + + return finalChild; + }} + + ); +}; diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx new file mode 100644 index 000000000..40e17577f --- /dev/null +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -0,0 +1,40 @@ +import { ReactNode } from "react"; +import { faLock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +type Props = { + containerClassName?: string; + className?: string; + children?: ReactNode; +}; + +export const PermissionDeniedBanner = ({ containerClassName, className, children }: Props) => { + return ( +
    +
    +
    + +
    +
    +
    Access Restricted
    + {children || ( +
    + Your role has limited permissions, please
    contact your administrator to gain access +
    + )} +
    +
    +
    + ); +}; diff --git a/frontend/src/components/permissions/ProjectPermissionCan.tsx b/frontend/src/components/permissions/ProjectPermissionCan.tsx new file mode 100644 index 000000000..aa64c33d7 --- /dev/null +++ b/frontend/src/components/permissions/ProjectPermissionCan.tsx @@ -0,0 +1,52 @@ +import { FunctionComponent, ReactNode } from "react"; +import { BoundCanProps, Can } from "@casl/react"; + +import { TProjectPermission, useProjectPermission } from "@app/context/ProjectPermissionContext"; + +import { Tooltip } from "../v2"; + +type Props = { + label?: ReactNode; + // this prop is used when there exist already a tooltip as helper text for users + // so when permission is allowed same tooltip will be reused to show helpertext + renderTooltip?: boolean; + allowedLabel?: string; + // BUG(akhilmhdh): As a workaround for now i put any but this should be TProjectPermission + // For some reason when i put TProjectPermission in a wrapper component it just wont work causes a weird ts error + // tried a lot combinations + // REF: https://github.com/stalniy/casl/blob/ac081a34f56366a7eaaed05d21689d27041ef005/packages/casl-react/src/factory.ts#L15 +} & BoundCanProps; + +export const ProjectPermissionCan: FunctionComponent = ({ + label = "Access restricted", + children, + passThrough = true, + renderTooltip, + allowedLabel, + ...props +}) => { + const permission = useProjectPermission(); + return ( + + {(isAllowed, ability) => { + // akhilmhdh: This is set as type due to error in casl react type. + const finalChild = + typeof children === "function" + ? children(isAllowed, ability as TProjectPermission) + : children; + + if (!isAllowed && passThrough) { + return {finalChild}; + } + + if (isAllowed && renderTooltip) { + return {finalChild}; + } + + if (!isAllowed) return null; + + return finalChild; + }} + + ); +}; diff --git a/frontend/src/components/permissions/index.tsx b/frontend/src/components/permissions/index.tsx new file mode 100644 index 000000000..8d523c311 --- /dev/null +++ b/frontend/src/components/permissions/index.tsx @@ -0,0 +1,3 @@ +export { OrgPermissionCan } from "./OrgPermissionCan"; +export { PermissionDeniedBanner } from "./PermissionDeniedBanner"; +export { ProjectPermissionCan } from "./ProjectPermissionCan"; diff --git a/frontend/src/components/utilities/parseDotEnv.ts b/frontend/src/components/utilities/parseDotEnv.ts index 683670b33..bec3a506b 100644 --- a/frontend/src/components/utilities/parseDotEnv.ts +++ b/frontend/src/components/utilities/parseDotEnv.ts @@ -1,5 +1,5 @@ const LINE = - /(?:^|^)\s*(?:export\s+)?([\w.-:]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm; + /(?:^|^)\s*(?:export\s+)?([\w.:-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm; /** * Return text that is the buffer parsed diff --git a/frontend/src/components/v2/Accordion/Accordion.stories.tsx b/frontend/src/components/v2/Accordion/Accordion.stories.tsx new file mode 100644 index 000000000..bd17c8743 --- /dev/null +++ b/frontend/src/components/v2/Accordion/Accordion.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./Accordion"; + +const meta: Meta = { + title: "Components/Accordion", + component: Accordion, + tags: ["v2"], + argTypes: {} +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + render: (args) => ( +
    + + + Section 1 + Description of Section 1 + + + Section 2 + Description of Section 2 + + +
    + ) +}; diff --git a/frontend/src/components/v2/Accordion/Accordion.tsx b/frontend/src/components/v2/Accordion/Accordion.tsx new file mode 100644 index 000000000..9188b873e --- /dev/null +++ b/frontend/src/components/v2/Accordion/Accordion.tsx @@ -0,0 +1,74 @@ +import { forwardRef } from "react"; +import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { twMerge } from "tailwind-merge"; + +export const AccordionItem = forwardRef( + ({ children, className, ...props }, forwardedRef) => ( + + {children} + + ) +); +AccordionItem.displayName = "AccordionItem"; + +export const AccordionTrigger = forwardRef< + HTMLButtonElement, + AccordionPrimitive.AccordionTriggerProps +>(({ children, className, ...props }, forwardedRef) => ( + + + {children} + + + +)); + +AccordionTrigger.displayName = "AccordionTrigger"; + +export const AccordionContent = forwardRef< + HTMLDivElement, + AccordionPrimitive.AccordionContentProps +>(({ children, className, ...props }, forwardedRef) => ( + +
    {children}
    +
    +)); + +AccordionContent.displayName = "AccordionContent"; + +// ref: https://www.radix-ui.com/primitives/docs/components/accordion#root +export const Accordion = ({ + children, + ...props +}: AccordionPrimitive.AccordionSingleProps | AccordionPrimitive.AccordionMultipleProps) => ( + + {children} + +); diff --git a/frontend/src/components/v2/Accordion/index.tsx b/frontend/src/components/v2/Accordion/index.tsx new file mode 100644 index 000000000..831904d54 --- /dev/null +++ b/frontend/src/components/v2/Accordion/index.tsx @@ -0,0 +1 @@ +export { Accordion, AccordionContent, AccordionItem,AccordionTrigger } from "./Accordion"; diff --git a/frontend/src/components/v2/Checkbox/Checkbox.tsx b/frontend/src/components/v2/Checkbox/Checkbox.tsx index 64ec0a54a..8c79a5ff2 100644 --- a/frontend/src/components/v2/Checkbox/Checkbox.tsx +++ b/frontend/src/components/v2/Checkbox/Checkbox.tsx @@ -45,7 +45,7 @@ export const Checkbox = ({ -