diff --git a/.env.example b/.env.example index 79fca5686..df316753e 100644 --- a/.env.example +++ b/.env.example @@ -31,12 +31,12 @@ MONGO_PASSWORD=example SITE_URL=http://localhost:8080 # Mail/SMTP -SMTP_HOST= # required -SMTP_USERNAME= # required -SMTP_PASSWORD= # required +SMTP_HOST= +SMTP_USERNAME= +SMTP_PASSWORD= SMTP_PORT=587 SMTP_SECURE=false -SMTP_FROM_ADDRESS= # required +SMTP_FROM_ADDRESS= SMTP_FROM_NAME=Infisical # Integration @@ -66,4 +66,4 @@ STRIPE_WEBHOOK_SECRET= STRIPE_PRODUCT_STARTER= STRIPE_PRODUCT_TEAM= STRIPE_PRODUCT_PRO= -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= \ No newline at end of file +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index b4f9546ae..0c06bf342 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -9,6 +9,12 @@ jobs: steps: - name: โ˜๏ธ Checkout source uses: actions/checkout@v3 + - 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: Save commit hashes for tag id: commit uses: pr-mpt/actions-commit-hash@v2 @@ -45,8 +51,8 @@ jobs: token: ${{ secrets.DEPOT_PROJECT_TOKEN }} push: true context: backend - tags: infisical/backend:${{ steps.commit.outputs.short }}, - infisical/backend:latest + tags: infisical/backend:${{ steps.commit.outputs.short }}, + infisical/backend:latest platforms: linux/amd64,linux/arm64 frontend-image: @@ -94,8 +100,8 @@ jobs: push: true token: ${{ secrets.DEPOT_PROJECT_TOKEN }} context: frontend - tags: infisical/frontend:${{ steps.commit.outputs.short }}, - infisical/frontend:latest + tags: infisical/frontend:${{ steps.commit.outputs.short }}, + infisical/frontend:latest platforms: linux/amd64,linux/arm64 build-args: | POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} @@ -122,7 +128,7 @@ jobs: token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} - name: Save DigitalOcean kubeconfig with short-lived credentials run: doctl kubernetes cluster kubeconfig save --expiry-seconds 600 k8s-1-25-4-do-0-nyc1-1670645170179 - - name: switch to gamma namespace + - name: switch to gamma namespace run: kubectl config set-context --current --namespace=gamma - name: test kubectl run: kubectl get ingress @@ -135,4 +141,4 @@ jobs: exit 1 else echo "Helm upgrade was successful" - fi \ No newline at end of file + fi diff --git a/README.md b/README.md index 449b8cce6..6e6d40855 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ git commit activity - Cloudsmith downloads + Cloudsmith downloads Slack community channel diff --git a/backend/package-lock.json b/backend/package-lock.json index 93146e5fc..ab41b74b0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -62,7 +62,7 @@ "@types/cookie-parser": "^1.4.3", "@types/cors": "^2.8.12", "@types/express": "^4.17.14", - "@types/jest": "^29.2.4", + "@types/jest": "^29.5.0", "@types/jsonwebtoken": "^8.5.9", "@types/lodash": "^4.14.191", "@types/node": "^18.11.3", @@ -3629,9 +3629,9 @@ } }, "node_modules/@types/jest": { - "version": "29.4.0", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.4.0.tgz", - "integrity": "sha512-VaywcGQ9tPorCX/Jkkni7RWGFfI11whqzs8dvxF41P17Z+z872thvEvlIbznjPJ02kl1HMX3LmLOonsj2n7HeQ==", + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.0.tgz", + "integrity": "sha512-3Emr5VOl/aoBwnWcH/EFQvlSAmjV+XtV9GGu5mwdYew5vhQh0IUZx/60x0TzHDu09Bi7HMx10t/namdJw5QIcg==", "dev": true, "dependencies": { "expect": "^29.0.0", @@ -15642,9 +15642,9 @@ } }, "@types/jest": { - "version": "29.4.0", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.4.0.tgz", - "integrity": "sha512-VaywcGQ9tPorCX/Jkkni7RWGFfI11whqzs8dvxF41P17Z+z872thvEvlIbznjPJ02kl1HMX3LmLOonsj2n7HeQ==", + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.0.tgz", + "integrity": "sha512-3Emr5VOl/aoBwnWcH/EFQvlSAmjV+XtV9GGu5mwdYew5vhQh0IUZx/60x0TzHDu09Bi7HMx10t/namdJw5QIcg==", "dev": true, "requires": { "expect": "^29.0.0", diff --git a/backend/package.json b/backend/package.json index c740364d7..ef6e348b7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,8 +3,8 @@ "@aws-sdk/client-secrets-manager": "^3.287.0", "@godaddy/terminus": "^4.11.2", "@octokit/rest": "^19.0.5", - "@sentry/tracing": "^7.39.0", "@sentry/node": "^7.40.0", + "@sentry/tracing": "^7.39.0", "@types/crypto-js": "^4.1.1", "@types/libsodium-wrappers": "^0.7.10", "await-to-js": "^3.0.0", @@ -57,7 +57,7 @@ "lint-and-fix": "eslint . --ext .ts --fix", "lint-staged": "lint-staged", "pretest": "docker compose -f test-resources/docker-compose.test.yml up -d", - "test": "cross-env NODE_ENV=test jest --verbose --testTimeout=10000 --detectOpenHandles", + "test": "cross-env NODE_ENV=test jest --verbose --testTimeout=10000 --detectOpenHandles; npm run posttest", "test:ci": "npm test -- --watchAll=false --ci --reporters=default --reporters=jest-junit --reporters=github-actions --coverage --testLocationInResults --json --outputFile=coverage/report.json", "posttest": "docker compose -f test-resources/docker-compose.test.yml down" }, @@ -80,7 +80,7 @@ "@types/cookie-parser": "^1.4.3", "@types/cors": "^2.8.12", "@types/express": "^4.17.14", - "@types/jest": "^29.2.4", + "@types/jest": "^29.5.0", "@types/jsonwebtoken": "^8.5.9", "@types/lodash": "^4.14.191", "@types/node": "^18.11.3", diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 68427d078..29f62cdc6 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -48,4 +48,17 @@ export const getStripeSecretKey = () => infisical.get('STRIPE_SECRET_KEY')!; export const getStripeWebhookSecret = () => infisical.get('STRIPE_WEBHOOK_SECRET')!; export const getTelemetryEnabled = () => infisical.get('TELEMETRY_ENABLED')! !== 'false' && true; export const getLoopsApiKey = () => infisical.get('LOOPS_API_KEY')!; -export const getSmtpConfigured = () => infisical.get('SMTP_HOST') == '' || infisical.get('SMTP_HOST') == undefined ? false : true \ No newline at end of file +export const getSmtpConfigured = () => infisical.get('SMTP_HOST') == '' || infisical.get('SMTP_HOST') == undefined ? false : true +export const getHttpsEnabled = () => { + if (getNodeEnv() != "production") { + // no https for anything other than prod + return false + } + + if (infisical.get('HTTPS_ENABLED') == undefined || infisical.get('HTTPS_ENABLED') == "") { + // default when no value present + return true + } + + return infisical.get('HTTPS_ENABLED') === 'true' && true +} \ No newline at end of file diff --git a/backend/src/controllers/v1/authController.ts b/backend/src/controllers/v1/authController.ts index 3c6ec523a..84404bc74 100644 --- a/backend/src/controllers/v1/authController.ts +++ b/backend/src/controllers/v1/authController.ts @@ -15,10 +15,10 @@ import { BadRequestError } from '../../utils/errors'; import { EELogService } from '../../ee/services'; import { getChannelFromUserAgent } from '../../utils/posthog'; // TODO: move this import { - getNodeEnv, getJwtRefreshSecret, getJwtAuthLifetime, - getJwtAuthSecret + getJwtAuthSecret, + getHttpsEnabled } from '../../config'; declare module 'jsonwebtoken' { @@ -126,21 +126,21 @@ export const login2 = async (req: Request, res: Response) => { httpOnly: true, path: '/', sameSite: 'strict', - secure: getNodeEnv() === 'production' ? true : false + secure: getHttpsEnabled() }); const loginAction = await EELogService.createAction({ name: ACTION_LOGIN, userId: user._id }); - + loginAction && await EELogService.createLog({ userId: user._id, actions: [loginAction], channel: getChannelFromUserAgent(req.headers['user-agent']), ipAddress: req.ip }); - + // return (access) token in response return res.status(200).send({ token: tokens.token, @@ -182,14 +182,14 @@ export const logout = async (req: Request, res: Response) => { httpOnly: true, path: '/', sameSite: 'strict', - secure: getNodeEnv() === 'production' ? true : false + secure: getHttpsEnabled() as boolean }); const logoutAction = await EELogService.createAction({ name: ACTION_LOGOUT, userId: req.user._id }); - + logoutAction && await EELogService.createLog({ userId: req.user._id, actions: [logoutAction], diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts index 3a36d559c..92b4a159a 100644 --- a/backend/src/controllers/v2/authController.ts +++ b/backend/src/controllers/v2/authController.ts @@ -17,9 +17,9 @@ import { } from '../../variables'; import { getChannelFromUserAgent } from '../../utils/posthog'; // TODO: move this import { - getNodeEnv, getJwtMfaLifetime, - getJwtMfaSecret + getJwtMfaSecret, + getHttpsEnabled } from '../../config'; declare module 'jsonwebtoken' { @@ -163,7 +163,7 @@ export const login2 = async (req: Request, res: Response) => { httpOnly: true, path: '/', sameSite: 'strict', - secure: getNodeEnv() === 'production' ? true : false + secure: getHttpsEnabled() }); // case: user does not have MFA enablgged @@ -302,7 +302,7 @@ export const verifyMfaToken = async (req: Request, res: Response) => { httpOnly: true, path: '/', sameSite: 'strict', - secure: getNodeEnv() === 'production' ? true : false + secure: getHttpsEnabled() }); interface VerifyMfaTokenRes { diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 82b5c65d6..82b16edd6 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -550,7 +550,10 @@ export const getSecrets = async (req: Request, res: Response) => { const workspaceId = req.query.workspaceId as string; const environment = req.query.environment as string; - // tags logic + // secrets to return + let secrets: ISecret[] = []; + + // query tags table to get all tags ids for the tag names for the given workspace let tagIds = []; const tagNamesList = typeof tagSlugs === 'string' && tagSlugs !== '' ? tagSlugs.split(',') : []; if (tagNamesList != undefined && tagNamesList.length != 0) { @@ -560,72 +563,71 @@ export const getSecrets = async (req: Request, res: Response) => { return tag ? tag.id : null; }); } - - let secrets: ISecret[] = []; - + if (req.user) { // case: client authorization is via JWT - - let hasWriteOnlyAccess - if (!req.serviceTokenData) { - hasWriteOnlyAccess = await userHasWriteOnlyAbility(req.user._id, new Types.ObjectId(workspaceId), environment) - const hasNoAccess = await userHasNoAbility(req.user._id, new Types.ObjectId(workspaceId), environment) - if (hasNoAccess) { - throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" }) - } + const hasWriteOnlyAccess = await userHasWriteOnlyAbility(req.user._id, new Types.ObjectId(workspaceId), environment) + const hasNoAccess = await userHasNoAbility(req.user._id, new Types.ObjectId(workspaceId), environment) + if (hasNoAccess) { + throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" }) } - let secretQuery: any; - if (tagNamesList != undefined && tagNamesList.length != 0) { - const workspaceFromDB = await Tag.find({ workspace: workspaceId }) - - const tagIds = _.map(tagNamesList, (tagName) => { - const tag = _.find(workspaceFromDB, { slug: tagName }); - return tag ? tag.id : null; - }); - - secretQuery = { - workspace: workspaceId, - environment, - $or: [ - { user: req.user._id }, - { user: { $exists: false } } - ], - tags: { $in: tagIds }, - type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } - } - } else { - secretQuery = { - workspace: workspaceId, - environment, - $or: [ - { user: req.user._id }, - { user: { $exists: false } } - ], - type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } - } + const secretQuery: any = { + workspace: workspaceId, + environment, + $or: [ + { user: req.user._id }, // personal secrets for this user + { user: { $exists: false } } // shared secrets from workspace + ] + } + + if (tagIds.length > 0) { + secretQuery.tags = { $in: tagIds }; } if (hasWriteOnlyAccess) { - // (i.e. you don't get values to decrypt since you can only write) - secrets = await Secret.find(secretQuery).select("secretKeyCiphertext secretKeyIV secretKeyTag") + // only return the secret keys and not the values since user does not have right to see values + secrets = await Secret.find(secretQuery).select("secretKeyCiphertext secretKeyIV secretKeyTag").populate("tags") } else { secrets = await Secret.find(secretQuery).populate("tags") } } - - if (req.serviceAccount || req.serviceTokenData) { - // case: client authorization is either via service account or service token - - secrets = await Secret.find({ - workspace: new Types.ObjectId(workspaceId), + + // case: client authorization is via service token + if (req.serviceTokenData) { + const userId = req.serviceTokenData.user._id + + const secretQuery: any = { + workspace: workspaceId, environment, - user: { - $exists: false - }, - ...(tagIds.length > 0 ? { tags: { $in: tagIds } } : {}), - type: SECRET_SHARED - }); + $or: [ + { user: userId }, // personal secrets for this user + { user: { $exists: false } } // shared secrets from workspace + ] + } + + if (tagIds.length > 0) { + secretQuery.tags = { $in: tagIds }; + } + + // TODO check if service token has write only permission + + secrets = await Secret.find(secretQuery).populate("tags"); + } + + // case: client authorization is via service account + if (req.serviceAccount) { + const secretQuery: any = { + workspace: workspaceId, + environment, + user: { $exists: false } // shared secrets only from workspace + } + + if (tagIds.length > 0) { + secretQuery.tags = { $in: tagIds }; + } + + secrets = await Secret.find(secretQuery).populate("tags"); } const channel = getChannelFromUserAgent(req.headers['user-agent']) @@ -638,7 +640,7 @@ export const getSecrets = async (req: Request, res: Response) => { workspaceId: new Types.ObjectId(workspaceId as string), secretIds: secrets.map((n: any) => n._id) }); - + readAction && await EELogService.createLog({ userId: req.user?._id, serviceAccountId: req.serviceAccount?._id, @@ -952,7 +954,7 @@ export const deleteSecrets = async (req: Request, res: Response) => { } } */ - + return res.status(200).send({ message: 'delete secrets!!' }); diff --git a/backend/src/controllers/v2/signupController.ts b/backend/src/controllers/v2/signupController.ts index aaf055b78..ff41aa017 100644 --- a/backend/src/controllers/v2/signupController.ts +++ b/backend/src/controllers/v2/signupController.ts @@ -8,7 +8,7 @@ import { import { issueAuthTokens } from '../../helpers/auth'; import { INVITED, ACCEPTED } from '../../variables'; import request from '../../config/request'; -import { getNodeEnv, getLoopsApiKey } from '../../config'; +import { getLoopsApiKey, getHttpsEnabled } from '../../config'; /** * Complete setting up user by adding their personal and auth information as part of the @@ -24,9 +24,9 @@ export const completeAccountSignup = async (req: Request, res: Response) => { email, firstName, lastName, - protectedKey, - protectedKeyIV, - protectedKeyTag, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, encryptedPrivateKey, encryptedPrivateKeyIV, @@ -38,9 +38,9 @@ export const completeAccountSignup = async (req: Request, res: Response) => { email: string; firstName: string; lastName: string; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; publicKey: string; encryptedPrivateKey: string; encryptedPrivateKeyIV: string; @@ -48,11 +48,11 @@ export const completeAccountSignup = async (req: Request, res: Response) => { salt: string; verifier: string; organizationName: string; - } = req.body; + } = req.body; // get user user = await User.findOne({ email }); - + if (!user || (user && user?.publicKey)) { // case 1: user doesn't exist. // case 2: user has already completed account @@ -66,10 +66,10 @@ export const completeAccountSignup = async (req: Request, res: Response) => { userId: user._id.toString(), firstName, lastName, - encryptionVersion: 2, - protectedKey, - protectedKeyIV, - protectedKeyTag, + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, encryptedPrivateKey, encryptedPrivateKeyIV, @@ -127,7 +127,7 @@ export const completeAccountSignup = async (req: Request, res: Response) => { httpOnly: true, path: '/', sameSite: 'strict', - secure: getNodeEnv() === 'production' ? true : false + secure: getHttpsEnabled() }); } catch (err) { Sentry.setUser(null); @@ -158,9 +158,9 @@ export const completeAccountInvite = async (req: Request, res: Response) => { email, firstName, lastName, - protectedKey, - protectedKeyIV, - protectedKeyTag, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, encryptedPrivateKey, encryptedPrivateKeyIV, @@ -192,10 +192,10 @@ export const completeAccountInvite = async (req: Request, res: Response) => { userId: user._id.toString(), firstName, lastName, - encryptionVersion: 2, - protectedKey, - protectedKeyIV, - protectedKeyTag, + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, encryptedPrivateKey, encryptedPrivateKeyIV, @@ -232,7 +232,7 @@ export const completeAccountInvite = async (req: Request, res: Response) => { httpOnly: true, path: '/', sameSite: 'strict', - secure: getNodeEnv() === 'production' ? true : false + secure: getHttpsEnabled() }); } catch (err) { Sentry.setUser(null); @@ -241,7 +241,7 @@ export const completeAccountInvite = async (req: Request, res: Response) => { message: 'Failed to complete account setup' }); } - + return res.status(200).send({ message: 'Successfully set up account', user, diff --git a/backend/src/ee/middleware/requireSecretSnapshotAuth.ts b/backend/src/ee/middleware/requireSecretSnapshotAuth.ts index f43e9c315..af4d1e21c 100644 --- a/backend/src/ee/middleware/requireSecretSnapshotAuth.ts +++ b/backend/src/ee/middleware/requireSecretSnapshotAuth.ts @@ -15,32 +15,28 @@ import { const requireSecretSnapshotAuth = ({ acceptedRoles, }: { - acceptedRoles: string[]; + acceptedRoles: Array<'admin' | 'member'>; }) => { return async (req: Request, res: Response, next: NextFunction) => { - try { - const { secretSnapshotId } = req.params; - - const secretSnapshot = await SecretSnapshot.findById(secretSnapshotId); - - if (!secretSnapshot) { - return next(SecretSnapshotNotFoundError({ - message: 'Failed to find secret snapshot' - })); - } - - await validateMembership({ - userId: req.user._id, - workspaceId: secretSnapshot.workspace, - acceptedRoles - }); - - req.secretSnapshot = secretSnapshot as any; - - next(); - } catch (err) { - return next(UnauthorizedRequestError({ message: 'Unable to authenticate secret snapshot' })); + const { secretSnapshotId } = req.params; + + const secretSnapshot = await SecretSnapshot.findById(secretSnapshotId); + + if (!secretSnapshot) { + return next(SecretSnapshotNotFoundError({ + message: 'Failed to find secret snapshot' + })); } + + await validateMembership({ + userId: req.user._id, + workspaceId: secretSnapshot.workspace, + acceptedRoles + }); + + req.secretSnapshot = secretSnapshot as any; + + next(); } } diff --git a/backend/src/ee/routes/v1/secret.ts b/backend/src/ee/routes/v1/secret.ts index 8e93919a0..3e956a388 100644 --- a/backend/src/ee/routes/v1/secret.ts +++ b/backend/src/ee/routes/v1/secret.ts @@ -7,7 +7,12 @@ import { } from '../../../middleware'; import { query, param, body } from 'express-validator'; import { secretController } from '../../controllers/v1'; -import { ADMIN, MEMBER } from '../../../variables'; +import { + ADMIN, + MEMBER, + PERMISSION_READ_SECRETS, + PERMISSION_WRITE_SECRETS +} from '../../../variables'; router.get( '/:secretId/secret-versions', @@ -15,7 +20,8 @@ router.get( acceptedAuthModes: ['jwt', 'apiKey'] }), requireSecretAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + requiredPermissions: [PERMISSION_READ_SECRETS] }), param('secretId').exists().trim(), query('offset').exists().isInt(), @@ -30,7 +36,8 @@ router.post( acceptedAuthModes: ['jwt', 'apiKey'] }), requireSecretAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + requiredPermissions: [PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS] }), param('secretId').exists().trim(), body('version').exists().isInt(), diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index c2c21ded5..9cbe82e34 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -148,7 +148,7 @@ const getAuthSTDPayload = async ({ if (!isMatch) throw UnauthorizedRequestError({ message: 'Failed to authenticate service token' }); - + serviceTokenData = await ServiceTokenData .findOneAndUpdate({ _id: new Types.ObjectId(TOKEN_IDENTIFIER) @@ -157,8 +157,8 @@ const getAuthSTDPayload = async ({ }, { new: true }) - .select('+encryptedKey +iv +tag'); - + .select('+encryptedKey +iv +tag').populate('user serviceAccount'); + if (!serviceTokenData) throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' }); return serviceTokenData; @@ -176,20 +176,20 @@ const getAuthSAAKPayload = async ({ authTokenValue: string; }) => { const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split('.', 3); - + const serviceAccount = await ServiceAccount.findById( Buffer.from(TOKEN_IDENTIFIER, 'base64').toString('hex') ).select('+secretHash'); - + if (!serviceAccount) { throw ServiceAccountNotFoundError({ message: 'Failed to find service account' }); } - + const result = await bcrypt.compare(TOKEN_SECRET, serviceAccount.secretHash); if (!result) throw UnauthorizedRequestError({ message: 'Failed to authenticate service account access key' }); - + return serviceAccount; } @@ -208,7 +208,7 @@ const getAuthAPIKeyPayload = async ({ let apiKeyData = await APIKeyData .findById(TOKEN_IDENTIFIER, '+secretHash +expiresAt') - .populate<{user: IUser}>('user', '+publicKey'); + .populate<{ user: IUser }>('user', '+publicKey'); if (!apiKeyData) { throw APIKeyDataNotFoundError({ message: 'Failed to find API key data' }); @@ -232,13 +232,13 @@ const getAuthAPIKeyPayload = async ({ }, { new: true }); - + if (!apiKeyData) { throw APIKeyDataNotFoundError({ message: 'Failed to find API key data' }); } - + const user = await User.findById(apiKeyData.user).select('+publicKey'); - + if (!user) { throw AccountNotFoundError({ message: 'Failed to find user' diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts index 5cfbeebf5..1e242778d 100644 --- a/backend/src/helpers/bot.ts +++ b/backend/src/helpers/bot.ts @@ -1,10 +1,16 @@ import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; import { Bot, BotKey, Secret, ISecret, - IUser + IUser, + User, + IServiceAccount, + ServiceAccount, + IServiceTokenData, + ServiceTokenData } from '../models'; import { generateKeyPair, @@ -12,8 +18,88 @@ import { decryptSymmetric, decryptAsymmetric } from '../utils/crypto'; -import { SECRET_SHARED } from '../variables'; +import { + SECRET_SHARED, + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; import { getEncryptionKey } from '../config'; +import { BotNotFoundError, UnauthorizedRequestError } from '../utils/errors'; +import { + validateMembership +} from '../helpers/membership'; +import { + validateUserClientForWorkspace +} from '../helpers/user'; +import { + validateServiceAccountClientForWorkspace +} from '../helpers/serviceAccount'; + +/** + * Validate authenticated clients for bot with id [botId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.botId - id of bot to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles + */ +const validateClientForBot = async ({ + authData, + botId, + acceptedRoles +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + botId: Types.ObjectId; + acceptedRoles: Array<'admin' | 'member'>; +}) => { + const bot = await Bot.findById(botId); + + if (!bot) throw BotNotFoundError(); + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: bot.workspace, + acceptedRoles + }); + + return bot; + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId: bot.workspace + }); + + return bot; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for bot' + }); + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: bot.workspace, + acceptedRoles + }); + + return bot; + } + + throw BotNotFoundError({ + message: 'Failed client authorization for bot' + }); +} /** * Create an inactive bot with name [name] for workspace with id [workspaceId] @@ -222,6 +308,7 @@ const decryptSymmetricHelper = async ({ } export { + validateClientForBot, createBot, getSecretsHelper, encryptSymmetricHelper, diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 27afbb4b1..fbbc2f807 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -1,17 +1,42 @@ import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; import { Bot, Integration, IntegrationAuth, + IUser, + User, + IServiceAccount, + ServiceAccount, + IServiceTokenData, + ServiceTokenData } from '../models'; import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations'; import { BotService } from '../services'; import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY, INTEGRATION_VERCEL, INTEGRATION_NETLIFY } from '../variables'; -import { UnauthorizedRequestError } from '../utils/errors'; +import { + UnauthorizedRequestError, + IntegrationAuthNotFoundError, + IntegrationNotFoundError +} from '../utils/errors'; import RequestError from '../utils/requestError'; +import { + validateClientForIntegrationAuth +} from '../helpers/integrationAuth'; +import { + validateUserClientForWorkspace +} from '../helpers/user'; +import { + validateServiceAccountClientForWorkspace +} from '../helpers/serviceAccount'; +import { IntegrationService } from '../services'; interface Update { workspace: string; @@ -20,6 +45,84 @@ interface Update { accountId?: string; } +/** + * Validate authenticated clients for integration with id [integrationId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.integrationId - id of integration to validate against + * @param {String} obj.environment - (optional) environment in workspace to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles + * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint + */ + const validateClientForIntegration = async ({ + authData, + integrationId, + acceptedRoles +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + integrationId: Types.ObjectId; + acceptedRoles: Array<'admin' | 'member'>; +}) => { + + 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 accessToken = (await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id + })).accessToken; + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: integration.workspace, + acceptedRoles + }); + + return ({ integration, accessToken }); + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId: integration.workspace + }); + + return ({ integration, accessToken }); + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for integration' + }); + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: integration.workspace, + acceptedRoles + }); + + return ({ integration, accessToken }); + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for integration' + }); +} + /** * Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration * named [integration] @@ -140,7 +243,7 @@ const syncIntegrationsHelper = async ({ // get integration auth access token const access = await getIntegrationAuthAccessHelper({ - integrationAuthId: integration.integrationAuth.toString() + integrationAuthId: integration.integrationAuth }); // sync secrets to integration @@ -167,7 +270,7 @@ const syncIntegrationsHelper = async ({ * @param {String} obj.integrationAuthId - id of integration auth * @param {String} refreshToken - decrypted refresh token */ - const getIntegrationAuthRefreshHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => { + const getIntegrationAuthRefreshHelper = async ({ integrationAuthId }: { integrationAuthId: Types.ObjectId }) => { let refreshToken; try { @@ -204,7 +307,7 @@ const syncIntegrationsHelper = async ({ * @param {String} obj.integrationAuthId - id of integration auth * @returns {String} accessToken - decrypted access token */ -const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => { +const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrationAuthId: Types.ObjectId }) => { let accessId; let accessToken; try { @@ -367,6 +470,7 @@ const setIntegrationAuthAccessHelper = async ({ } export { + validateClientForIntegration, handleOAuthExchangeHelper, syncIntegrationsHelper, getIntegrationAuthRefreshHelper, diff --git a/backend/src/helpers/integrationAuth.ts b/backend/src/helpers/integrationAuth.ts index e69de29bb..c169fb799 100644 --- a/backend/src/helpers/integrationAuth.ts +++ b/backend/src/helpers/integrationAuth.ts @@ -0,0 +1,108 @@ +import { Types } from 'mongoose'; +import { + IntegrationAuth, + IUser, + User, + IServiceAccount, + ServiceAccount, + IServiceTokenData, + ServiceTokenData, + IWorkspace +} from '../models'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; +import { + IntegrationAuthNotFoundError, + UnauthorizedRequestError +} from '../utils/errors'; +import { IntegrationService } from '../services'; +import { validateUserClientForWorkspace } from '../helpers/user'; +import { validateServiceAccountClientForWorkspace } from '../helpers/serviceAccount'; + +/** + * Validate authenticated clients for integration authorization with id [integrationAuthId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.integrationAuthId - id of integration authorization to validate against + * @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 +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + 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' + ); + + if (!integrationAuth) throw IntegrationAuthNotFoundError(); + + let accessToken; + if (attachAccessToken) { + accessToken = (await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id + })).accessToken; + } + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: integrationAuth.workspace._id, + acceptedRoles + }); + + return ({ integrationAuth, accessToken }); + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId: integrationAuth.workspace._id + }); + + return ({ integrationAuth, accessToken }); + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for integration authorization' + }); + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: integrationAuth.workspace._id, + acceptedRoles + }); + + return ({ integrationAuth, accessToken }); + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for integration authorization' + }); +} + +export { + validateClientForIntegrationAuth +}; \ No newline at end of file diff --git a/backend/src/helpers/membership.ts b/backend/src/helpers/membership.ts index 93c1ac7e9..503ca9fc6 100644 --- a/backend/src/helpers/membership.ts +++ b/backend/src/helpers/membership.ts @@ -1,10 +1,106 @@ import * as Sentry from '@sentry/node'; import { Types } from 'mongoose'; -import { Membership, Key } from '../models'; +import { + Membership, + Key, + IUser, + User, + IServiceAccount, + ServiceAccount, + IServiceTokenData, + ServiceTokenData +} from '../models'; import { MembershipNotFoundError, - BadRequestError + BadRequestError, + UnauthorizedRequestError } from '../utils/errors'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; +import { + validateUserClientForWorkspace +} from '../helpers/user'; +import { + validateServiceAccountClientForWorkspace +} from '../helpers/serviceAccount'; +import { + validateServiceTokenDataClientForWorkspace +} from '../helpers/serviceTokenData'; + +/** + * Validate authenticated clients for membership with id [membershipId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.membershipId - id of membership to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspaceRoles + * @returns {Membership} - validated membership + */ +const validateClientForMembership = async ({ + authData, + membershipId, + acceptedRoles +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + membershipId: Types.ObjectId; + acceptedRoles: Array<'admin' | 'member'>; +}) => { + + const membership = await Membership.findById(membershipId); + + if (!membership) throw MembershipNotFoundError({ + message: 'Failed to find membership' + }); + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: membership.workspace, + acceptedRoles + }); + + return membership; + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId: membership.workspace + }); + + return membership; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: authData.authPayload, + workspaceId: new Types.ObjectId(membership.workspace) + }); + + return membership; + } + + if (authData.authMode == AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: membership.workspace, + acceptedRoles + }); + + return membership; + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for membership' + }); +} /** * Validate that user with id [userId] is a member of workspace with id [workspaceId] @@ -21,7 +117,7 @@ const validateMembership = async ({ }: { userId: Types.ObjectId; workspaceId: Types.ObjectId; - acceptedRoles?: string[]; + acceptedRoles?: Array<'admin' | 'member'>; }) => { const membership = await Membership.findOne({ @@ -35,7 +131,7 @@ const validateMembership = async ({ if (acceptedRoles) { if (!acceptedRoles.includes(membership.role)) { - throw BadRequestError({ message: 'Failed to validate workspace membership role' }); + throw BadRequestError({ message: 'Failed authorization for membership role' }); } } @@ -134,6 +230,7 @@ const deleteMembership = async ({ membershipId }: { membershipId: string }) => { }; export { + validateClientForMembership, validateMembership, addMemberships, findMembership, diff --git a/backend/src/helpers/membershipOrg.ts b/backend/src/helpers/membershipOrg.ts index 65c1e01c2..b34e5dd2f 100644 --- a/backend/src/helpers/membershipOrg.ts +++ b/backend/src/helpers/membershipOrg.ts @@ -1,10 +1,98 @@ import * as Sentry from '@sentry/node'; import { Types } from 'mongoose'; -import { MembershipOrg, Workspace, Membership, Key } from '../models'; +import { + MembershipOrg, + Workspace, + Membership, + Key, + IUser, + User, + IServiceAccount, + ServiceAccount, + IServiceTokenData, + ServiceTokenData +} from '../models'; import { MembershipOrgNotFoundError, - BadRequestError + BadRequestError, + UnauthorizedRequestError } from '../utils/errors'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; + +/** + * Validate authenticated clients for organization membership with id [membershipOrgId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.membershipOrgId - id of organization membership to validate against + * @param {Array<'owner' | 'admin' | 'member'>} obj.acceptedRoles - accepted organization roles + * @param {MembershipOrg} - validated organization membership + */ +const validateClientForMembershipOrg = async ({ + authData, + membershipOrgId, + acceptedRoles, + acceptedStatuses +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + membershipOrgId: Types.ObjectId; + acceptedRoles: Array<'owner' | 'admin' | 'member'>; + acceptedStatuses: Array<'invited' | 'accepted'>; +}) => { + const membershipOrg = await MembershipOrg.findById(membershipOrgId); + + if (!membershipOrg) throw MembershipOrgNotFoundError({ + message: 'Failed to find organization membership ' + }); + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateMembershipOrg({ + userId: authData.authPayload._id, + organizationId: membershipOrg.organization, + acceptedRoles, + acceptedStatuses + }); + + return membershipOrg; + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + if (!authData.authPayload.organization.equals(membershipOrg.organization)) throw UnauthorizedRequestError({ + message: 'Failed service account client authorization for organization membership' + }); + + return membershipOrg; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + throw UnauthorizedRequestError({ + message: 'Failed service account client authorization for organization membership' + }); + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateMembershipOrg({ + userId: authData.authPayload._id, + organizationId: membershipOrg.organization, + acceptedRoles, + acceptedStatuses + }); + + return membershipOrg; + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for organization membership' + }); +} /** * Validate that user with id [userId] is a member of organization with id [organizationId] @@ -22,8 +110,8 @@ const validateMembershipOrg = async ({ }: { userId: Types.ObjectId; organizationId: Types.ObjectId; - acceptedRoles: string[]; - acceptedStatuses: string[]; + acceptedRoles?: Array<'owner' | 'admin' | 'member'>; + acceptedStatuses?: Array<'invited' | 'accepted'>; }) => { const membershipOrg = await MembershipOrg.findOne({ user: userId, @@ -34,12 +122,16 @@ const validateMembershipOrg = async ({ throw MembershipOrgNotFoundError({ message: 'Failed to find organization membership' }); } - if (!acceptedRoles.includes(membershipOrg.role)) { - throw BadRequestError({ message: 'Failed to validate organization membership role' }); + if (acceptedRoles) { + if (!acceptedRoles.includes(membershipOrg.role)) { + throw UnauthorizedRequestError({ message: 'Failed to validate organization membership role' }); + } } - - if (!acceptedStatuses.includes(membershipOrg.status)) { - throw BadRequestError({ message: 'Failed to validate organization membership status' }); + + if (acceptedStatuses) { + if (!acceptedStatuses.includes(membershipOrg.status)) { + throw UnauthorizedRequestError({ message: 'Failed to validate organization membership status' }); + } } return membershipOrg; @@ -164,6 +256,7 @@ const deleteMembershipOrg = async ({ }; export { + validateClientForMembershipOrg, validateMembershipOrg, findMembershipOrg, addMembershipsOrg, diff --git a/backend/src/helpers/organization.ts b/backend/src/helpers/organization.ts index 0784f446b..9840c9075 100644 --- a/backend/src/helpers/organization.ts +++ b/backend/src/helpers/organization.ts @@ -15,7 +15,8 @@ import { AUTH_MODE_JWT, AUTH_MODE_SERVICE_ACCOUNT, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_API_KEY + AUTH_MODE_API_KEY, + OWNER } from '../variables'; import { getStripeSecretKey, @@ -24,8 +25,15 @@ import { getStripeProductStarter } from '../config'; import { - UnauthorizedRequestError + UnauthorizedRequestError, + OrganizationNotFoundError } from '../utils/errors'; +import { + validateUserClientForOrganization +} from '../helpers/user'; +import { + validateServiceAccountClientForOrganization +} from '../helpers/serviceAccount'; /** * Validate accepted clients for organization with id [organizationId] @@ -35,34 +43,66 @@ import { */ const validateClientForOrganization = async ({ authData, - organizationId + organizationId, + acceptedRoles, + acceptedStatuses }: { authData: { authMode: string; authPayload: IUser | IServiceAccount | IServiceTokenData; }, - organizationId: string; + organizationId: Types.ObjectId; + acceptedRoles: Array<'owner' | 'admin' | 'member'>; + acceptedStatuses: Array<'invited' | 'accepted'>; }) => { - // TODO + + const organization = await Organization.findById(organizationId); + + if (!organization) { + throw OrganizationNotFoundError({ + message: 'Failed to find organization' + }); + } if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { - // TODO + const membershipOrg = await validateUserClientForOrganization({ + user: authData.authPayload, + organization, + acceptedRoles, + acceptedStatuses + }); + + return ({ organization, membershipOrg }); } if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { - // TODO + await validateServiceAccountClientForOrganization({ + serviceAccount: authData.authPayload, + organization + }); + + return ({ organization }); } if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { - // TODO + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for organization' + }); } if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { - // TODO + const membershipOrg = await validateUserClientForOrganization({ + user: authData.authPayload, + organization, + acceptedRoles, + acceptedStatuses + }); + + return ({ organization, membershipOrg }); } throw UnauthorizedRequestError({ - message: 'Failed client authorization for organization resource' + message: 'Failed client authorization for organization' }); } @@ -228,6 +268,7 @@ const updateSubscriptionOrgQuantity = async ({ }; export { + validateClientForOrganization, createOrganization, initSubscriptionOrg, updateSubscriptionOrgQuantity diff --git a/backend/src/helpers/rateLimiter.ts b/backend/src/helpers/rateLimiter.ts index 432bd5f97..073ba11bc 100644 --- a/backend/src/helpers/rateLimiter.ts +++ b/backend/src/helpers/rateLimiter.ts @@ -15,7 +15,7 @@ const apiLimiter = rateLimit({ }); // 10 requests per minute -const authLimiter = rateLimit({ +const authLimit = rateLimit({ windowMs: 60 * 1000, max: 10, standardHeaders: true, @@ -36,8 +36,16 @@ const passwordLimiter = rateLimit({ } }); -export { - apiLimiter, - authLimiter, - passwordLimiter +const authLimiter = (req: any, res: any, next: any) => { + if (process.env.NODE_ENV === 'production') { + authLimit(req, res, next); + } else { + next(); + } +}; + +export { + apiLimiter, + authLimiter, + passwordLimiter }; diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index 09418560d..7d3e54105 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -10,15 +10,24 @@ import { ISecret } from '../models'; import { + validateMembership +} from '../helpers/membership'; +import { + validateUserClientForSecret, validateUserClientForSecrets } from '../helpers/user'; import { - validateServiceTokenDataClientForSecrets + validateServiceTokenDataClientForSecrets, validateServiceTokenDataClientForWorkspace } from '../helpers/serviceTokenData'; import { - validateServiceAccountClientForSecrets + validateServiceAccountClientForSecrets, + validateServiceAccountClientForWorkspace } from '../helpers/serviceAccount'; -import { BadRequestError, UnauthorizedRequestError } from '../utils/errors'; +import { + BadRequestError, + UnauthorizedRequestError, + SecretNotFoundError +} from '../utils/errors'; import { AUTH_MODE_JWT, AUTH_MODE_SERVICE_ACCOUNT, @@ -27,12 +36,91 @@ import { } from '../variables'; /** - * Validate accepted clients for secrets with ids [secretIds] + * Validate authenticated clients for secrets with id [secretId] based + * on any known permissions. * @param {Object} obj - * @param {User} obj.user - user client - * @param {ServiceAccount} obj.serviceAccount - service account client - * @param {ServiceTokenData} obj.service - service token client - * @param {String[]} obj.secretIds - ids of secrets to validate against + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.secretId - id of secret to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles + * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint + */ +const validateClientForSecret = async ({ + authData, + secretId, + acceptedRoles, + requiredPermissions +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }, + secretId: Types.ObjectId; + acceptedRoles: Array<'admin' | 'member'>; + requiredPermissions: string[]; +}) => { + const secret = await Secret.findById(secretId); + + if (!secret) throw SecretNotFoundError({ + message: 'Failed to find secret' + }); + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForSecret({ + user: authData.authPayload, + secret, + acceptedRoles, + requiredPermissions + }); + + return secret; + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId: secret.workspace, + environment: secret.environment, + requiredPermissions + }); + + return secret; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: authData.authPayload, + workspaceId: secret.workspace, + environment: secret.environment + }); + + return secret; + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForSecret({ + user: authData.authPayload, + secret, + acceptedRoles, + requiredPermissions + }); + + return secret; + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for secret' + }); +} + +/** + * Validate authenticated clients for secrets with ids [secretIds] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId[]} obj.secretIds - id of workspace to validate against + * @param {String} obj.environment - (optional) environment in workspace to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles + * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint */ const validateClientForSecrets = async ({ authData, @@ -43,7 +131,7 @@ const validateClientForSecrets = async ({ authMode: string; authPayload: IUser | IServiceAccount | IServiceTokenData; }, - secretIds: string[]; + secretIds: Types.ObjectId[]; requiredPermissions: string[]; }) => { @@ -51,7 +139,7 @@ const validateClientForSecrets = async ({ secrets = await Secret.find({ _id: { - $in: secretIds.map((secretId: string) => new Types.ObjectId(secretId)) + $in: secretIds } }); @@ -105,5 +193,6 @@ const validateClientForSecrets = async ({ } export { + validateClientForSecret, validateClientForSecrets } \ No newline at end of file diff --git a/backend/src/helpers/serviceAccount.ts b/backend/src/helpers/serviceAccount.ts index f0fe3063e..892767259 100644 --- a/backend/src/helpers/serviceAccount.ts +++ b/backend/src/helpers/serviceAccount.ts @@ -8,6 +8,8 @@ import { ServiceTokenData, IServiceTokenData, ISecret, + IOrganization, + IServiceAccountWorkspacePermission, ServiceAccountWorkspacePermission } from '../models'; import { @@ -109,21 +111,20 @@ const validateClientForServiceAccount = async ({ environment?: string; requiredPermissions?: string[]; }) => { - // TODO: add service account API support for workspace-level endpoints that are not - // tied to any specific environment - if (environment) { + // case: environment specified -> + // evaluate service account authorization for workspace + // in the context of a specific environment [environment] const permission = await ServiceAccountWorkspacePermission.findOne({ serviceAccount, workspace: new Types.ObjectId(workspaceId), environment }); - + if (!permission) throw UnauthorizedRequestError({ message: 'Failed service account authorization for the given workspace environment' }); - - // TODO: refactor + let runningIsDisallowed = false; requiredPermissions?.forEach((requiredPermission: string) => { switch (requiredPermission) { @@ -143,6 +144,20 @@ const validateClientForServiceAccount = async ({ }); } }); + + } else { + // case: no environment specified -> + // evaluate service account authorization for workspace + // without need of environment [environment] + + const permission = await ServiceAccountWorkspacePermission.findOne({ + serviceAccount, + workspace: new Types.ObjectId(workspaceId) + }); + + if (!permission) throw UnauthorizedRequestError({ + message: 'Failed service account authorization for the given workspace' + }); } } @@ -180,7 +195,6 @@ const validateClientForServiceAccount = async ({ }); requiredPermissions?.forEach((requiredPermission: string) => { - // TODO: refactor let runningIsDisallowed = false; requiredPermissions?.forEach((requiredPermission: string) => { switch (requiredPermission) { @@ -202,9 +216,6 @@ const validateClientForServiceAccount = async ({ }); }); }); - - // TODO - return []; } /** @@ -231,9 +242,30 @@ const validateServiceAccountClientForServiceAccount = ({ } } +/** + * Validate that service account (client) can access organization [organization] + * @param {Object} obj + * @param {User} obj.user - service account client + * @param {Organization} obj.organization - organization to validate against + */ +const validateServiceAccountClientForOrganization = async ({ + serviceAccount, + organization +}: { + serviceAccount: IServiceAccount; + organization: IOrganization; +}) => { + if (!serviceAccount.organization.equals(organization._id)) { + throw UnauthorizedRequestError({ + message: 'Failed service account authorization for the given organization' + }); + } +} + export { validateClientForServiceAccount, validateServiceAccountClientForWorkspace, validateServiceAccountClientForSecrets, - validateServiceAccountClientForServiceAccount + validateServiceAccountClientForServiceAccount, + validateServiceAccountClientForOrganization } \ No newline at end of file diff --git a/backend/src/helpers/serviceTokenData.ts b/backend/src/helpers/serviceTokenData.ts index 70c9d416b..9a8bb1288 100644 --- a/backend/src/helpers/serviceTokenData.ts +++ b/backend/src/helpers/serviceTokenData.ts @@ -1,9 +1,94 @@ import { Types } from 'mongoose'; import { ISecret, - IServiceTokenData + IServiceTokenData, + ServiceTokenData, + IUser, + User, + IServiceAccount, + ServiceAccount, } from '../models'; -import { UnauthorizedRequestError } from '../utils/errors'; +import { + UnauthorizedRequestError, + ServiceTokenDataNotFoundError +} from '../utils/errors'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; +import { validateUserClientForWorkspace } from '../helpers/user'; +import { validateServiceAccountClientForWorkspace } from '../helpers/serviceAccount'; + +/** + * Validate authenticated clients for service token with id [serviceTokenId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.serviceTokenData - id of service token to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles + */ +const validateClientForServiceTokenData = async ({ + authData, + serviceTokenDataId, + acceptedRoles +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + serviceTokenDataId: Types.ObjectId; + acceptedRoles: Array<'admin' | 'member'>; +}) => { + const serviceTokenData = await ServiceTokenData + .findById(serviceTokenDataId) + .select('+encryptedKey +iv +tag') + .populate<{ user: IUser }>('user'); + + if (!serviceTokenData) throw ServiceTokenDataNotFoundError({ + message: 'Failed to find service token data' + }); + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: serviceTokenData.workspace, + acceptedRoles + }); + + return serviceTokenData; + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId: serviceTokenData.workspace + }); + + return serviceTokenData; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for service token data' + }); + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: serviceTokenData.workspace, + acceptedRoles + }); + + return serviceTokenData; + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for service token data' + }); +} /** * Validate that service token (client) can access workspace @@ -34,20 +119,24 @@ import { UnauthorizedRequestError } from '../utils/errors'; }); } - if (serviceTokenData.environment !== environment) { - // case: invalid environment passed - throw UnauthorizedRequestError({ - message: 'Failed service token authorization for the given workspace environment' - }); - } - - requiredPermissions?.forEach((permission) => { - if (!serviceTokenData.permissions.includes(permission)) { + if (environment) { + // case: environment is specified + + if (serviceTokenData.environment !== environment) { + // case: invalid environment passed throw UnauthorizedRequestError({ - message: `Failed service token authorization for the given workspace environment action: ${permission}` + message: 'Failed service token authorization for the given workspace environment' }); } - }); + + requiredPermissions?.forEach((permission) => { + if (!serviceTokenData.permissions.includes(permission)) { + throw UnauthorizedRequestError({ + message: `Failed service token authorization for the given workspace environment action: ${permission}` + }); + } + }); + } } /** @@ -94,6 +183,7 @@ import { UnauthorizedRequestError } from '../utils/errors'; } export { + validateClientForServiceTokenData, validateServiceTokenDataClientForWorkspace, validateServiceTokenDataClientForSecrets } \ No newline at end of file diff --git a/backend/src/helpers/user.ts b/backend/src/helpers/user.ts index ddf313da9..5a33f3d2e 100644 --- a/backend/src/helpers/user.ts +++ b/backend/src/helpers/user.ts @@ -5,7 +5,9 @@ import { ISecret, IServiceAccount, User, - Membership + Membership, + IOrganization, + Organization, } from '../models'; import { sendMail } from './nodemailer'; import { validateMembership } from './membership'; @@ -177,21 +179,23 @@ const validateUserClientForWorkspace = async ({ user, workspaceId, environment, + acceptedRoles, requiredPermissions }: { 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 + workspaceId, + acceptedRoles }); - // TODO: refactor let runningIsDisallowed = false; requiredPermissions?.forEach((requiredPermission: string) => { switch (requiredPermission) { @@ -215,6 +219,42 @@ const validateUserClientForWorkspace = async ({ return membership; } +/** + * Validate that user (client) can access secret [secret] + * with required permissions [requiredPermissions] + * @param {Object} obj + * @param {User} obj.user - user client + * @param {Secret[]} obj.secrets - secrets to validate against + * @param {String[]} requiredPermissions - required permissions as part of the endpoint + */ +const validateUserClientForSecret = async ({ + user, + secret, + acceptedRoles, + requiredPermissions +}: { + 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 }); + + if (isDisallowed) { + throw UnauthorizedRequestError({ + message: 'You do not have the required permissions to perform this action' + }); + } + } +} + /** * Validate that user (client) can access secrets [secrets] * with required permissions [requiredPermissions] @@ -232,7 +272,8 @@ const validateUserClientForWorkspace = async ({ secrets: ISecret[]; requiredPermissions?: string[]; }) => { - // TODO: refactor + + // TODO: add acceptedRoles? const userMemberships = await Membership.find({ user: user._id }) const userMembershipById = _.keyBy(userMemberships, 'workspace'); @@ -288,11 +329,40 @@ const validateUserClientForServiceAccount = async ({ } } +/** + * Validate that user (client) can access organization [organization] + * @param {Object} obj + * @param {User} obj.user - user client + * @param {Organization} obj.organization - organization to validate against + */ + const validateUserClientForOrganization = async ({ + user, + organization, + acceptedRoles, + acceptedStatuses +}: { + 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; +} + export { setupAccount, completeAccount, checkUserDevice, validateUserClientForWorkspace, validateUserClientForSecrets, - validateUserClientForServiceAccount + validateUserClientForServiceAccount, + validateUserClientForOrganization, + validateUserClientForSecret }; diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts index 7b077e7d1..f3b27bf10 100644 --- a/backend/src/helpers/workspace.ts +++ b/backend/src/helpers/workspace.ts @@ -19,7 +19,7 @@ import { validateUserClientForWorkspace } from '../helpers/user'; import { validateServiceAccountClientForWorkspace } from '../helpers/serviceAccount'; import { validateServiceTokenDataClientForWorkspace } from '../helpers/serviceTokenData'; import { validateMembership } from '../helpers/membership'; -import { UnauthorizedRequestError } from '../utils/errors'; +import { UnauthorizedRequestError, WorkspaceNotFoundError } from '../utils/errors'; import { AUTH_MODE_JWT, AUTH_MODE_SERVICE_ACCOUNT, @@ -34,28 +34,38 @@ import { * @param {Object} obj.authData - authenticated client details * @param {Types.ObjectId} obj.workspaceId - id of workspace to validate against * @param {String} obj.environment - (optional) environment in workspace to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint */ const validateClientForWorkspace = async ({ authData, workspaceId, environment, + acceptedRoles, requiredPermissions }: { authData: { authMode: string; authPayload: IUser | IServiceAccount | IServiceTokenData; - }, + }; workspaceId: Types.ObjectId; environment?: string; + acceptedRoles: Array<'admin' | 'member'>; requiredPermissions?: string[]; }) => { + + const workspace = await Workspace.findById(workspaceId); + + if (!workspace) throw WorkspaceNotFoundError({ + message: 'Failed to find workspace' + }); if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { const membership = await validateUserClientForWorkspace({ user: authData.authPayload, workspaceId, environment, + acceptedRoles, requiredPermissions }); @@ -89,6 +99,7 @@ const validateClientForWorkspace = async ({ user: authData.authPayload, workspaceId, environment, + acceptedRoles, requiredPermissions }); @@ -96,7 +107,7 @@ const validateClientForWorkspace = async ({ } throw UnauthorizedRequestError({ - message: 'Failed client authorization for workspace resource' + message: 'Failed client authorization for workspace' }); } diff --git a/backend/src/index.ts b/backend/src/index.ts index 56fcc288a..912d39177 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -48,18 +48,18 @@ import { integrationAuth as v1IntegrationAuthRouter } from './routes/v1'; import { - signup as v2SignupRouter, - auth as v2AuthRouter, - users as v2UsersRouter, - organizations as v2OrganizationsRouter, - workspace as v2WorkspaceRouter, - secret as v2SecretRouter, // begin to phase out - secrets as v2SecretsRouter, - serviceTokenData as v2ServiceTokenDataRouter, - serviceAccounts as v2ServiceAccountsRouter, - apiKeyData as v2APIKeyDataRouter, - environment as v2EnvironmentRouter, - tags as v2TagsRouter, + signup as v2SignupRouter, + auth as v2AuthRouter, + users as v2UsersRouter, + organizations as v2OrganizationsRouter, + workspace as v2WorkspaceRouter, + secret as v2SecretRouter, // begin to phase out + secrets as v2SecretsRouter, + serviceTokenData as v2ServiceTokenDataRouter, + serviceAccounts as v2ServiceAccountsRouter, + apiKeyData as v2APIKeyDataRouter, + environment as v2EnvironmentRouter, + tags as v2TagsRouter, } from './routes/v2'; import { healthCheck } from './routes/status'; import { getLogger } from './utils/logger'; @@ -172,7 +172,7 @@ const main = async () => { getLogger("backend-main").info(`Server started listening at port ${getPort()}`) }); - createTestUserForDevelopment(); + await createTestUserForDevelopment(); setUpHealthEndpoint(server); server.on('close', async () => { diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 6929726e9..58568145d 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -16,6 +16,7 @@ import { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE, INTEGRATION_HEROKU_API_URL, INTEGRATION_GITLAB_API_URL, INTEGRATION_VERCEL_API_URL, @@ -25,6 +26,7 @@ import { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, + INTEGRATION_SUPABASE_API_URL } from "../variables"; interface App { @@ -116,6 +118,11 @@ const getApps = async ({ accessToken, }) break; + case INTEGRATION_SUPABASE: + apps = await getAppsSupabase({ + accessToken + }); + break; } } catch (err) { Sentry.setUser(null); @@ -608,4 +615,40 @@ const getAppsGitlab = async ({ return apps; } + +/** + * Return list of projects for Supabase integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Supabase API + * @returns {Object[]} apps - names of Supabase apps + * @returns {String} apps.name - name of Supabase app + */ +const getAppsSupabase = async ({ accessToken }: { accessToken: string }) => { + let apps: any; + try { + const { data } = await request.get( + `${INTEGRATION_SUPABASE_API_URL}/v1/projects`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' + } + } + ); + + apps = data.map((a: any) => { + return { + name: a.name, + appId: a.id + }; + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Supabase projects'); + } + + return apps; +}; + export { getApps }; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 9a998d6f5..285f31ca7 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -25,6 +25,7 @@ import { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE, INTEGRATION_HEROKU_API_URL, INTEGRATION_GITLAB_API_URL, INTEGRATION_VERCEL_API_URL, @@ -34,6 +35,7 @@ import { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, + INTEGRATION_SUPABASE_API_URL } from "../variables"; import request from '../config/request'; import axios from "axios"; @@ -157,6 +159,13 @@ const syncSecrets = async ({ accessToken, }); break; + case INTEGRATION_SUPABASE: + await syncSecretsSupabase({ + integration, + secrets, + accessToken + }); + break; } } catch (err) { Sentry.setUser(null); @@ -1618,4 +1627,79 @@ const syncSecretsGitLab = async ({ } } +/** + * Sync/push [secrets] to Supabase with name [integration.app] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {IIntegrationAuth} obj.integrationAuth - integration auth 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 Supabase integration + */ +const syncSecretsSupabase = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + try { + const { data: getSecretsRes } = await request.get( + `${INTEGRATION_SUPABASE_API_URL}/v1/projects/${integration.appId}/secrets`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' + } + } + ); + + // convert the secrets to [{}] format + const modifiedFormatForSecretInjection = Object.keys(secrets).map( + (key) => { + return { + name: key, + value: secrets[key] + }; + } + ); + + await request.post( + `${INTEGRATION_SUPABASE_API_URL}/v1/projects/${integration.appId}/secrets`, + modifiedFormatForSecretInjection, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' + } + } + ); + + const secretsToDelete: any = []; + getSecretsRes?.forEach((secretObj: any) => { + if (!(secretObj.name in secrets)) { + secretsToDelete.push(secretObj.name); + } + }); + + await request.delete( + `${INTEGRATION_SUPABASE_API_URL}/v1/projects/${integration.appId}/secrets`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Accept-Encoding': 'application/json' + }, + data: secretsToDelete + } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Supabase'); + } +}; + + export { syncSecrets }; diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index fd828c89b..16191d607 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -44,6 +44,7 @@ const requireAuth = ({ acceptedAuthModes: string[]; }) => { return async (req: Request, res: Response, next: NextFunction) => { + // validate auth token against accepted auth modes [acceptedAuthModes] // and return token type [authTokenType] and value [authTokenValue] const { authMode, authTokenValue } = validateAuthMode({ @@ -87,7 +88,7 @@ const requireAuth = ({ req.authData = { authMode, - authPayload + authPayload // User, ServiceAccount, ServiceTokenData } return next(); diff --git a/backend/src/middleware/requireBotAuth.ts b/backend/src/middleware/requireBotAuth.ts index c06f1c861..089f570c8 100644 --- a/backend/src/middleware/requireBotAuth.ts +++ b/backend/src/middleware/requireBotAuth.ts @@ -1,32 +1,28 @@ import { Request, Response, NextFunction } from 'express'; +import { Types } from 'mongoose'; import { Bot } from '../models'; import { validateMembership } from '../helpers/membership'; +import { validateClientForBot } from '../helpers/bot'; import { AccountNotFoundError } from '../utils/errors'; type req = 'params' | 'body' | 'query'; const requireBotAuth = ({ acceptedRoles, - location = 'params' + locationBotId = 'params' }: { - acceptedRoles: string[]; - location?: req; + acceptedRoles: Array<'admin' | 'member'>; + locationBotId?: req; }) => { return async (req: Request, res: Response, next: NextFunction) => { - const bot = await Bot.findById(req[location].botId); + const { botId } = req[locationBotId]; - if (!bot) { - return next(AccountNotFoundError({message: 'Failed to locate Bot account'})) - } - - await validateMembership({ - userId: req.user._id, - workspaceId: bot.workspace, + req.bot = await validateClientForBot({ + authData: req.authData, + botId: new Types.ObjectId(botId), acceptedRoles }); - req.bot = bot; - next(); } } diff --git a/backend/src/middleware/requireIntegrationAuth.ts b/backend/src/middleware/requireIntegrationAuth.ts index 51051584c..bcde94f34 100644 --- a/backend/src/middleware/requireIntegrationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuth.ts @@ -1,7 +1,9 @@ import { Request, Response, NextFunction } from 'express'; +import { Types } from 'mongoose'; import { Integration, IntegrationAuth } from '../models'; import { IntegrationService } from '../services'; import { validateMembership } from '../helpers/membership'; +import { validateClientForIntegration } from '../helpers/integration'; import { IntegrationNotFoundError, UnauthorizedRequestError } from '../utils/errors'; /** @@ -13,42 +15,24 @@ import { IntegrationNotFoundError, UnauthorizedRequestError } from '../utils/err const requireIntegrationAuth = ({ acceptedRoles }: { - acceptedRoles: string[]; + acceptedRoles: Array<'admin' | 'member'>; }) => { return async (req: Request, res: Response, next: NextFunction) => { - // integration authorization middleware - const { integrationId } = req.params; - // validate integration accessibility - const integration = await Integration.findOne({ - _id: integrationId - }); - - if (!integration) { - return next(IntegrationNotFoundError({message: 'Failed to locate Integration'})) - } - - await validateMembership({ - userId: req.user._id, - workspaceId: integration.workspace, + const { integration, accessToken } = await validateClientForIntegration({ + authData: req.authData, + integrationId: new Types.ObjectId(integrationId), acceptedRoles }); - const integrationAuth = await IntegrationAuth.findOne({ - _id: integration.integrationAuth - }).select( - '+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt' - ); - - if (!integrationAuth) { - return next(UnauthorizedRequestError({message: 'Failed to locate Integration Authentication credentials'})) + if (integration) { + req.integration = integration; + } + + if (accessToken) { + req.accessToken = accessToken; } - - req.integration = integration; - req.accessToken = await IntegrationService.getIntegrationAuthAccess({ - integrationAuthId: integrationAuth._id.toString() - }); return next(); }; diff --git a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts index 07f347ecc..af1eb1a53 100644 --- a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts @@ -1,7 +1,9 @@ import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; import { Request, Response, NextFunction } from 'express'; import { IntegrationAuth, IWorkspace } from '../models'; import { IntegrationService } from '../services'; +import { validateClientForIntegrationAuth } from '../helpers/integrationAuth'; import { validateMembership } from '../helpers/membership'; import { UnauthorizedRequestError } from '../utils/errors'; @@ -19,36 +21,26 @@ const requireIntegrationAuthorizationAuth = ({ attachAccessToken = true, location = 'params' }: { - acceptedRoles: string[]; + acceptedRoles: Array<'admin' | 'member'>; attachAccessToken?: boolean; location?: req; }) => { return async (req: Request, res: Response, next: NextFunction) => { const { integrationAuthId } = req[location]; - const integrationAuth = await IntegrationAuth.findOne({ - _id: integrationAuthId - }) - .populate<{ workspace: IWorkspace }>('workspace') - .select( - '+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt' - ); - if (!integrationAuth) { - return next(UnauthorizedRequestError({message: 'Failed to locate Integration Authorization credentials'})) - } - - await validateMembership({ - userId: req.user._id, - workspaceId: integrationAuth.workspace._id, - acceptedRoles + const { integrationAuth, accessToken } = await validateClientForIntegrationAuth({ + authData: req.authData, + integrationAuthId: new Types.ObjectId(integrationAuthId), + acceptedRoles, + attachAccessToken }); + + if (integrationAuth) { + req.integrationAuth = integrationAuth; + } - req.integrationAuth = integrationAuth; - if (attachAccessToken) { - const access = await IntegrationService.getIntegrationAuthAccess({ - integrationAuthId: integrationAuth._id.toString() - }); - req.accessToken = access.accessToken; + if (accessToken) { + req.accessToken = accessToken; } return next(); diff --git a/backend/src/middleware/requireMembershipAuth.ts b/backend/src/middleware/requireMembershipAuth.ts index 136fabadb..851230371 100644 --- a/backend/src/middleware/requireMembershipAuth.ts +++ b/backend/src/middleware/requireMembershipAuth.ts @@ -1,9 +1,13 @@ +import { Types } from 'mongoose'; import { Request, Response, NextFunction } from 'express'; import { UnauthorizedRequestError } from '../utils/errors'; import { Membership, } from '../models'; -import { validateMembership } from '../helpers/membership'; +import { + validateClientForMembership, + validateMembership +} from '../helpers/membership'; type req = 'params' | 'body' | 'query'; @@ -16,43 +20,25 @@ type req = 'params' | 'body' | 'query'; */ const requireMembershipAuth = ({ acceptedRoles, - location = 'params' + locationMembershipId = 'params' }: { - acceptedRoles: string[]; - location?: req; + acceptedRoles: Array<'admin' | 'member'>; + locationMembershipId: req }) => { return async ( req: Request, res: Response, next: NextFunction ) => { - try { - const { membershipId } = req[location]; - - const membership = await Membership.findById(membershipId); - - if (!membership) throw new Error('Failed to find target membership'); - - const userMembership = await Membership.findOne({ - workspace: membership.workspace - }); - - if (!userMembership) throw new Error('Failed to validate own membership') - - const targetMembership = await validateMembership({ - userId: req.user._id, - workspaceId: membership.workspace, - acceptedRoles - }); - - req.targetMembership = targetMembership; - - return next(); - } catch (err) { - return next(UnauthorizedRequestError({ - message: 'Unable to validate workspace membership' - })); - } + const { membershipId } = req[locationMembershipId]; + + req.targetMembership = await validateClientForMembership({ + authData: req.authData, + membershipId: new Types.ObjectId(membershipId), + acceptedRoles + }); + + return next(); } } diff --git a/backend/src/middleware/requireMembershipOrgAuth.ts b/backend/src/middleware/requireMembershipOrgAuth.ts index ea9ed9afc..b34c9c5e2 100644 --- a/backend/src/middleware/requireMembershipOrgAuth.ts +++ b/backend/src/middleware/requireMembershipOrgAuth.ts @@ -1,11 +1,17 @@ +import { Types } from 'mongoose'; import { Request, Response, NextFunction } from 'express'; import { UnauthorizedRequestError } from '../utils/errors'; import { MembershipOrg } from '../models'; -import { validateMembershipOrg } from '../helpers/membershipOrg'; +import { + validateClientForMembershipOrg, + validateMembershipOrg +} from '../helpers/membershipOrg'; +// TODO: transform + type req = 'params' | 'body' | 'query'; /** @@ -18,32 +24,23 @@ type req = 'params' | 'body' | 'query'; const requireMembershipOrgAuth = ({ acceptedRoles, acceptedStatuses, - location = 'params' + locationMembershipOrgId = 'params' }: { - acceptedRoles: string[]; - acceptedStatuses: string[]; - location?: req; + acceptedRoles: Array<'owner' | 'admin' | 'member'>; + acceptedStatuses: Array<'invited' | 'accepted'>; + locationMembershipOrgId?: req; }) => { return async (req: Request, res: Response, next: NextFunction) => { - try { - const { membershipId } = req[location]; - const membershipOrg = await MembershipOrg.findById(membershipId); - - if (!membershipOrg) throw new Error('Failed to find target organization membership'); - - req.targetMembership = await validateMembershipOrg({ - userId: req.user._id, - organizationId: membershipOrg.organization, - acceptedRoles, - acceptedStatuses - }); - - return next(); - } catch (err) { - return next(UnauthorizedRequestError({ - message: 'Unable to validate organization membership' - })); - } + const { membershipId } = req[locationMembershipOrgId]; + + req.membershipOrg = await validateClientForMembershipOrg({ + authData: req.authData, + membershipOrgId: new Types.ObjectId(membershipId), + acceptedRoles, + acceptedStatuses + }); + + return next(); } } diff --git a/backend/src/middleware/requireOrganizationAuth.ts b/backend/src/middleware/requireOrganizationAuth.ts index 5768b4b36..f6d8eb8ce 100644 --- a/backend/src/middleware/requireOrganizationAuth.ts +++ b/backend/src/middleware/requireOrganizationAuth.ts @@ -3,6 +3,7 @@ import { Types } from 'mongoose'; import { IOrganization, MembershipOrg } from '../models'; import { UnauthorizedRequestError, ValidationError } from '../utils/errors'; import { validateMembershipOrg } from '../helpers/membershipOrg'; +import { validateClientForOrganization } from '../helpers/organization'; type req = 'params' | 'body' | 'query'; @@ -16,20 +17,29 @@ type req = 'params' | 'body' | 'query'; const requireOrganizationAuth = ({ acceptedRoles, acceptedStatuses, - location = 'params' + locationOrganizationId = 'params' }: { - acceptedRoles: string[]; - acceptedStatuses: string[]; - location?: req; + acceptedRoles: Array<'owner' | 'admin' | 'member'>; + acceptedStatuses: Array<'invited' | 'accepted'>; + locationOrganizationId?: req; }) => { return async (req: Request, res: Response, next: NextFunction) => { - const { organizationId } = req[location]; - req.membershipOrg = await validateMembershipOrg({ - userId: req.user._id, + const { organizationId } = req[locationOrganizationId]; + + const { organization, membershipOrg } = await validateClientForOrganization({ + authData: req.authData, organizationId: new Types.ObjectId(organizationId), acceptedRoles, acceptedStatuses }); + + if (organization) { + req.organization = organization; + } + + if (membershipOrg) { + req.membershipOrg = membershipOrg; + } return next(); }; diff --git a/backend/src/middleware/requireSecretAuth.ts b/backend/src/middleware/requireSecretAuth.ts index c86b7b680..1462d67b0 100644 --- a/backend/src/middleware/requireSecretAuth.ts +++ b/backend/src/middleware/requireSecretAuth.ts @@ -1,12 +1,17 @@ import { Request, Response, NextFunction } from 'express'; +import { Types } from 'mongoose'; import { UnauthorizedRequestError, SecretNotFoundError } from '../utils/errors'; import { Secret } from '../models'; import { validateMembership } from '../helpers/membership'; +import { + validateClientForSecret +} from '../helpers/secrets'; // note: used for old /v1/secret and /v2/secret routes. -// newer /v2/secrets routes use [requireSecretsAuth] middleware +// newer /v2/secrets routes use [requireSecretsAuth] middleware with the exception +// of some /ee endpoints /** * Validate if user on request has proper membership to modify secret. @@ -15,34 +20,25 @@ import { * @param {String[]} obj.location - location of [workspaceId] on request (e.g. params, body) for parsing */ const requireSecretAuth = ({ - acceptedRoles + acceptedRoles, + requiredPermissions }: { - acceptedRoles: string[]; + acceptedRoles: Array<'admin' | 'member'>; + requiredPermissions: string[]; }) => { return async (req: Request, res: Response, next: NextFunction) => { - try { - const { secretId } = req.params; - - const secret = await Secret.findById(secretId); - - if (!secret) { - return next(SecretNotFoundError({ - message: 'Failed to find secret' - })); - } - - await validateMembership({ - userId: req.user._id, - workspaceId: secret.workspace, - acceptedRoles - }); - - req._secret = secret; + const { secretId } = req.params; + + const secret = await validateClientForSecret({ + authData: req.authData, + secretId: new Types.ObjectId(secretId), + acceptedRoles, + requiredPermissions + }); + + req._secret = secret; - next(); - } catch (err) { - return next(UnauthorizedRequestError({ message: 'Unable to authenticate secret' })); - } + next(); } } diff --git a/backend/src/middleware/requireSecretsAuth.ts b/backend/src/middleware/requireSecretsAuth.ts index 5a550156a..a076a3f1a 100644 --- a/backend/src/middleware/requireSecretsAuth.ts +++ b/backend/src/middleware/requireSecretsAuth.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction } from 'express'; +import { Types } from 'mongoose'; import { UnauthorizedRequestError } from '../utils/errors'; import { Secret, Membership } from '../models'; import { validateClientForSecrets } from '../helpers/secrets'; @@ -24,7 +25,7 @@ const requireSecretsAuth = ({ req.secrets = await validateClientForSecrets({ authData: req.authData, - secretIds: [req.body.secretIds], + secretIds: secretIds.map((secretId: string) => new Types.ObjectId(secretId)), requiredPermissions }); diff --git a/backend/src/middleware/requireServiceAccountWorkspacePermissionAuth.ts b/backend/src/middleware/requireServiceAccountWorkspacePermissionAuth.ts index d28c72352..0ceb4f598 100644 --- a/backend/src/middleware/requireServiceAccountWorkspacePermissionAuth.ts +++ b/backend/src/middleware/requireServiceAccountWorkspacePermissionAuth.ts @@ -14,8 +14,8 @@ const requireServiceAccountWorkspacePermissionAuth = ({ acceptedStatuses, location = 'params' }: { - acceptedRoles: string[]; - acceptedStatuses: string[]; + acceptedRoles: Array<'owner' | 'admin' | 'member'>; + acceptedStatuses: Array<'invited' | 'accepted'>; location?: req; }) => { return async (req: Request, res: Response, next: NextFunction) => { diff --git a/backend/src/middleware/requireServiceTokenDataAuth.ts b/backend/src/middleware/requireServiceTokenDataAuth.ts index 513cbe604..7715991ba 100644 --- a/backend/src/middleware/requireServiceTokenDataAuth.ts +++ b/backend/src/middleware/requireServiceTokenDataAuth.ts @@ -1,5 +1,7 @@ import { Request, Response, NextFunction } from 'express'; +import { Types } from 'mongoose'; import { ServiceToken, ServiceTokenData } from '../models'; +import { validateClientForServiceTokenData } from '../helpers/serviceTokenData'; import { validateMembership } from '../helpers/membership'; import { AccountNotFoundError, UnauthorizedRequestError } from '../utils/errors'; @@ -9,30 +11,17 @@ const requireServiceTokenDataAuth = ({ acceptedRoles, location = 'params' }: { - acceptedRoles: string[]; + acceptedRoles: Array<'admin' | 'member'>; location?: req; }) => { return async (req: Request, res: Response, next: NextFunction) => { const { serviceTokenDataId } = req[location]; - - const serviceTokenData = await ServiceTokenData - .findById(req[location].serviceTokenDataId) - .select('+encryptedKey +iv +tag').populate('user'); - - if (!serviceTokenData) { - return next(AccountNotFoundError({ message: 'Failed to locate service token data' })); - } - - if (req.user) { - // case: jwt auth - await validateMembership({ - userId: req.user._id, - workspaceId: serviceTokenData.workspace, - acceptedRoles - }); - } - - req.serviceTokenData = serviceTokenData; + + req.serviceTokenData = await validateClientForServiceTokenData({ + authData: req.authData, + serviceTokenDataId: new Types.ObjectId(serviceTokenDataId), + acceptedRoles + }); next(); } diff --git a/backend/src/middleware/requireWorkspaceAuth.ts b/backend/src/middleware/requireWorkspaceAuth.ts index 64c1c37f2..5d094f972 100644 --- a/backend/src/middleware/requireWorkspaceAuth.ts +++ b/backend/src/middleware/requireWorkspaceAuth.ts @@ -19,13 +19,12 @@ const requireWorkspaceAuth = ({ locationEnvironment = undefined, requiredPermissions = [] }: { - acceptedRoles: string[]; + acceptedRoles: Array<'admin' | 'member'>; locationWorkspaceId: req; locationEnvironment?: req | undefined; requiredPermissions?: string[]; }) => { return async (req: Request, res: Response, next: NextFunction) => { - const workspaceId = req[locationWorkspaceId]?.workspaceId; const environment = locationEnvironment ? req[locationEnvironment]?.environment : undefined; @@ -34,6 +33,7 @@ const requireWorkspaceAuth = ({ authData: req.authData, workspaceId: new Types.ObjectId(workspaceId), environment, + acceptedRoles, requiredPermissions }); diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 22a8713e0..2a77c95e9 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -13,6 +13,7 @@ import { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE } from "../variables"; export interface IIntegration { @@ -42,7 +43,8 @@ export interface IIntegration { | 'railway' | 'flyio' | 'circleci' - | 'travisci'; + | 'travisci' + | 'supabase'; integrationAuth: Types.ObjectId; } @@ -122,6 +124,7 @@ const integrationSchema = new Schema( INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE ], required: true, }, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 64c4f2d69..ead969fc7 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -1,4 +1,4 @@ -import { Schema, model, Types } from "mongoose"; +import { Schema, model, Types, Document } from "mongoose"; import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_AWS_PARAMETER_STORE, @@ -13,12 +13,13 @@ import { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE, } from "../variables"; -export interface IIntegrationAuth { +export interface IIntegrationAuth extends Document { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'gitlab' | 'render' | 'railway' | 'flyio' | 'azure-key-vault' | 'circleci' | 'travisci' | 'aws-parameter-store' | 'aws-secret-manager'; + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'gitlab' | 'render' | 'railway' | 'flyio' | 'azure-key-vault' | 'circleci' | 'travisci' | 'supabase' | 'aws-parameter-store' | 'aws-secret-manager'; teamId: string; accountId: string; refreshCiphertext?: string; @@ -56,6 +57,7 @@ const integrationAuthSchema = new Schema( INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE ], required: true, }, diff --git a/backend/src/models/membershipOrg.ts b/backend/src/models/membershipOrg.ts index b5013acbb..540a4451b 100644 --- a/backend/src/models/membershipOrg.ts +++ b/backend/src/models/membershipOrg.ts @@ -1,7 +1,7 @@ -import { Schema, model, Types } from 'mongoose'; +import { Schema, model, Types, Document } from 'mongoose'; import { OWNER, ADMIN, MEMBER, INVITED, ACCEPTED } from '../variables'; -export interface IMembershipOrg { +export interface IMembershipOrg extends Document { _id: Types.ObjectId; user: Types.ObjectId; inviteEmail: string; diff --git a/backend/src/routes/v2/secret.ts b/backend/src/routes/v2/secret.ts index ff9764ced..f2c825ba8 100644 --- a/backend/src/routes/v2/secret.ts +++ b/backend/src/routes/v2/secret.ts @@ -10,7 +10,9 @@ import { ADMIN, MEMBER, AUTH_MODE_JWT, - AUTH_MODE_SERVICE_TOKEN + AUTH_MODE_SERVICE_TOKEN, + PERMISSION_READ_SECRETS, + PERMISSION_WRITE_SECRETS } from '../../variables'; import { CreateSecretRequestBody, ModifySecretRequestBody } from '../../types/secret'; import { secretController } from '../../controllers/v2'; @@ -75,7 +77,8 @@ router.get( acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_SERVICE_TOKEN] }), requireSecretAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + requiredPermissions: [PERMISSION_READ_SECRETS] }), validateRequest, secretController.getSecret @@ -103,7 +106,8 @@ router.delete( acceptedAuthModes: [AUTH_MODE_JWT] }), requireSecretAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + requiredPermissions: [PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS] }), param('secretId').isMongoId(), validateRequest, diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index 792491557..57657b079 100644 --- a/backend/src/routes/v2/secrets.ts +++ b/backend/src/routes/v2/secrets.ts @@ -1,5 +1,6 @@ import express from 'express'; const router = express.Router(); +import { Types } from 'mongoose'; import { requireAuth, requireWorkspaceAuth, @@ -47,7 +48,7 @@ router.post( if (secretIds.length > 0) { req.secrets = await validateClientForSecrets({ authData: req.authData, - secretIds, + secretIds: secretIds.map((secretId: string) => new Types.ObjectId(secretId)), requiredPermissions: [] }); } diff --git a/backend/src/routes/v2/serviceAccounts.ts b/backend/src/routes/v2/serviceAccounts.ts index fef0c87e9..6f0db91b7 100644 --- a/backend/src/routes/v2/serviceAccounts.ts +++ b/backend/src/routes/v2/serviceAccounts.ts @@ -53,7 +53,7 @@ router.post( requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], acceptedStatuses: [ACCEPTED], - location: 'body' + locationOrganizationId: 'body' }), serviceAccountsController.createServiceAccount ); diff --git a/backend/src/routes/v2/workspace.ts b/backend/src/routes/v2/workspace.ts index d2180624b..e258636fb 100644 --- a/backend/src/routes/v2/workspace.ts +++ b/backend/src/routes/v2/workspace.ts @@ -106,7 +106,8 @@ router.patch( // TODO - rewire dashboard to this route locationWorkspaceId: 'params' }), requireMembershipAuth({ - acceptedRoles: [ADMIN] + acceptedRoles: [ADMIN], + locationMembershipId: 'params' }), workspaceController.updateWorkspaceMembership ); @@ -124,7 +125,8 @@ router.delete( // TODO - rewire dashboard to this route locationWorkspaceId: 'params' }), requireMembershipAuth({ - acceptedRoles: [ADMIN] + acceptedRoles: [ADMIN], + locationMembershipId: 'params' }), workspaceController.deleteWorkspaceMembership ); diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 7b3a20a6b..0fd634c33 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -1,3 +1,4 @@ +import { Types } from 'mongoose'; import { handleOAuthExchangeHelper, syncIntegrationsHelper, @@ -67,7 +68,7 @@ class IntegrationService { * @param {String} obj.integrationAuthId - id of integration auth * @param {String} refreshToken - decrypted refresh token */ - static async getIntegrationAuthRefresh({ integrationAuthId }: { integrationAuthId: string}) { + static async getIntegrationAuthRefresh({ integrationAuthId }: { integrationAuthId: Types.ObjectId}) { return await getIntegrationAuthRefreshHelper({ integrationAuthId }); @@ -80,7 +81,7 @@ class IntegrationService { * @param {String} obj.integrationAuthId - id of integration auth * @param {String} accessToken - decrypted access token */ - static async getIntegrationAuthAccess({ integrationAuthId }: { integrationAuthId: string}) { + static async getIntegrationAuthAccess({ integrationAuthId }: { integrationAuthId: Types.ObjectId }) { return await getIntegrationAuthAccessHelper({ integrationAuthId }); diff --git a/backend/src/services/TelemetryService.ts b/backend/src/services/TelemetryService.ts index 0566439b4..248ad3633 100644 --- a/backend/src/services/TelemetryService.ts +++ b/backend/src/services/TelemetryService.ts @@ -8,7 +8,9 @@ import { } from '../config'; import { IUser, + User, IServiceAccount, + ServiceAccount, IServiceTokenData } from '../models'; import { @@ -56,7 +58,7 @@ class Telemetry { }: { user?: IUser; serviceAccount?: IServiceAccount; - serviceTokenData?: IServiceTokenData; + serviceTokenData?: any; // TODO: fix (it's ServiceTokenData with user populated) }) => { let distinctId = ''; @@ -65,11 +67,13 @@ class Telemetry { } if (serviceAccount) { - distinctId = `sa.${serviceAccount._id}`; + distinctId = `sa.${serviceAccount._id.toString()}`; } - - if (serviceTokenData) { - distinctId = `st.${serviceTokenData._id}`; + + if (serviceTokenData?.user && serviceTokenData?.user instanceof User) { + distinctId = serviceTokenData.user.email; + } else if (serviceTokenData?.serviceAccount && serviceTokenData?.serviceAccount instanceof ServiceAccount) { + distinctId = `sa.${serviceTokenData.serviceAccount._id.toString()}`; } if (distinctId === '') { diff --git a/backend/src/utils/addDevelopmentUser.ts b/backend/src/utils/addDevelopmentUser.ts index 585740a6b..136d91a98 100644 --- a/backend/src/utils/addDevelopmentUser.ts +++ b/backend/src/utils/addDevelopmentUser.ts @@ -8,17 +8,18 @@ import { Key, Membership, MembershipOrg, Organization, User, Workspace } from ". import { Types } from 'mongoose'; import { getNodeEnv } from '../config'; -export const createTestUserForDevelopment = async () => { - if (getNodeEnv() === "development") { - const testUserEmail = "test@localhost.local" - const testUserPassword = "testInfisical1" - const testUserId = "63cefa6ec8d3175601cfa980" - const testWorkspaceId = "63cefb15c8d3175601cfa989" - const testOrgId = "63cefb15c8d3175601cfa985" - const testMembershipId = "63cefb159185d9aa3ef0cf35" - const testMembershipOrgId = "63cefb159185d9aa3ef0cf31" - const testWorkspaceKeyId = "63cf48f0225e6955acec5eff" +export const testUserEmail = "test@localhost.local" +export const testUserPassword = "testInfisical1" +export const testUserId = "63cefa6ec8d3175601cfa980" +export const testWorkspaceId = "63cefb15c8d3175601cfa989" +export const testOrgId = "63cefb15c8d3175601cfa985" +export const testMembershipId = "63cefb159185d9aa3ef0cf35" +export const testMembershipOrgId = "63cefb159185d9aa3ef0cf31" +export const testWorkspaceKeyId = "63cf48f0225e6955acec5eff" +export const plainTextWorkspaceKey = "543fef8224813a46230b0a50a46c5fb2" +export const createTestUserForDevelopment = async () => { + if (getNodeEnv() === "development" || getNodeEnv() === "test") { const testUser = { _id: testUserId, email: testUserEmail, diff --git a/backend/src/utils/errors.ts b/backend/src/utils/errors.ts index 4799728b9..987215091 100644 --- a/backend/src/utils/errors.ts +++ b/backend/src/utils/errors.ts @@ -73,6 +73,16 @@ export const ValidationError = (error?: Partial) => new Req stack: error?.stack }); +//* ----->[INTEGRATION AUTH ERRORS]<----- +export const IntegrationAuthNotFoundError = (error?: Partial) => new RequestError({ + logLevel: error?.logLevel ?? LogLevel.ERROR, + statusCode: error?.statusCode ?? 404, + type: error?.type ?? 'integration_auth_not_found_error', + message: error?.message ?? 'The requested integration authorization was not found', + context: error?.context, + stack: error?.stack +}); + //* ----->[INTEGRATION ERRORS]<----- export const IntegrationNotFoundError = (error?: Partial) => new RequestError({ logLevel: error?.logLevel ?? LogLevel.ERROR, @@ -202,4 +212,13 @@ export const ServiceAccountKeyNotFoundError = (error?: Partial) => new RequestError({ + logLevel: error?.logLevel ?? LogLevel.ERROR, + statusCode: error?.statusCode ?? 404, + type: error?.type ?? 'bot_not_found_error', + message: error?.message ?? 'The requested bot was not found', + context: error?.context, + stack: error?.stack +}) + //* ----->[MISC ERRORS]<----- diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 3029b265f..979de21ab 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -19,6 +19,7 @@ import { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_AZURE_TOKEN_URL, @@ -36,6 +37,7 @@ import { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, + INTEGRATION_SUPABASE_API_URL, getIntegrationOptions } from "./integration"; import { OWNER, ADMIN, MEMBER, INVITED, ACCEPTED } from "./organization"; @@ -102,6 +104,7 @@ export { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_AZURE_TOKEN_URL, @@ -119,6 +122,7 @@ export { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, + INTEGRATION_SUPABASE_API_URL, EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS, ACTION_LOGIN, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 3c25c79da..bbfd7f107 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -21,6 +21,7 @@ const INTEGRATION_RAILWAY = "railway"; const INTEGRATION_FLYIO = "flyio"; const INTEGRATION_CIRCLECI = "circleci"; const INTEGRATION_TRAVISCI = "travisci"; +const INTEGRATION_SUPABASE = 'supabase'; const INTEGRATION_SET = new Set([ INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, @@ -32,6 +33,7 @@ const INTEGRATION_SET = new Set([ INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE ]); // integration types @@ -57,6 +59,7 @@ const INTEGRATION_RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2"; const INTEGRATION_FLYIO_API_URL = "https://api.fly.io/graphql"; const INTEGRATION_CIRCLECI_API_URL = "https://circleci.com/api"; const INTEGRATION_TRAVISCI_API_URL = "https://api.travis-ci.com"; +const INTEGRATION_SUPABASE_API_URL = 'https://api.supabase.com'; const getIntegrationOptions = () => { const INTEGRATION_OPTIONS = [ @@ -178,6 +181,15 @@ const getIntegrationOptions = () => { clientId: '', docsLink: '' }, + { + name: 'Supabase', + slug: 'supabase', + image: 'Supabase.png', + isAvailable: true, + type: 'pat', + clientId: '', + docsLink: '' + }, { name: 'Google Cloud Platform', slug: 'gcp', @@ -207,6 +219,7 @@ export { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_AZURE_TOKEN_URL, @@ -224,5 +237,6 @@ export { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, + INTEGRATION_SUPABASE_API_URL, getIntegrationOptions }; diff --git a/backend/test-resources/env-vars.js b/backend/test-resources/env-vars.js index a7542728f..6702649c1 100644 --- a/backend/test-resources/env-vars.js +++ b/backend/test-resources/env-vars.js @@ -3,3 +3,8 @@ process.env.MONGO_URL = 'mongodb://test:test1234@localhost:27018/?authSource=admin'; process.env.MONGO_USERNAME = 'test'; process.env.MONGO_PASSWORD = 'test1234'; +process.env.NODE_ENV = 'test'; +process.env.JWT_SIGNUP_SECRET= "38ea90fb7998b92176080f457d890392" +process.env.JWT_REFRESH_SECRET= "7764c7bbf3928ad501591a3e005eb364" +process.env.JWT_AUTH_SECRET= "5239fea3a4720c0e524f814a540e14a2" +process.env.JWT_SERVICE_SECRET= "8509fb8b90c9b53e9e61d1e35826dcb5" \ No newline at end of file diff --git a/backend/tests/data/batch-create-secrets-with-some-missing-params.json b/backend/tests/data/batch-create-secrets-with-some-missing-params.json new file mode 100644 index 000000000..40d07827c --- /dev/null +++ b/backend/tests/data/batch-create-secrets-with-some-missing-params.json @@ -0,0 +1,51 @@ +[ + { + "method": "POST", + "secret": { + "workspace": "63cefb15c8d3175601cfa989", + "type": "shared", + "tags": [], + "environment": "dev", + "secretKeyCiphertext": "eaX9a2g=", + "secretKeyIV": "YJ4adgI/wEHifGdtT9reaA==", + "secretKeyTag": "dP73x3wrq7pqxzAHo+bfPA==", + "secretValueCiphertext": "cw==", + "secretValueIV": "7ksYWWZ3+9rzLG5NpEbEgg==", + "secretValueTag": "H0YQ8vrhiVJ0XSW4nBJdQA==", + "secretCommentCiphertext": "", + "secretCommentIV": "yXhMdLdA9q7Vaw4UUaeBYA==", + "secretCommentTag": "qMj7SHESM5Jn+C2qpbw2pA==" + } + }, + { + "method": "POST", + "secret": { + "workspace": "63cefb15c8d3175601cfa989", + "type": "shared", + "tags": [], + "environment": "dev", + "secretKeyIV": "YJ4adgI/wEHifGdtT9reaA==", + "secretKeyTag": "dP73x3wrq7pqxzAHo+bfPA==", + "secretValueIV": "7ksYWWZ3+9rzLG5NpEbEgg==", + "secretValueTag": "H0YQ8vrhiVJ0XSW4nBJdQA==", + "secretCommentIV": "yXhMdLdA9q7Vaw4UUaeBYA==", + "secretCommentTag": "qMj7SHESM5Jn+C2qpbw2pA==" + } + }, + { + "method": "POST", + "secret": { + "workspace": "63cefb15c8d3175601cfa989", + "type": "shared", + "tags": [], + "environment": "dev", + "secretKeyIV": "YJ4adgI/wEHifGdtT9reaA==", + "secretKeyTag": "dP73x3wrq7pqxzAHo+bfPA==", + "secretValueCiphertext": "cw==", + "secretValueTag": "H0YQ8vrhiVJ0XSW4nBJdQA==", + "secretCommentCiphertext": "", + "secretCommentIV": "yXhMdLdA9q7Vaw4UUaeBYA==", + "secretCommentTag": "qMj7SHESM5Jn+C2qpbw2pA==" + } + } +] \ No newline at end of file diff --git a/backend/tests/data/batch-secrets-no-override.json b/backend/tests/data/batch-secrets-no-override.json new file mode 100644 index 000000000..7236c907a --- /dev/null +++ b/backend/tests/data/batch-secrets-no-override.json @@ -0,0 +1,56 @@ +[ + { + "method": "POST", + "secret": { + "workspace": "63cefb15c8d3175601cfa989", + "type": "shared", + "tags": [], + "environment": "dev", + "secretKeyCiphertext": "eaX9a2g=", + "secretKeyIV": "YJ4adgI/wEHifGdtT9reaA==", + "secretKeyTag": "dP73x3wrq7pqxzAHo+bfPA==", + "secretValueCiphertext": "cw==", + "secretValueIV": "7ksYWWZ3+9rzLG5NpEbEgg==", + "secretValueTag": "H0YQ8vrhiVJ0XSW4nBJdQA==", + "secretCommentCiphertext": "", + "secretCommentIV": "yXhMdLdA9q7Vaw4UUaeBYA==", + "secretCommentTag": "qMj7SHESM5Jn+C2qpbw2pA==" + } + }, + { + "method": "POST", + "secret": { + "workspace": "63cefb15c8d3175601cfa989", + "type": "shared", + "tags": [], + "environment": "dev", + "secretKeyCiphertext": "eaX9a2g=", + "secretKeyIV": "YJ4adgI/wEHifGdtT9reaA==", + "secretKeyTag": "dP73x3wrq7pqxzAHo+bfPA==", + "secretValueCiphertext": "cw==", + "secretValueIV": "7ksYWWZ3+9rzLG5NpEbEgg==", + "secretValueTag": "H0YQ8vrhiVJ0XSW4nBJdQA==", + "secretCommentCiphertext": "", + "secretCommentIV": "yXhMdLdA9q7Vaw4UUaeBYA==", + "secretCommentTag": "qMj7SHESM5Jn+C2qpbw2pA==" + } + }, + { + "method": "POST", + "secret": { + "workspace": "63cefb15c8d3175601cfa989", + "type": "shared", + "tags": [], + "environment": "dev", + "secretKeyCiphertext": "eaX9a2g=", + "secretKeyIV": "YJ4adgI/wEHifGdtT9reaA==", + "secretKeyTag": "dP73x3wrq7pqxzAHo+bfPA==", + "secretValueCiphertext": "cw==", + "secretValueIV": "7ksYWWZ3+9rzLG5NpEbEgg==", + "secretValueTag": "H0YQ8vrhiVJ0XSW4nBJdQA==", + "secretCommentCiphertext": "", + "secretCommentIV": "yXhMdLdA9q7Vaw4UUaeBYA==", + "secretCommentTag": "qMj7SHESM5Jn+C2qpbw2pA==" + } + } +] \ No newline at end of file diff --git a/backend/tests/data/batch-secrets-with-overrides.json b/backend/tests/data/batch-secrets-with-overrides.json new file mode 100644 index 000000000..6173289aa --- /dev/null +++ b/backend/tests/data/batch-secrets-with-overrides.json @@ -0,0 +1,38 @@ +[ + { + "method": "POST", + "secret": { + "workspace": "63cefb15c8d3175601cfa989", + "type": "shared", + "environment": "dev", + "secretKeyCiphertext": "IVMtGWE=", + "secretKeyIV": "BDsG7/ylk7mT8MrIMn0e7w==", + "secretKeyTag": "1ujy08fctmZ1xTXMYr23UQ==", + "secretValueCiphertext": "I9psUg==", + "secretValueIV": "W+DJETpCerHkFv8AR9Fv4w==", + "secretValueTag": "yODOeN3HBr/usly4VSMt9w==", + "secretCommentCiphertext": "", + "secretCommentIV": "QET7oX2ZiuLDSzwrkeL2Ig==", + "secretCommentTag": "6P3xeA9eO+3Wp66ROHXgfg==" + } + }, + { + "method": "POST", + "secret": { + "workspace": "63cefb15c8d3175601cfa989", + "type": "personal", + "user": "63cefa6ec8d3175601cfa980", + "tags": [], + "environment": "dev", + "secretKeyCiphertext": "Q7lyRO8=", + "secretKeyIV": "yz8koc3d63ywJMiGXpCNSw==", + "secretKeyTag": "j2bMQ2d4sDZKA0OaKM5SXA==", + "secretValueCiphertext": "X4kaiShmtGZt", + "secretValueIV": "p/GdbksLVveNLsV3vz5GLA==", + "secretValueTag": "//dhRL+pagecavHJCtMPWg==", + "secretCommentCiphertext": "", + "secretCommentIV": "7eYJzuilvjQPutqrqbd2MQ==", + "secretCommentTag": "LpPv9K0Hhd5noE39Zu9U+w==" + } + } +] \ No newline at end of file diff --git a/backend/tests/helper/helper.ts b/backend/tests/helper/helper.ts new file mode 100644 index 000000000..c59c8d43b --- /dev/null +++ b/backend/tests/helper/helper.ts @@ -0,0 +1,96 @@ +// Helper functions for integration tests + +import axiosInstance from "../../src/config/request"; +import { Secret } from "../../src/models"; +import { testUserEmail, testUserPassword } from "../../src/utils/addDevelopmentUser"; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const crypto = require('crypto') +// eslint-disable-next-line @typescript-eslint/no-var-requires +const jsrp = require('jsrp'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const axios = require('axios'); +import { plainTextWorkspaceKey, testWorkspaceId } from "../../src/utils/addDevelopmentUser"; +import { encryptSymmetric } from "../../src/utils/crypto"; + +interface TokenData { + token: string; + publicKey: string; + encryptedPrivateKey: string; + iv: string; + tag: string; +} + +export const getJWTFromTestUser = (): Promise => { + return new Promise((resolve, reject) => { + const client = new jsrp.client(); + const EMAIL = testUserEmail + const PASSWORD = testUserPassword + + client.init({ + username: EMAIL, + password: PASSWORD, + }, async () => { + const clientPublicKey = client.getPublicKey(); + + // POST: /login1 + const reqBody = { + email: EMAIL, + clientPublicKey + } + + + const loginOneRes = await axiosInstance.post('http://localhost:4000/api/v1/auth/login1', reqBody); + const serverPublicKey = loginOneRes.data.serverPublicKey; + const salt = loginOneRes.data.salt; + + client.setSalt(salt); + client.setServerPublicKey(serverPublicKey); + const clientSharedKey = client.getSharedKey(); // shared Key + const clientProof = client.getProof(); // called M1 + + // POST: /login2 + const reqBody2 = { + email: EMAIL, + clientProof + } + + const response2 = await axiosInstance.post('http://localhost:4000/api/v1/auth/login2', reqBody2); + + resolve(response2.data) + }) + }); +} + +export const getServiceTokenFromTestUser = async () => { + const loggedInUserDetails = await getJWTFromTestUser() + const randomBytes = crypto.randomBytes(16).toString('hex'); + const { ciphertext, iv, tag } = encryptSymmetric({ + plaintext: plainTextWorkspaceKey, + key: randomBytes, + }); + + const newServiceToken = await axiosInstance.post('http://localhost:4000/api/v2/service-token/', { + 'name': "test service token", + 'workspaceId': testWorkspaceId, + 'environment': "dev", + 'encryptedKey': ciphertext, + 'iv': iv, + 'tag': tag, + 'expiresIn': Date.now() + 90000, + 'permissions': ["read"] + }, { + headers: { + 'Authorization': `Bearer ${loggedInUserDetails.token}` + } + }); + + return `${newServiceToken.data.serviceToken}.${randomBytes}` +} + +export const deleteAllSecrets = async () => { + await Secret.deleteMany() +} + +export const getAllSecrets = async () => { + return await Secret.find() +} \ No newline at end of file diff --git a/backend/tests/integration-tests/routes/v2/secrets.test.ts b/backend/tests/integration-tests/routes/v2/secrets.test.ts new file mode 100644 index 000000000..4df6056fe --- /dev/null +++ b/backend/tests/integration-tests/routes/v2/secrets.test.ts @@ -0,0 +1,408 @@ +import request from 'supertest' +import main from '../../../../src/index' +import { testWorkspaceId } from '../../../../src/utils/addDevelopmentUser'; +import { deleteAllSecrets, getAllSecrets, getJWTFromTestUser, getServiceTokenFromTestUser } from '../../../helper/helper'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const batchSecretRequestWithNoOverride = require('../../../data/batch-secrets-no-override.json'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const batchSecretRequestWithOverrides = require('../../../data/batch-secrets-with-overrides.json'); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const batchSecretRequestWithBadRequest = require('../../../data/batch-create-secrets-with-some-missing-params.json'); + +let server: any; +beforeAll(async () => { + server = await main; +}); + +afterAll(async () => { + server.close(); +}); + +describe("GET /api/v2/secrets", () => { + describe("Get secrets via JTW", () => { + test("should create secrets and read secrets via jwt", async () => { + try { + // get login details + const loginResponse = await getJWTFromTestUser() + + // create creates + const createSecretsResponse = await request(server) + .post("/api/v2/secrets/batch") + .set('Authorization', `Bearer ${loginResponse.token}`) + .send({ + workspaceId: testWorkspaceId, + environment: "dev", + requests: batchSecretRequestWithNoOverride + }) + + expect(createSecretsResponse.statusCode).toBe(200) + + + const getSecrets = await request(server) + .get("/api/v2/secrets") + .set('Authorization', `Bearer ${loginResponse.token}`) + .query({ + workspaceId: testWorkspaceId, + environment: "dev" + }) + + expect(getSecrets.statusCode).toBe(200) + expect(getSecrets.body).toHaveProperty("secrets") + expect(getSecrets.body.secrets).toHaveLength(3) + expect(getSecrets.body.secrets).toBeInstanceOf(Array); + + getSecrets.body.secrets.forEach((secret: any) => { + expect(secret).toHaveProperty('_id'); + expect(secret._id).toBeTruthy(); + + expect(secret).toHaveProperty('version'); + expect(secret.version).toBeTruthy(); + + expect(secret).toHaveProperty('workspace'); + expect(secret.workspace).toBeTruthy(); + + expect(secret).toHaveProperty('type'); + expect(secret.type).toBeTruthy(); + + expect(secret).toHaveProperty('tags'); + expect(secret.tags).toHaveLength(0); + + expect(secret).toHaveProperty('environment'); + expect(secret.environment).toEqual("dev"); + + expect(secret).toHaveProperty('secretKeyCiphertext'); + expect(secret.secretKeyCiphertext).toBeTruthy(); + + expect(secret).toHaveProperty('secretKeyIV'); + expect(secret.secretKeyIV).toBeTruthy(); + + expect(secret).toHaveProperty('secretKeyTag'); + expect(secret.secretKeyTag).toBeTruthy(); + + expect(secret).toHaveProperty('secretValueCiphertext'); + expect(secret.secretValueCiphertext).toBeTruthy(); + + expect(secret).toHaveProperty('secretValueIV'); + expect(secret.secretValueIV).toBeTruthy(); + + expect(secret).toHaveProperty('secretValueTag'); + expect(secret.secretValueTag).toBeTruthy(); + + expect(secret).toHaveProperty('secretCommentCiphertext'); + expect(secret.secretCommentCiphertext).toBeFalsy(); + + expect(secret).toHaveProperty('secretCommentIV'); + expect(secret.secretCommentIV).toBeTruthy(); + + expect(secret).toHaveProperty('secretCommentTag'); + expect(secret.secretCommentTag).toBeTruthy(); + + expect(secret).toHaveProperty('createdAt'); + expect(secret.createdAt).toBeTruthy(); + + expect(secret).toHaveProperty('updatedAt'); + expect(secret.updatedAt).toBeTruthy(); + }); + } finally { + // clean up + await deleteAllSecrets() + } + }) + + test("Get secrets via jwt when personal overrides exist", async () => { + try { + // get login details + const loginResponse = await getJWTFromTestUser() + + // create creates + const createSecretsResponse = await request(server) + .post("/api/v2/secrets/batch") + .set('Authorization', `Bearer ${loginResponse.token}`) + .send({ + workspaceId: testWorkspaceId, + environment: "dev", + requests: batchSecretRequestWithOverrides + }) + + expect(createSecretsResponse.statusCode).toBe(200) + + const getSecrets = await request(server) + .get("/api/v2/secrets") + .set('Authorization', `Bearer ${loginResponse.token}`) + .query({ + workspaceId: testWorkspaceId, + environment: "dev" + }) + + expect(getSecrets.statusCode).toBe(200) + expect(getSecrets.body).toHaveProperty("secrets") + expect(getSecrets.body.secrets).toHaveLength(2) + expect(getSecrets.body.secrets).toBeInstanceOf(Array); + + getSecrets.body.secrets.forEach((secret: any) => { + expect(secret).toHaveProperty('_id'); + expect(secret._id).toBeTruthy(); + + expect(secret).toHaveProperty('version'); + expect(secret.version).toBeTruthy(); + + expect(secret).toHaveProperty('workspace'); + expect(secret.workspace).toBeTruthy(); + + expect(secret).toHaveProperty('type'); + expect(secret.type).toBeTruthy(); + + expect(secret).toHaveProperty('tags'); + expect(secret.tags).toHaveLength(0); + + expect(secret).toHaveProperty('environment'); + expect(secret.environment).toEqual("dev"); + + expect(secret).toHaveProperty('secretKeyCiphertext'); + expect(secret.secretKeyCiphertext).toBeTruthy(); + + expect(secret).toHaveProperty('secretKeyIV'); + expect(secret.secretKeyIV).toBeTruthy(); + + expect(secret).toHaveProperty('secretKeyTag'); + expect(secret.secretKeyTag).toBeTruthy(); + + expect(secret).toHaveProperty('secretValueCiphertext'); + expect(secret.secretValueCiphertext).toBeTruthy(); + + expect(secret).toHaveProperty('secretValueIV'); + expect(secret.secretValueIV).toBeTruthy(); + + expect(secret).toHaveProperty('secretValueTag'); + expect(secret.secretValueTag).toBeTruthy(); + + expect(secret).toHaveProperty('secretCommentCiphertext'); + expect(secret.secretCommentCiphertext).toBeFalsy(); + + expect(secret).toHaveProperty('secretCommentIV'); + expect(secret.secretCommentIV).toBeTruthy(); + + expect(secret).toHaveProperty('secretCommentTag'); + expect(secret.secretCommentTag).toBeTruthy(); + + expect(secret).toHaveProperty('createdAt'); + expect(secret.createdAt).toBeTruthy(); + + expect(secret).toHaveProperty('updatedAt'); + expect(secret.updatedAt).toBeTruthy(); + }); + } finally { + // clean up + await deleteAllSecrets() + } + }) + }) + + describe("fetch secrets via service token", () => { + test("Get secrets via jwt when personal overrides exist", async () => { + try { + // get login details + const loginResponse = await getJWTFromTestUser() + + // create creates + const createSecretsResponse = await request(server) + .post("/api/v2/secrets/batch") + .set('Authorization', `Bearer ${loginResponse.token}`) + .send({ + workspaceId: testWorkspaceId, + environment: "dev", + requests: batchSecretRequestWithOverrides + }) + + expect(createSecretsResponse.statusCode).toBe(200) + + // now use the service token to fetch secrets + const serviceToken = await getServiceTokenFromTestUser() + + const getSecrets = await request(server) + .get("/api/v2/secrets") + .set('Authorization', `Bearer ${serviceToken}`) + .query({ + workspaceId: testWorkspaceId, + environment: "dev" + }) + + expect(getSecrets.statusCode).toBe(200) + expect(getSecrets.body).toHaveProperty("secrets") + expect(getSecrets.body.secrets).toHaveLength(2) + expect(getSecrets.body.secrets).toBeInstanceOf(Array); + + getSecrets.body.secrets.forEach((secret: any) => { + expect(secret).toHaveProperty('_id'); + expect(secret._id).toBeTruthy(); + + expect(secret).toHaveProperty('version'); + expect(secret.version).toBeTruthy(); + + expect(secret).toHaveProperty('workspace'); + expect(secret.workspace).toBeTruthy(); + + expect(secret).toHaveProperty('type'); + expect(secret.type).toBeTruthy(); + + expect(secret).toHaveProperty('tags'); + expect(secret.tags).toHaveLength(0); + + expect(secret).toHaveProperty('environment'); + expect(secret.environment).toEqual("dev"); + + expect(secret).toHaveProperty('secretKeyCiphertext'); + expect(secret.secretKeyCiphertext).toBeTruthy(); + + expect(secret).toHaveProperty('secretKeyIV'); + expect(secret.secretKeyIV).toBeTruthy(); + + expect(secret).toHaveProperty('secretKeyTag'); + expect(secret.secretKeyTag).toBeTruthy(); + + expect(secret).toHaveProperty('secretValueCiphertext'); + expect(secret.secretValueCiphertext).toBeTruthy(); + + expect(secret).toHaveProperty('secretValueIV'); + expect(secret.secretValueIV).toBeTruthy(); + + expect(secret).toHaveProperty('secretValueTag'); + expect(secret.secretValueTag).toBeTruthy(); + + expect(secret).toHaveProperty('secretCommentCiphertext'); + expect(secret.secretCommentCiphertext).toBeFalsy(); + + expect(secret).toHaveProperty('secretCommentIV'); + expect(secret.secretCommentIV).toBeTruthy(); + + expect(secret).toHaveProperty('secretCommentTag'); + expect(secret.secretCommentTag).toBeTruthy(); + + expect(secret).toHaveProperty('createdAt'); + expect(secret.createdAt).toBeTruthy(); + + expect(secret).toHaveProperty('updatedAt'); + expect(secret.updatedAt).toBeTruthy(); + }); + } finally { + // clean up + await deleteAllSecrets() + } + }) + + test("should create secrets and read secrets via service token when no overrides", async () => { + try { + // get login details + const loginResponse = await getJWTFromTestUser() + + // create secrets + const createSecretsResponse = await request(server) + .post("/api/v2/secrets/batch") + .set('Authorization', `Bearer ${loginResponse.token}`) + .send({ + workspaceId: testWorkspaceId, + environment: "dev", + requests: batchSecretRequestWithNoOverride + }) + + expect(createSecretsResponse.statusCode).toBe(200) + + + // now use the service token to fetch secrets + const serviceToken = await getServiceTokenFromTestUser() + + const getSecrets = await request(server) + .get("/api/v2/secrets") + .set('Authorization', `Bearer ${serviceToken}`) + .query({ + workspaceId: testWorkspaceId, + environment: "dev" + }) + + expect(getSecrets.statusCode).toBe(200) + expect(getSecrets.body).toHaveProperty("secrets") + expect(getSecrets.body.secrets).toHaveLength(3) + expect(getSecrets.body.secrets).toBeInstanceOf(Array); + + getSecrets.body.secrets.forEach((secret: any) => { + expect(secret).toHaveProperty('_id'); + expect(secret._id).toBeTruthy(); + + expect(secret).toHaveProperty('version'); + expect(secret.version).toBeTruthy(); + + expect(secret).toHaveProperty('workspace'); + expect(secret.workspace).toBeTruthy(); + + expect(secret).toHaveProperty('type'); + expect(secret.type).toBeTruthy(); + + expect(secret).toHaveProperty('tags'); + expect(secret.tags).toHaveLength(0); + + expect(secret).toHaveProperty('environment'); + expect(secret.environment).toEqual("dev"); + + expect(secret).toHaveProperty('secretKeyCiphertext'); + expect(secret.secretKeyCiphertext).toBeTruthy(); + + expect(secret).toHaveProperty('secretKeyIV'); + expect(secret.secretKeyIV).toBeTruthy(); + + expect(secret).toHaveProperty('secretKeyTag'); + expect(secret.secretKeyTag).toBeTruthy(); + + expect(secret).toHaveProperty('secretValueCiphertext'); + expect(secret.secretValueCiphertext).toBeTruthy(); + + expect(secret).toHaveProperty('secretValueIV'); + expect(secret.secretValueIV).toBeTruthy(); + + expect(secret).toHaveProperty('secretValueTag'); + expect(secret.secretValueTag).toBeTruthy(); + + expect(secret).toHaveProperty('secretCommentCiphertext'); + expect(secret.secretCommentCiphertext).toBeFalsy(); + + expect(secret).toHaveProperty('secretCommentIV'); + expect(secret.secretCommentIV).toBeTruthy(); + + expect(secret).toHaveProperty('secretCommentTag'); + expect(secret.secretCommentTag).toBeTruthy(); + + expect(secret).toHaveProperty('createdAt'); + expect(secret.createdAt).toBeTruthy(); + + expect(secret).toHaveProperty('updatedAt'); + expect(secret.updatedAt).toBeTruthy(); + }); + } finally { + // clean up + await deleteAllSecrets() + } + }) + }) + + describe("create secrets via JWT", () => { + test("Create secrets via jwt when some requests have missing required parameters", async () => { + // get login details + const loginResponse = await getJWTFromTestUser() + + // create creates + const createSecretsResponse = await request(server) + .post("/api/v2/secrets/batch") + .set('Authorization', `Bearer ${loginResponse.token}`) + .send({ + workspaceId: testWorkspaceId, + environment: "dev", + requests: batchSecretRequestWithBadRequest + }) + + const allSecretsInDB = await getAllSecrets() + + expect(createSecretsResponse.statusCode).toBe(500) // TODO should be set to 400 + expect(allSecretsInDB).toHaveLength(0) + }) + }) +}) \ No newline at end of file diff --git a/backend/tests/integration-tests/routes/v2/service-tokens.ts b/backend/tests/integration-tests/routes/v2/service-tokens.ts new file mode 100644 index 000000000..b011db36f --- /dev/null +++ b/backend/tests/integration-tests/routes/v2/service-tokens.ts @@ -0,0 +1,58 @@ +import request from 'supertest' +import main from '../../../../src/index' +import { getServiceTokenFromTestUser } from '../../../helper/helper'; +let server: any; + +beforeAll(async () => { + server = await main; +}); + +afterAll(async () => { + server.close(); +}); + +describe("GET /api/v2/service-token", () => { + describe("Get service token details", () => { + test("should respond create and get the details of a service token", async () => { + // generate a service token + const serviceToken = await getServiceTokenFromTestUser() + + // get the service token details + const serviceTokenDetails = await request(server) + .get("/api/v2/service-token") + .set('Authorization', `Bearer ${serviceToken}`) + + expect(serviceTokenDetails.body).toMatchObject({ + _id: expect.any(String), + name: 'test service token', + workspace: '63cefb15c8d3175601cfa989', + environment: 'dev', + user: { + _id: '63cefa6ec8d3175601cfa980', + email: 'test@localhost.local', + firstName: 'Jake', + lastName: 'Moni', + isMfaEnabled: false, + mfaMethods: expect.any(Array), + devices: [ + { + ip: expect.any(String), + userAgent: expect.any(String), + _id: expect.any(String), + }, + ], + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + lastUsed: expect.any(String), + expiresAt: expect.any(String), + encryptedKey: expect.any(String), + iv: expect.any(String), + tag: expect.any(String), + permissions: ['read'], + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }) + }) +}) \ No newline at end of file diff --git a/backend/tests/utils/crypto.test.ts b/backend/tests/unit-tests/utils/crypto.test.ts similarity index 99% rename from backend/tests/utils/crypto.test.ts rename to backend/tests/unit-tests/utils/crypto.test.ts index aea5f5f26..bbbfd0297 100644 --- a/backend/tests/utils/crypto.test.ts +++ b/backend/tests/unit-tests/utils/crypto.test.ts @@ -4,7 +4,7 @@ import { decryptSymmetric, encryptAsymmetric, encryptSymmetric -} from '../../src/utils/crypto'; +} from '../../../src/utils/crypto'; describe('Crypto', () => { describe('encryptAsymmetric', () => { diff --git a/backend/tests/utils/posthog.test.ts b/backend/tests/unit-tests/utils/posthog.test.ts similarity index 93% rename from backend/tests/utils/posthog.test.ts rename to backend/tests/unit-tests/utils/posthog.test.ts index a380060b0..9f202385c 100644 --- a/backend/tests/utils/posthog.test.ts +++ b/backend/tests/unit-tests/utils/posthog.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from '@jest/globals'; -import { getChannelFromUserAgent } from '../../src/utils/posthog'; +import { getChannelFromUserAgent } from '../../../src/utils/posthog'; describe('posthog getChannelFromUserAgent', () => { test("should return 'web' when userAgent includes 'mozilla'", () => { diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index af8dbc5b4..71354c84c 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -230,19 +230,10 @@ type GetEncryptedSecretsV2Response struct { } type GetServiceTokenDetailsResponse struct { - ID string `json:"_id"` - Name string `json:"name"` - Workspace string `json:"workspace"` - Environment string `json:"environment"` - User struct { - ID string `json:"_id"` - Email string `json:"email"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - V int `json:"__v"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - } `json:"user"` + ID string `json:"_id"` + Name string `json:"name"` + Workspace string `json:"workspace"` + Environment string `json:"environment"` ExpiresAt time.Time `json:"expiresAt"` EncryptedKey string `json:"encryptedKey"` Iv string `json:"iv"` diff --git a/cloudformation/ec2-deployment/infisical-ec2-deployment.template b/cloudformation/ec2-deployment/infisical-ec2-deployment.template index 36f0d9dce..2db93b779 100644 --- a/cloudformation/ec2-deployment/infisical-ec2-deployment.template +++ b/cloudformation/ec2-deployment/infisical-ec2-deployment.template @@ -96,6 +96,7 @@ Resources: echo "JWT_AUTH_SECRET=${!JWT_AUTH_SECRET}" >> .env echo "JWT_SERVICE_SECRET=${!JWT_SERVICE_SECRET}" >> .env echo "MONGO_URL=${!DOCUMENT_DB_CONNECTION_URL}" >> .env + echo "HTTPS_ENABLED=false" >> .env docker-compose up -d diff --git a/docs/api-reference/overview/authentication.mdx b/docs/api-reference/overview/authentication.mdx index 7df8f10e5..3e18f8ce4 100644 --- a/docs/api-reference/overview/authentication.mdx +++ b/docs/api-reference/overview/authentication.mdx @@ -1,25 +1,51 @@ --- title: "Authentication" +description: "How to authenticate with the Infisical Public API" --- -To authenticate requests with Infisical, you can either use an API Key or [Infisical Token](../../../getting-started/dashboard/token); certain endpoints will accept either one or both. -- API Key: This general-purpose authentication token provides user access to most endpoints in this reference. -- [Infisical Token](../../../getting-started/dashboard/token): This authentication token (also referred to as the service token) is scoped to a specific project and environment and used for CRUD secret operations. +## Essentials + +The Public API accepts multiple modes of authentication being via API Key, Service Account credentials, or [Infisical Token](../../../getting-started/dashboard/token). + +- API Key: Provides full access to all endpoints representing the user. +- [Service Account](): Provides scoped access to an organization and select projects representing a machine such as a VM or application client. +- [Infisical Token](../../../getting-started/dashboard/token): Provides short-lived, scoped CRUD access to the secrets of a specific project and environment. +The API key mode uses an API key to authenticate with the API. + To authenticate requests with Infisical using the API Key, you must include an API key in the `X-API-KEY` header of HTTP requests made to the platform. You can obtain an API key in User Settings > API Keys ![API key dashboard](../../images/api-key-dashboard.png) ![API key in personal settings](../../images/api-key-settings.png) + + +The Service Account mode uses an Access Key to authenticate with the API and a Public Key and Private Key to perform any cryptographic operations. + +To authenticate requests with Infisical using the Access Key, you must include it in the `Authorization` header of HTTP requests made to the platform with the value `Bearer `. + +You can create a Service Account in Organization Settings > Service Accounts + -To authenticate requests with Infisical using the Infisical Token, you must include your Infisical Token in the `Authorization` header of HTTP requests made to the platform with the value `Bearer st.`. + +The Infisical Token mode uses an Infisical Token to authenticate with the API. + +To authenticate requests with Infisical using the Infisical Token, you must include your Infisical Token in the `Authorization` header of HTTP requests made to the platform with the value `Bearer `. You can obtain an Infisical Token in Project Settings > Service Tokens. ![token add](../../images/project-token-add.png) - \ No newline at end of file + + +## Use Cases + +Depending on your use case, it may make sense to use one or another authentication mode: + +- API Key (not recommended): Use if you need full access to the Public API without needing to access any secrets endpoints (because API keys can't encrypt/decrypt secrets). +- Service Account (recommeded): Use if you need access to multiple projects and environments in an organization; service accounts can generate short-lived access tokens, making them useful for some complex setups. +- Service Token (recommeded): Use if you need short-lived, scoped CRUD access to the secrets of a specific project and environment. \ No newline at end of file diff --git a/docs/api-reference/overview/introduction.mdx b/docs/api-reference/overview/introduction.mdx index 3d748314b..585abc8f0 100644 --- a/docs/api-reference/overview/introduction.mdx +++ b/docs/api-reference/overview/introduction.mdx @@ -2,11 +2,17 @@ title: "Introduction" --- -Infisical's REST API provides users an alternative way to programmatically access and manage +Infisical's Public (REST) API provides users an alternative way to programmatically access and manage secrets via HTTPS requests. This can be useful for automating tasks, such as rotating credentials, or for integrating secret management into a larger system. -With the REST API, users can create, read, update, and delete secrets, as well as manage access control, query audit logs, and more. +With the Public API, users can create, read, update, and delete secrets, as well as manage access control, query audit logs, and more. + + + We highly recommend using one of the available SDKs when working with the Infisical API. + + If you decide to make your own requests using the API reference instead, be prepared for a steeper learning curve and more manual work. + ## Concepts diff --git a/docs/getting-started/dashboard/organization.mdx b/docs/getting-started/dashboard/organization.mdx index 63ec2cd49..161a79f14 100644 --- a/docs/getting-started/dashboard/organization.mdx +++ b/docs/getting-started/dashboard/organization.mdx @@ -24,6 +24,14 @@ To add a member to your organization, scroll down to the "Organization Members" projects by default. +## Service Accounts + +Service accounts represent machine identities such as VMs or application clients that can authenticate with Infisical. They can be provisioned read/write permissions for project(s) and environment(s). + +To add a service account to your organization, scroll down to the "Service Accounts" section and create a service account. Afterwards, you can press on the edit button beside the service account to provision it permissions. + +![organization service accounts](../../images/organization-service-accounts.png) + ## Incident contacts Incident contacts of an organization are alerted if anything abnormal is detected within the operations of an organization. diff --git a/docs/getting-started/dashboard/project.mdx b/docs/getting-started/dashboard/project.mdx index ce7df57e3..065835fb3 100644 --- a/docs/getting-started/dashboard/project.mdx +++ b/docs/getting-started/dashboard/project.mdx @@ -25,16 +25,16 @@ In most cases, environment variables belong to specific environments: developmen ![project environment](../../images/project-environment.png) -### Personal/Shared scoping +### Personal overrides -Every environment variable is classified as either personal or shared. +Every environment variable value can be overriden with a custom value. -- A personal environment variable is one created by a user of a project to be available for that user only. -- A shared environment variable is one created by a user of a project to be available for other users of the project. +- 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. -You can toggle the classification of an environment variable by pressing on its settings: +You can turn overrides on/off by toggling the override/branch icon: -![project variable toggle open](../../images/project-envar-toggle-open.png) +![project variable toggle open](../../images/project-envar-override.png) ### Search @@ -42,12 +42,6 @@ You can search for any environment variable by its key. ![project search](../../images/project-search.png) -### Sort - -You can sort environment variables alphabetically by their keys. - -![project sort](../../images/project-sort.png) - ### Hide/Un-hide You can hide or un-hide the values of your environment variables. By default, the values are hidden for your privacy. diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index cd5022083..d70d12e1b 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -3,7 +3,7 @@ title: "Introduction" description: "What is Infisical?" --- -Infisical is an [open-source](https://opensource.com/resources/what-open-source), [end-to-end encrypted](https://en.wikipedia.org/wiki/End-to-end_encryption) secret manager that enables teams to easily manage and sync their environment variables. +Infisical is an [open-source](https://opensource.com/resources/what-open-source), [end-to-end encrypted](https://en.wikipedia.org/wiki/End-to-end_encryption) secret management platform that enables teams to easily manage and sync their environment variables. Start syncing environment variables with [Infisical Cloud](https://app.infisical.com) or learn how to [host Infisical](/self-hosting/overview) yourself. diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index 256ae8f85..f19bd1120 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -1,6 +1,6 @@ --- title: "Quickstart" -description: "Start managing your developer secrets and configs with Infisical in 10 minutes." +description: "Start managing developer secrets and configs with Infisical in minutes." --- These examples demonstrate how to store and fetch environment variables from [Infisical Cloud](https://app.infisical.com) into your application. @@ -9,7 +9,7 @@ These examples demonstrate how to store and fetch environment variables from [In 1. Login or create an account at `app.infisical.com`. 2. Create a new project. -3. Populate your environment variables as in the image below. +3. Keep the default environment variables or populate them as in the image below. ![project quickstart](../images/project-quickstart.png) diff --git a/docs/images/dashboard-name-modal-organization.png b/docs/images/dashboard-name-modal-organization.png index 7fe84675e..59145d21b 100644 Binary files a/docs/images/dashboard-name-modal-organization.png and b/docs/images/dashboard-name-modal-organization.png differ diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png index 961b53587..2188debfc 100644 Binary files a/docs/images/dashboard.png and b/docs/images/dashboard.png differ diff --git a/docs/images/integrations-supabase-authorization.png b/docs/images/integrations-supabase-authorization.png new file mode 100644 index 000000000..d6daab30c Binary files /dev/null and b/docs/images/integrations-supabase-authorization.png differ diff --git a/docs/images/integrations-supabase-create.png b/docs/images/integrations-supabase-create.png new file mode 100644 index 000000000..b6c2cd35d Binary files /dev/null and b/docs/images/integrations-supabase-create.png differ diff --git a/docs/images/integrations-supabase-dashboard.png b/docs/images/integrations-supabase-dashboard.png new file mode 100644 index 000000000..4908cfc5a Binary files /dev/null and b/docs/images/integrations-supabase-dashboard.png differ diff --git a/docs/images/integrations-supabase-token.png b/docs/images/integrations-supabase-token.png new file mode 100644 index 000000000..00a3c0b20 Binary files /dev/null and b/docs/images/integrations-supabase-token.png differ diff --git a/docs/images/integrations-supabase.png b/docs/images/integrations-supabase.png new file mode 100644 index 000000000..60808a232 Binary files /dev/null and b/docs/images/integrations-supabase.png differ diff --git a/docs/images/integrations.png b/docs/images/integrations.png index 234a842b3..fc6593143 100644 Binary files a/docs/images/integrations.png and b/docs/images/integrations.png differ diff --git a/docs/images/organization-ic.png b/docs/images/organization-ic.png index d94f4061a..e45ad2d82 100644 Binary files a/docs/images/organization-ic.png and b/docs/images/organization-ic.png differ diff --git a/docs/images/organization-members.png b/docs/images/organization-members.png index 321808d51..90344b698 100644 Binary files a/docs/images/organization-members.png and b/docs/images/organization-members.png differ diff --git a/docs/images/organization-service-accounts.png b/docs/images/organization-service-accounts.png new file mode 100644 index 000000000..f48e848d3 Binary files /dev/null and b/docs/images/organization-service-accounts.png differ diff --git a/docs/images/organization.png b/docs/images/organization.png index 473c31db0..488d6c4cf 100644 Binary files a/docs/images/organization.png and b/docs/images/organization.png differ diff --git a/docs/images/pit-commits.png b/docs/images/pit-commits.png index 19cfa4976..22599311a 100644 Binary files a/docs/images/pit-commits.png and b/docs/images/pit-commits.png differ diff --git a/docs/images/pit-snapshot.png b/docs/images/pit-snapshot.png index 7e790e875..b7e913108 100644 Binary files a/docs/images/pit-snapshot.png and b/docs/images/pit-snapshot.png differ diff --git a/docs/images/pit-snapshots.png b/docs/images/pit-snapshots.png index f22231648..aa1cd52d3 100644 Binary files a/docs/images/pit-snapshots.png and b/docs/images/pit-snapshots.png differ diff --git a/docs/images/project-download.png b/docs/images/project-download.png index 8750b6cd3..00b02d9bf 100644 Binary files a/docs/images/project-download.png and b/docs/images/project-download.png differ diff --git a/docs/images/project-drag-drop.png b/docs/images/project-drag-drop.png index a283faec6..7ae0ed718 100644 Binary files a/docs/images/project-drag-drop.png and b/docs/images/project-drag-drop.png differ diff --git a/docs/images/project-envar-override.png b/docs/images/project-envar-override.png new file mode 100644 index 000000000..d9e01c8fd Binary files /dev/null and b/docs/images/project-envar-override.png differ diff --git a/docs/images/project-envar-toggle-open.png b/docs/images/project-envar-toggle-open.png deleted file mode 100644 index 297299454..000000000 Binary files a/docs/images/project-envar-toggle-open.png and /dev/null differ diff --git a/docs/images/project-environment.png b/docs/images/project-environment.png index 5b316511c..e2e0d2959 100644 Binary files a/docs/images/project-environment.png and b/docs/images/project-environment.png differ diff --git a/docs/images/project-hide.png b/docs/images/project-hide.png index 69fdb13f0..61aed0bee 100644 Binary files a/docs/images/project-hide.png and b/docs/images/project-hide.png differ diff --git a/docs/images/project-quickstart.png b/docs/images/project-quickstart.png index 6d8d7f664..9a9660d1d 100644 Binary files a/docs/images/project-quickstart.png and b/docs/images/project-quickstart.png differ diff --git a/docs/images/project-search.png b/docs/images/project-search.png index 7388b34ce..bafc0b2c3 100644 Binary files a/docs/images/project-search.png and b/docs/images/project-search.png differ diff --git a/docs/images/project-sort.png b/docs/images/project-sort.png deleted file mode 100644 index 134adc8d5..000000000 Binary files a/docs/images/project-sort.png and /dev/null differ diff --git a/docs/images/secret-versioning.png b/docs/images/secret-versioning.png index ec1734289..96dbfde41 100644 Binary files a/docs/images/secret-versioning.png and b/docs/images/secret-versioning.png differ diff --git a/docs/integrations/cicd/circleci.mdx b/docs/integrations/cicd/circleci.mdx index 7b9298f4b..58b8ae3ff 100644 --- a/docs/integrations/cicd/circleci.mdx +++ b/docs/integrations/cicd/circleci.mdx @@ -1,6 +1,6 @@ --- title: "CircleCI" -description: "How to automatically sync secrets from Infisical into your CircleCI project." +description: "How to sync secrets from Infisical to CircleCI" --- Prerequisites: diff --git a/docs/integrations/cicd/githubactions.mdx b/docs/integrations/cicd/githubactions.mdx index 8f93d8162..5ecf68d03 100644 --- a/docs/integrations/cicd/githubactions.mdx +++ b/docs/integrations/cicd/githubactions.mdx @@ -1,6 +1,6 @@ --- title: "GitHub Actions" -description: "How to automatically sync secrets from Infisical into your GitHub Actions." +description: "How to sync secrets from Infisical to GitHub Actions" --- diff --git a/docs/integrations/cicd/gitlab.mdx b/docs/integrations/cicd/gitlab.mdx index f52afe99f..8a5432272 100644 --- a/docs/integrations/cicd/gitlab.mdx +++ b/docs/integrations/cicd/gitlab.mdx @@ -1,6 +1,6 @@ --- title: "GitLab" -description: "How to automatically sync secrets from Infisical into GitLab." +description: "How to sync secrets from Infisical to GitLab" --- Prerequisites: diff --git a/docs/integrations/cicd/travisci.mdx b/docs/integrations/cicd/travisci.mdx index 5509ffc7a..79737a2d8 100644 --- a/docs/integrations/cicd/travisci.mdx +++ b/docs/integrations/cicd/travisci.mdx @@ -1,6 +1,6 @@ --- title: "Travis CI" -description: "How to automatically sync secrets from Infisical to your Travis CI repository." +description: "How to sync secrets from Infisical to Travis CI" --- Prerequisites: diff --git a/docs/integrations/cloud/aws-parameter-store.mdx b/docs/integrations/cloud/aws-parameter-store.mdx index 1bb6c40c9..63fd1e1c6 100644 --- a/docs/integrations/cloud/aws-parameter-store.mdx +++ b/docs/integrations/cloud/aws-parameter-store.mdx @@ -1,6 +1,6 @@ --- title: "AWS Parameter Store" -description: "How to automatically sync secrets from Infisical to your AWS Parameter Store." +description: "How to sync secrets from Infisical to AWS Parameter Store" --- Prerequisites: diff --git a/docs/integrations/cloud/aws-secret-manager.mdx b/docs/integrations/cloud/aws-secret-manager.mdx index 9b282c527..a798ae820 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 automatically sync secrets from Infisical to your AWS Secret Manager." +description: "How to sync secrets from Infisical to AWS Secret Manager" --- Prerequisites: diff --git a/docs/integrations/cloud/azure-key-vault.mdx b/docs/integrations/cloud/azure-key-vault.mdx index 3b23f4d52..90c630666 100644 --- a/docs/integrations/cloud/azure-key-vault.mdx +++ b/docs/integrations/cloud/azure-key-vault.mdx @@ -1,6 +1,6 @@ --- title: "Azure Key Vault" -description: "How to automatically sync secrets from Infisical into your Azure Key Vault." +description: "How to sync secrets from Infisical to Azure Key Vault" --- Prerequisites: diff --git a/docs/integrations/cloud/flyio.mdx b/docs/integrations/cloud/flyio.mdx index 2a1dd7e2c..283c54176 100644 --- a/docs/integrations/cloud/flyio.mdx +++ b/docs/integrations/cloud/flyio.mdx @@ -1,6 +1,6 @@ --- title: "Fly.io" -description: "How to automatically sync secrets from Infisical into your Fly.io project." +description: "How to sync secrets from Infisical to Fly.io" --- Prerequisites: @@ -11,7 +11,7 @@ Prerequisites: ![integrations](../../images/integrations.png) -## Authorize Infisical for Fly.io +## Enter your Fly.io Access Token Obtain a Fly.io access token in Access Tokens diff --git a/docs/integrations/cloud/heroku.mdx b/docs/integrations/cloud/heroku.mdx index 9d0c26789..f3c58d27e 100644 --- a/docs/integrations/cloud/heroku.mdx +++ b/docs/integrations/cloud/heroku.mdx @@ -1,6 +1,6 @@ --- title: "Heroku" -description: "How to automatically sync secrets from Infisical into your Heroku project." +description: "How to sync secrets from Infisical to Heroku" --- Prerequisites: diff --git a/docs/integrations/cloud/netlify.mdx b/docs/integrations/cloud/netlify.mdx index b6dc36ba0..acd037417 100644 --- a/docs/integrations/cloud/netlify.mdx +++ b/docs/integrations/cloud/netlify.mdx @@ -1,6 +1,6 @@ --- title: "Netlify" -description: "How to automatically sync secrets from Infisical into your Netlify project." +description: "How to sync secrets from Infisical to Netlify" --- diff --git a/docs/integrations/cloud/railway.mdx b/docs/integrations/cloud/railway.mdx index 5ebf8fc1d..e19cfb626 100644 --- a/docs/integrations/cloud/railway.mdx +++ b/docs/integrations/cloud/railway.mdx @@ -1,6 +1,6 @@ --- title: "Railway" -description: "How to automatically sync secrets from Infisical into your Railway projects and services" +description: "How to sync secrets from Infisical to Railway" --- Prerequisites: diff --git a/docs/integrations/cloud/render.mdx b/docs/integrations/cloud/render.mdx index 95890e92a..290e39012 100644 --- a/docs/integrations/cloud/render.mdx +++ b/docs/integrations/cloud/render.mdx @@ -1,6 +1,6 @@ --- title: "Render" -description: "How to automatically sync secrets from Infisical into your Render project." +description: "How to sync secrets from Infisical to Render" --- Prerequisites: diff --git a/docs/integrations/cloud/supabase.mdx b/docs/integrations/cloud/supabase.mdx new file mode 100644 index 000000000..14c787f75 --- /dev/null +++ b/docs/integrations/cloud/supabase.mdx @@ -0,0 +1,44 @@ +--- +title: "Supabase" +description: "How to sync secrets from Infisical to Supabase" +--- + + + The Supabase integration is useful if your Supabase project uses sensitive-information such as [environment variables in edge functions](https://supabase.com/docs/guides/functions/secrets). + + Synced envars can be accessed in edge functions using Deno's built-in handler: `Deno.env.get(MY_SECRET_NAME)`. + + +Prerequisites: + +- Have an account and project set up at [Supabase](https://supabase.com/) +- 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 Supabase Access Token + +Obtain a Supabase Access Token in your Supabase [Account > Access Tokens](https://app.supabase.com/account/tokens). +![integrations supabase dashboard](../../images/integrations-supabase-dashboard.png) +![integrations supabase token](../../images/integrations-supabase-token.png) + +Press on the Supabase tile and input your Supabase Access Token to grant Infisical access to your Supabase account. + +![integrations supabase authorization](../../images/integrations-supabase-authorization.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 + +Select which Infisical environment secrets you want to sync to which Supabase project. Lastly, press create integration to start syncing secrets to Supabase. + +![integrations supabase create](../../images/integrations-supabase-create.png) + +![integrations supabase](../../images/integrations-supabase.png) \ No newline at end of file diff --git a/docs/integrations/cloud/vercel.mdx b/docs/integrations/cloud/vercel.mdx index af20c60cf..fb265ec98 100644 --- a/docs/integrations/cloud/vercel.mdx +++ b/docs/integrations/cloud/vercel.mdx @@ -1,6 +1,6 @@ --- title: "Vercel" -description: "How to automatically sync secrets from Infisical into your Vercel project." +description: "How to sync secrets from Infisical to Vercel" --- Prerequisites: diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index e6e2622cd..dcbdf44b8 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -18,8 +18,9 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi | [Vercel](/integrations/cloud/vercel) | Cloud | Available | | [Netlify](/integrations/cloud/netlify) | Cloud | Available | | [Render](/integrations/cloud/render) | Cloud | Available | -| [Railway](/integrations/cloud/railway) | Cloud | Available | +| [Railway](/integrations/cloud/railway) | Cloud | Available | | [Fly.io](/integrations/cloud/flyio) | Cloud | Available | +| [Supabase](/integrations/cloud/supabase) | Cloud | Available | | [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | | [AWS Secret Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | | [Azure Key Vault](/integrations/cloud/azure-key-vault) | Cloud | Available | @@ -42,7 +43,5 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi | [Flask](/integrations/frameworks/flask) | Framework | Available | | [Laravel](/integrations/frameworks/laravel) | Framework | Available | | [Ruby on Rails](/integrations/frameworks/rails) | Framework | Available | -| GCP | Cloud | Coming soon | -| DigitalOcean | Cloud | Coming soon | -| GitHub Actions | CI/CD | Coming soon | +| GCP Secret Manager | Cloud | Coming soon | | Jenkins | CI/CD | Coming soon | diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 959a4fce2..5ea3967d9 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -1,6 +1,6 @@ --- title: 'Kubernetes' -description: "This page explains how to use Infisical to inject secrets into Kubernetes clusters." +description: "How to use Infisical to inject secrets into Kubernetes clusters." --- ![title](../../images/k8-diagram.png) diff --git a/docs/mint.json b/docs/mint.json index 165ce6862..850809a4f 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -151,6 +151,7 @@ "integrations/cloud/render", "integrations/cloud/railway", "integrations/cloud/flyio", + "integrations/cloud/supabase", "integrations/cloud/azure-key-vault", "integrations/cicd/githubactions", "integrations/cicd/gitlab", diff --git a/docs/self-hosting/overview.mdx b/docs/self-hosting/overview.mdx index 995fe93a0..8acdad974 100644 --- a/docs/self-hosting/overview.mdx +++ b/docs/self-hosting/overview.mdx @@ -17,7 +17,7 @@ Self-hosted Infisical allows you to maintain your sensitive information within y - 1 DocumentDB instance - Security groups - + @@ -28,6 +28,7 @@ Self-hosted Infisical allows you to maintain your sensitive information within y This deployment option is highly available + **Prerequisites** - You have understanding of [Kubernetes](https://kubernetes.io/) diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index f5e5e5016..40c88f65b 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -15,7 +15,8 @@ const integrationSlugNameMapping: Mapping = { 'railway': 'Railway', 'flyio': 'Fly.io', 'circleci': 'CircleCI', - 'travisci': 'TravisCI' + 'travisci': 'TravisCI', + 'supabase': 'Supabase' } const envMapping: Mapping = { diff --git a/frontend/public/images/integrations/Supabase.png b/frontend/public/images/integrations/Supabase.png new file mode 100644 index 000000000..843be59ca Binary files /dev/null and b/frontend/public/images/integrations/Supabase.png differ diff --git a/frontend/src/pages/api/integrations/DeleteIntegration.ts b/frontend/src/pages/api/integrations/DeleteIntegration.ts index a16c629a3..ae401984c 100644 --- a/frontend/src/pages/api/integrations/DeleteIntegration.ts +++ b/frontend/src/pages/api/integrations/DeleteIntegration.ts @@ -19,7 +19,6 @@ const deleteIntegration = ({ integrationId }: Props) => if (res && res.status === 200) { return (await res.json()).integration; } - console.log('Failed to delete an integration'); return undefined; }); diff --git a/frontend/src/pages/dashboard/[id].tsx b/frontend/src/pages/dashboard/[id].tsx index bb81245ec..0e77bd99c 100644 --- a/frontend/src/pages/dashboard/[id].tsx +++ b/frontend/src/pages/dashboard/[id].tsx @@ -1,20 +1,825 @@ +/* eslint-disable no-nested-ternary */ +import { UIEvent, useCallback, useEffect, useRef, useState } from 'react'; import Head from 'next/head'; +import Image from 'next/image'; +import queryString from 'query-string'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; -import queryString from 'query-string'; +import { + faArrowDown, + faArrowLeft, + faArrowUp, + faCheck, + faClockRotateLeft, + faEye, + faEyeSlash, + faFolderOpen, + faMagnifyingGlass, + faPlus, + faXmark +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Tag } from 'public/data/frequentInterfaces'; +import Button from '@app/components/basic/buttons/Button'; +import ListBox from '@app/components/basic/Listbox'; +import BottonRightPopup from '@app/components/basic/popups/BottomRightPopup'; +import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider'; +import ConfirmEnvOverwriteModal from '@app/components/dashboard/ConfirmEnvOverwriteModal'; +import DownloadSecretMenu from '@app/components/dashboard/DownloadSecretsMenu'; +import DropZone from '@app/components/dashboard/DropZone'; +import KeyPair from '@app/components/dashboard/KeyPair'; +import SideBar from '@app/components/dashboard/SideBar'; +import NavHeader from '@app/components/navigation/NavHeader'; +import { decryptAssymmetric, decryptSymmetric } from '@app/components/utilities/cryptography/crypto'; +import guidGenerator from '@app/components/utilities/randomId'; +import encryptSecrets from '@app/components/utilities/secrets/encryptSecrets'; +import getSecretsForProject from '@app/components/utilities/secrets/getSecretsForProject'; import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps'; import { DashboardPage } from '@app/views/DashboardPage'; import { DashboardEnvOverview } from '@app/views/DashboardPage/DashboardEnvOverview'; +import { IconButton } from '@app/components/v2'; +import { leaveConfirmDefaultMessage } from '@app/const'; +import getProjectSercetSnapshotsCount from '@app/ee/api/secrets/GetProjectSercetSnapshotsCount'; +import performSecretRollback from '@app/ee/api/secrets/PerformSecretRollback'; +import PITRecoverySidebar from '@app/ee/components/PITRecoverySidebar'; +import { useLeaveConfirm } from '@app/hooks'; -const Dashboard = () => { - const { t } = useTranslation(); +// import addSecrets from '../api/files/AddSecrets'; +// import deleteSecrets from '../api/files/DeleteSecrets'; +// import updateSecrets from '../api/files/UpdateSecrets'; +import batchSecrets from '../api/files/batchSecrets'; +import getUser from '../api/user/getUser'; +import checkUserAction from '../api/userActions/checkUserAction'; +import registerUserAction from '../api/userActions/registerUserAction'; +import getLatestFileKey from '../api/workspace/getLatestFileKey'; +import getWorkspaceEnvironments from '../api/workspace/getWorkspaceEnvironments'; +import getWorkspaces from '../api/workspace/getWorkspaces'; +import getWorkspaceTags from '../api/workspace/getWorkspaceTags'; + +type WorkspaceEnv = { + name: string; + slug: string; + isWriteDenied: boolean; + isReadDenied: boolean; +}; + +interface SecretDataProps { + pos: number; + key: string; + value: string | undefined; + valueOverride: string | undefined; + id: string; + idOverride: string | undefined; + comment: string; + tags: Tag[]; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +interface OverrideProps { + id: string; + keyName: string; + value: string; + pos: number; + comment: string; +} + +interface SnapshotProps { + id: string; + createdAt: string; + version: number; + secretVersions: { + id: string; + pos: number; + environment: string; + key: string; + value: string; + valueOverride: string; + comment: string; + tags: Tag[]; + }[]; +} + +interface EncryptedSecretProps { + _id: string; + createdAt: string; + environment: string; + secretCommentCiphertext: string; + secretCommentIV: string; + secretCommentTag: string; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + type: 'personal' | 'shared'; + tags: Tag[]; +} + +interface SecretProps { + key: string; + value: string | undefined; + type: 'personal' | 'shared'; + comment: string; + id: string; + tags: Tag[]; +} + +/** + * this function finds the the duplicates in an array + * @param arr - array of anything (e.g., with secret keys and types (personal/shared)) + * @returns - a list with duplicates + */ +function findDuplicates(arr: any[]) { + const map = new Map(); + return arr.filter((item) => { + if (map.has(item)) { + map.set(item, false); + return true; + } + map.set(item, true); + return false; + }); +} + +/** + * This is the main component for the dashboard (aka the screen with all the encironemnt variable & secrets) + * @returns + */ +export default function Dashboard() { + const [data, setData] = useState(); + const [initialData, setInitialData] = useState([]); const router = useRouter(); + const [blurred, setBlurred] = useState(true); + const [isKeyAvailable, setIsKeyAvailable] = useState(true); + const [isNew, setIsNew] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [searchKeys, setSearchKeys] = useState(''); + const [errorDragAndDrop, setErrorDragAndDrop] = useState(false); + const [sortMethod, setSortMethod] = useState('alphabetical'); + const [checkDocsPopUpVisible, setCheckDocsPopUpVisible] = useState(false); + const [hasUserEverPushed, setHasUserEverPushed] = useState(false); + const [sidebarSecretId, toggleSidebar] = useState('None'); + const [PITSidebarOpen, togglePITSidebar] = useState(false); + const [sharedToHide, setSharedToHide] = useState([]); + const [snapshotData, setSnapshotData] = useState(); + const [numSnapshots, setNumSnapshots] = useState(); + const [saveLoading, setSaveLoading] = useState(false); + const [autoCapitalization, setAutoCapitalization] = useState(false); + const [dropZoneData, setDropZoneData] = useState(); + const [projectTags, setProjectTags] = useState([]); + const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false }); + const { t } = useTranslation(); + + const { createNotification } = useNotificationContext(); + const router = useRouter(); const env = queryString.parse(router.asPath.split('?')[1])?.env; - return ( - <> + const workspaceId = router.query.id as string; + const [workspaceEnvs, setWorkspaceEnvs] = useState([]); + + const [selectedSnapshotEnv, setSelectedSnapshotEnv] = useState(); + const [selectedEnv, setSelectedEnv] = useState(); + const [atSecretAreaTop, setAtSecretsAreaTop] = useState(true); + const secretsTop = useRef(null); + + const onSecretsAreaScroll = (event: UIEvent) => { + if (event.currentTarget.scrollTop !== 0) { + setAtSecretsAreaTop(false); + } + }; + + // TODO(akhilmhdh): change to FP + const sortValuesHandler = ( + dataToSort: SecretDataProps[] | 1, + specificSortMethod?: 'alphabetical' | '-alphabetical' + ) => { + const howToSort = specificSortMethod === undefined ? sortMethod : specificSortMethod; + const sortedData = (dataToSort !== 1 ? dataToSort : data)! + .sort((a, b) => + howToSort === 'alphabetical' ? a.key.localeCompare(b.key) : b.key.localeCompare(a.key) + ) + .map((item: SecretDataProps, index: number) => ({ + ...item, + pos: index + })); + + setData(sortedData); + setIsLoading(false); + }; + + /** + * Reorder rows alphabetically or in the opprosite order + */ + const reorderRows = (dataToReorder: SecretDataProps[] | 1) => { + if (dataToReorder) { + setSortMethod((prevSort) => (prevSort === 'alphabetical' ? '-alphabetical' : 'alphabetical')); + + sortValuesHandler(dataToReorder, undefined); + } + }; + + useEffect(() => { + (async () => { + if (router.isReady && workspaceId === 'undefined') { + router.push('/noprojects'); + } else try { + const { push, query } = router + const tempNumSnapshots = await getProjectSercetSnapshotsCount({ + workspaceId + }); + setNumSnapshots(tempNumSnapshots); + const userWorkspaces = await getWorkspaces(); + const workspace = userWorkspaces.find((wp) => wp._id === workspaceId); + if (!workspace) { + push(`/dashboard/${userWorkspaces?.[0]?._id}`); + } + setAutoCapitalization(workspace?.autoCapitalization ?? true); + + const accessibleEnvironments = await getWorkspaceEnvironments({ workspaceId }); + setWorkspaceEnvs(accessibleEnvironments || []); + + // set env + const env = accessibleEnvironments?.[0] || { + name: 'unknown', + slug: 'unknown' + }; + setSelectedEnv(env); + setSelectedSnapshotEnv(env); + + if (query.env) { + const index = accessibleEnvironments?.findIndex(({ slug }: { slug: string }) => slug === query.env) + + setSelectedEnv({ + name: accessibleEnvironments?.[index]?.name as string, + slug: query.env as string, + isWriteDenied: accessibleEnvironments?.[index]?.isWriteDenied as boolean, + isReadDenied: accessibleEnvironments?.[index]?.isReadDenied as boolean + }) + setSelectedSnapshotEnv({ + name: accessibleEnvironments?.[index]?.name as string, + slug: query.env as string, + isWriteDenied: accessibleEnvironments?.[index]?.isWriteDenied as boolean, + isReadDenied: accessibleEnvironments?.[index]?.isReadDenied as boolean + }) + + } + + const user = await getUser(); + setIsNew((Date.parse(String(new Date())) - Date.parse(user.createdAt)) / 60000 < 3); + + const userAction = await checkUserAction({ + action: 'first_time_secrets_pushed' + }); + setHasUserEverPushed(!!userAction); + + setProjectTags(await getWorkspaceTags({ workspaceId })); + } catch (error) { + console.log('Error', error); + setData(undefined); + } + })(); + }, [workspaceId]); + + useEffect(() => { + (async () => { + try { + setIsLoading(true); + setBlurred(true); + // ENV + let dataToSort; + if (selectedEnv) { + dataToSort = await getSecretsForProject({ + env: selectedEnv.slug, + setIsKeyAvailable, + setData, + workspaceId + }); + setInitialData(dataToSort); + reorderRows(dataToSort); + } else { + setIsLoading(false); + } + } catch (error) { + console.log('Error', error); + setData(undefined); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedEnv]); + + const addRow = () => { + setIsNew(false); + setData([ + { + id: guidGenerator(), + idOverride: guidGenerator(), + pos: data!.length, + key: '', + value: '', + valueOverride: undefined, + comment: '', + tags: [] + }, + ...data! + ]); + if (!atSecretAreaTop) { + secretsTop.current?.scrollIntoView({ behavior: 'smooth' }); + } + }; + + const addRowToBottom = () => { + setIsNew(false); + setData([ + ...data!, + { + id: guidGenerator(), + idOverride: guidGenerator(), + pos: data!.length, + key: '', + value: '', + valueOverride: undefined, + comment: '', + tags: [] + } + ]); + }; + + const deleteRow = ({ ids, secretName }: { ids: string[]; secretName: string }) => { + setHasUnsavedChanges(true); + toggleSidebar('None'); + createNotification({ + text: `${secretName || 'Secret'} has been deleted. Remember to save changes.`, + type: 'error' + }); + sortValuesHandler( + data!.filter((row: SecretDataProps) => !ids.includes(row.id)), + sortMethod === 'alhpabetical' ? '-alphabetical' : 'alphabetical' + ); + }; + + const modifyValue = (value: string, id: string) => { + setData((oldData) => oldData?.map((e) => ((e.id ? e.id : e.idOverride) === id ? { ...e, value } : e))); + setHasUnsavedChanges(true); + }; + + const modifyValueOverride = (valueOverride: string | undefined, id: string) => { + setData((oldData) => oldData?.map((e) => ((e.id ? e.id : e.idOverride) === id ? { ...e, valueOverride } : e))); + setHasUnsavedChanges(true); + }; + + const modifyKey = (key: string, id: string) => { + setData((oldData) => oldData?.map((e) => ((e.id ? e.id : e.idOverride) === id ? { ...e, key } : e))); + setHasUnsavedChanges(true); + }; + + const modifyComment = (comment: string, id: string) => { + setData((oldData) => oldData?.map((e) => ((e.id ? e.id : e.idOverride) === id ? { ...e, comment } : e))); + setHasUnsavedChanges(true); + }; + + const modifyTags = (tags: Tag[], id: string) => { + setData((oldData) => oldData?.map((e) => ((e.id ? e.id : e.idOverride) === id ? { ...e, tags } : e))); + setHasUnsavedChanges(true); + }; + + // For speed purposes and better perforamance, we are using useCallback + const listenChangeValue = useCallback((value: string, id: string) => { + modifyValue(value, id); + }, []); + + const listenChangeValueOverride = useCallback((value: string | undefined, id: string) => { + modifyValueOverride(value, id); + }, []); + + const listenChangeKey = useCallback((value: string, id: string) => { + modifyKey(value, id); + }, []); + + const listenChangeComment = useCallback((value: string, id: string) => { + modifyComment(value, id); + }, []); + + const listenChangeTags = useCallback((value: Tag[], id: string) => { + modifyTags(value, id); + }, []); + + /** + * Save the changes of environment variables and push them to the database + */ + // TODO(akhilmhdh): split and make it small + const savePush = async (dataToPush?: SecretDataProps[]) => { + try { + setSaveLoading(true); + let newData: SecretDataProps[] | null | undefined; + // dataToPush is mostly used for rollbacks, otherwise we always take the current state data + if ((dataToPush ?? [])?.length > 0) { + newData = dataToPush; + } else { + newData = data; + } + + // Checking if any of the secret keys start with a number - if so, don't do anything + const nameErrors = !newData! + .map((secret) => !Number.isNaN(Number(secret.key.charAt(0)))) + .every((v) => v === false); + const emptyNameError = !newData! + .map((secret) => secret.key.length === 0) + .every((v) => v === false); + const duplicatesExist = + findDuplicates(data!.map((item: SecretDataProps) => item.key)).length > 0; + + if (emptyNameError) { + setSaveLoading(false); + return createNotification({ + text: 'You can`t have empty secret names.', + type: 'error' + }); + } + + if (nameErrors) { + setSaveLoading(false); + return createNotification({ + text: 'Solve all name errors before saving secrets.', + type: 'error' + }); + } + + if (duplicatesExist) { + setSaveLoading(false); + return createNotification({ + text: 'Remove duplicated secret names before saving.', + type: 'error' + }); + } + + if (selectedEnv?.isWriteDenied) { + setSaveLoading(false); + return createNotification({ + text: 'You are not allowed to edit this environment', + type: 'error' + }); + } + + // Once "Save changes" is clicked, disable that button + setHasUnsavedChanges(false); + + const secretsToBeDeleted = initialData! + .filter( + (initDataPoint) => + !newData!.map((newDataPoint) => newDataPoint.id).includes(initDataPoint.id) + ) + .map((secret) => secret.id); + console.log('delete', secretsToBeDeleted.length); + + const secretsToBeAdded = newData!.filter( + (newDataPoint) => + !initialData!.map((initDataPoint) => initDataPoint.id).includes(newDataPoint.id) + ); + console.log('add', secretsToBeAdded.length); + + const secretsToBeUpdated = newData!.filter((newDataPoint) => + initialData! + .filter( + (initDataPoint) => + newData!.filter((dataPoint) => dataPoint.id) + .map((dataPoint) => dataPoint.id).includes(initDataPoint.id) && + (newData!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0].value !== + initDataPoint.value || + newData!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0].key !== + initDataPoint.key || + newData!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0].comment !== + initDataPoint.comment || + JSON.stringify(newData!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0]?.tags) !== + JSON.stringify(initDataPoint?.tags)) + ) + .map((secret) => secret.id) + .includes(newDataPoint.id) + ); + console.log('update', secretsToBeUpdated.length); + + const newOverrides = newData!.filter( + (newDataPoint) => newDataPoint.valueOverride !== undefined + ); + const initOverrides = initialData!.filter( + (initDataPoint) => initDataPoint.valueOverride !== undefined + ); + + const overridesToBeDeleted = initOverrides + .filter( + (initDataPoint) => + !newOverrides!.map((newDataPoint) => newDataPoint.id).includes(initDataPoint.id) + ) + .map((secret) => String(secret.idOverride)); + console.log('override delete', overridesToBeDeleted.length); + + const overridesToBeAdded = newOverrides! + .filter( + (newDataPoint) => + !initOverrides.map((initDataPoint) => initDataPoint.idOverride).includes(newDataPoint.idOverride) + ) + .map((override) => ({ + pos: override.pos, + key: override.key, + value: String(override.valueOverride), + valueOverride: override.valueOverride, + comment: '', + id: String(override.idOverride), + idOverride: String(override.idOverride), + tags: override.tags + })); + console.log('override add', overridesToBeAdded.length); + + const overridesToBeUpdated = newOverrides! + .filter((newDataPoint) => + initOverrides + .filter( + (initDataPoint) => + newOverrides!.map((dataPoint) => dataPoint.idOverride) + .includes(initDataPoint.idOverride) && + (newOverrides!.filter((dataPoint) => dataPoint.idOverride === initDataPoint.idOverride)[0] + .valueOverride !== initDataPoint.valueOverride || + newOverrides!.filter((dataPoint) => dataPoint.idOverride === initDataPoint.idOverride)[0].key !== + initDataPoint.key || + (newOverrides!.filter((dataPoint) => dataPoint.idOverride === initDataPoint.idOverride)[0] + .comment || '') !== (initDataPoint.comment || '') + || + (JSON.stringify(newOverrides!.filter((dataPoint) => dataPoint.idOverride === initDataPoint.idOverride)[0]?.tags) || '') !== + (JSON.stringify(initDataPoint?.tags) || '') + ) + ) + .map((secret) => secret.idOverride) + .includes(newDataPoint.idOverride) + ) + .map((override) => ({ + pos: override.pos, + key: override.key, + value: String(override.valueOverride), + valueOverride: override.valueOverride, + comment: '', + id: String(override.idOverride), + idOverride: String(override.idOverride), + tags: override.tags + })); + console.log('override update', overridesToBeUpdated.length); + + const requests: any = []; // TODO: fix any + if (secretsToBeDeleted.concat(overridesToBeDeleted).length > 0) { + secretsToBeDeleted.concat(overridesToBeDeleted).forEach((_id: string) => { + requests.push({ + method: 'DELETE', + secret: { + _id + } + }); + }); + } + if (selectedEnv && secretsToBeAdded.concat(overridesToBeAdded).length > 0) { + const secrets = await encryptSecrets({ + secretsToEncrypt: secretsToBeAdded.concat(overridesToBeAdded), + workspaceId, + env: selectedEnv.slug + }); + if (secrets) { + secrets.forEach((secret) => { + requests.push({ + method: 'POST', + secret: { + type: secret.type, + secretKeyCiphertext: secret.secretKeyCiphertext, + secretKeyIV: secret.secretKeyIV, + secretKeyTag: secret.secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + secretCommentCiphertext: secret.secretCommentCiphertext, + secretCommentIV: secret.secretCommentIV, + secretCommentTag: secret.secretCommentTag, + tags: secret.tags + } + }) + }); + } + } + if (selectedEnv && !selectedEnv.isReadDenied && secretsToBeUpdated.concat(overridesToBeUpdated).length > 0) { + const secrets = await encryptSecrets({ + secretsToEncrypt: secretsToBeUpdated.concat(overridesToBeUpdated), + workspaceId, + env: selectedEnv.slug + }); + if (secrets) { + secrets.forEach((secret) => { + requests.push({ + method: 'PATCH', + secret: { + _id: secret.id, + type: secret.type, + secretKeyCiphertext: secret.secretKeyCiphertext, + secretKeyIV: secret.secretKeyIV, + secretKeyTag: secret.secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + secretCommentCiphertext: secret.secretCommentCiphertext, + secretCommentIV: secret.secretCommentIV, + secretCommentTag: secret.secretCommentTag, + tags: secret.tags + } + }); + }); + } + } + + let newSecrets; + if (selectedEnv && requests.length > 0) { + newSecrets = await batchSecrets({ + workspaceId, + environment: selectedEnv.slug, + requests + }); + } + + let formattedNewDecryptedKeys; + if (newSecrets.createdSecrets) { + const latestKey = await getLatestFileKey({ workspaceId }); + + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; + + const tempDecryptedSecrets: SecretProps[] = []; + if (latestKey) { + // assymmetrically decrypt symmetric key with local private key + const key = decryptAssymmetric({ + ciphertext: latestKey.latestKey.encryptedKey, + nonce: latestKey.latestKey.nonce, + publicKey: latestKey.latestKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + // decrypt secret keys, values, and comments + newSecrets.createdSecrets.forEach((secret: EncryptedSecretProps) => { + const plainTextKey = decryptSymmetric({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key + }); + + let plainTextValue; + if (secret.secretValueCiphertext !== undefined) { + plainTextValue = decryptSymmetric({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key + }); + } else { + plainTextValue = undefined; + } + + let plainTextComment; + if (secret.secretCommentCiphertext) { + plainTextComment = decryptSymmetric({ + ciphertext: secret.secretCommentCiphertext, + iv: secret.secretCommentIV, + tag: secret.secretCommentTag, + key + }); + } else { + plainTextComment = ''; + } + + tempDecryptedSecrets.push({ + id: secret._id, + key: plainTextKey, + value: plainTextValue, + type: secret.type, + comment: plainTextComment, + tags: secret.tags + }); + }); + } + + const secretKeys = [...new Set(tempDecryptedSecrets.map((secret) => secret.key))]; + + formattedNewDecryptedKeys = secretKeys.map((key, index) => ({ + id: tempDecryptedSecrets.filter((secret) => secret.key === key && secret.type === 'shared')[0] + ?.id, + idOverride: tempDecryptedSecrets.filter( + (secret) => secret.key === key && secret.type === 'personal' + )[0]?.id, + pos: (newData?.filter(dp => !dp.id?.includes('-'))?.length ?? 0) + index, + key, + value: tempDecryptedSecrets.filter( + (secret) => secret.key === key && secret.type === 'shared' + )[0]?.value, + valueOverride: tempDecryptedSecrets.filter( + (secret) => secret.key === key && secret.type === 'personal' + )[0]?.value, + comment: tempDecryptedSecrets.filter( + (secret) => secret.key === key && secret.type === 'shared' + )[0]?.comment, + tags: tempDecryptedSecrets.filter( + (secret) => secret.key === key && secret.type === 'shared' + )[0]?.tags + })); + + setInitialData(structuredClone(newData?.filter(dp => !dp.id?.includes('-')).concat(formattedNewDecryptedKeys.filter(dk => dk.id)))); + setData(structuredClone(newData?.filter(dp => !dp.id?.includes('-')).concat(formattedNewDecryptedKeys.filter(dk => dk.id)))) + } else { + setInitialData(structuredClone(newData)); + } + + // If this user has never saved environment variables before, show them a prompt to read docs + if (!hasUserEverPushed) { + setCheckDocsPopUpVisible(true); + await registerUserAction({ action: 'first_time_secrets_pushed' }); + } + + // increasing the number of project commits + setNumSnapshots((numSnapshots ?? 0) + 1); + setSaveLoading(false); + createNotification({ + text: `Successfully saved secrets.`, + type: 'success' + }); + } catch (error) { + console.log("Something went wrong while saving secrets: ", error) + createNotification({ + text: `Something went wrong while saving secrets.`, + type: 'error' + }); + } + return undefined; + }; + + const addDataWithMerge = (newData: SecretDataProps[], preserve?: 'old' | 'new') => { + setData((oldData) => { + let filteredOldData = oldData!; + let filteredNewData = newData; + if (preserve === 'new') + filteredOldData = oldData!.filter( + (oldDataPoint) => !newData.find((newDataPoint) => newDataPoint.key === oldDataPoint.key) + ); + if (preserve === 'old') + filteredNewData = newData.filter( + (newDataPoint) => !oldData?.find((oldDataPoint) => oldDataPoint.key === newDataPoint.key) + ); + return filteredOldData.concat(filteredNewData); + }); + setHasUnsavedChanges(true); + }; + + const addData = (newData: SecretDataProps[]) => { + if ( + newData.some((newDataPoint) => data?.find((dataPoint) => dataPoint.key === newDataPoint.key)) // if newData contains duplicates + ) { + setDropZoneData(newData); + return; + } + addDataWithMerge(newData); + }; + + const changeBlurred = () => { + setBlurred(!blurred); + }; + + const deleteCertainRow = ({ ids, secretName }: { ids: string[]; secretName: string }) => { + deleteRow({ ids, secretName }); + }; + + const handleOnEnvironmentChange = (envName: string) => { + if (hasUnsavedChanges) { + if (!window.confirm(leaveConfirmDefaultMessage)) return; + } + + const selectedWorkspaceEnv = workspaceEnvs.find( + ({ name }: { name: string }) => envName === name + ) || { + name: 'unknown', + slug: 'unknown', + isWriteDenied: false, + isReadDenied: false + }; + + if (selectedWorkspaceEnv) { + if (snapshotData) setSelectedSnapshotEnv(selectedWorkspaceEnv); + else setSelectedEnv(selectedWorkspaceEnv); + } + + setHasUnsavedChanges(false); + router.push({ + pathname: router.pathname, + query: { ...router.query, env: selectedWorkspaceEnv.slug }, + }) + }; + + return <> + {!env + ? + : data ? ( +
{t('common:head-title', { title: t('dashboard:title') })} @@ -22,15 +827,418 @@ const Dashboard = () => { - {env - ? - : } - - ); -}; +
+ setDropZoneData(undefined)} + onOverwriteConfirm={(preserve) => { + addDataWithMerge(dropZoneData!, preserve); + setDropZoneData(undefined); + }} + duplicateKeys={ + dropZoneData + ?.filter((newDataPoint) => + data?.find((dataPoint) => dataPoint.key === newDataPoint.key) + ) + .map((duplicate) => duplicate.key) ?? [] + } + /> +
+ + {checkDocsPopUpVisible && ( + + )} +
+ {snapshotData && ( +
+
+ )} +
+
+

{snapshotData ? 'Secret Snapshot' : t('dashboard:title')}

+ {snapshotData && ( + + {new Date(snapshotData.createdAt).toLocaleString()} + + )} + {selectedEnv?.isReadDenied && ( + + Add Only Mode + + )} +
+ {!snapshotData && data?.length === 0 && selectedEnv && ( + name)} + onChange={handleOnEnvironmentChange} + /> + )} +
+
+
+ {!selectedEnv?.isReadDenied && ( +
+ {(data?.length !== 0 || hasUnsavedChanges) && !snapshotData && ( +
+
+ )} + {snapshotData && selectedEnv && ( +
+
+ )} +
+
+
+
+
+ {(snapshotData || data?.length !== 0) && selectedEnv && ( + <> + {!snapshotData ? ( + name)} + onChange={handleOnEnvironmentChange} + /> + ) : ( + name)} + onChange={handleOnEnvironmentChange} + /> + )} +
+ + setSearchKeys(e.target.value)} + placeholder={String(t('dashboard:search-keys'))} + /> +
+ {!snapshotData && !selectedEnv.isReadDenied && ( +
+ +
+ )} +
+
+ {!snapshotData && ( +
+
+ )} + + )} +
+
+ {isLoading ? ( +
+ infisical loading indicator +
+ ) : (data?.length !== 0 || snapshotData?.secretVersions) ? ( +
+
+
+
+
+
+
0
+ Key + {!snapshotData && reorderRows(1)} + > + {sortMethod === 'alphabetical' ? : } + } +
+
+
+
+ Value +
+
+
+
+
+
+ Comment +
+
+
+
+
+
+ Tags +
+
+
+
+
+
null} + role="none" + onClick={() => { }} + className="invisible group-hover:visible" + > + +
+
+
+
+
+ {!snapshotData && + data + ?.filter((row) => + row.key?.toUpperCase().includes(searchKeys.toUpperCase()) + || row.tags?.map(tag => tag.name).join(" ")?.toUpperCase().includes(searchKeys.toUpperCase()) + || row.comment?.toUpperCase().includes(searchKeys.toUpperCase())) + .filter((row) => !sharedToHide.includes(row.id)) + // .filter((row) => row.value !== undefined) + .map((keyPair) => ( + item.key))?.includes( + keyPair.key + )} + toggleSidebar={toggleSidebar} + togglePITSidebar={togglePITSidebar} + sidebarSecretId={sidebarSecretId} + isSnapshot={false} + deleteRow={deleteCertainRow} + tags={projectTags} + /> + ))} + {snapshotData && + snapshotData.secretVersions + ?.sort((a, b) => a.key.localeCompare(b.key)) + .filter((row) => row.environment === selectedSnapshotEnv?.slug) + .filter((row) => row.key.toUpperCase().includes(searchKeys.toUpperCase())) + .map((keyPair) => ( + item.key))?.includes( + keyPair.key + )} + toggleSidebar={toggleSidebar} + sidebarSecretId={sidebarSecretId} + isSnapshot + tags={projectTags} + /> + ))} +
+
+ +
+
+ {!snapshotData && ( +
+ +
+ )} +
+
+ ) : ( +
+ {isKeyAvailable && !snapshotData && ( + + )} + {!isKeyAvailable && ( + <> + +

To view this file, contact your administrator for permission.

+

They need to grant you access in the team tab.

+ + )} +
+ )} +
+
+ {sidebarSecretId !== 'None' && ( + row.id === sidebarSecretId && row.value !== undefined + )} + modifyKey={listenChangeKey} + modifyValue={listenChangeValue} + modifyValueOverride={listenChangeValueOverride} + modifyComment={listenChangeComment} + buttonReady={hasUnsavedChanges} + workspaceEnvs={workspaceEnvs} + selectedEnv={selectedEnv!} + workspaceId={workspaceId} + savePush={savePush} + sharedToHide={sharedToHide} + setSharedToHide={setSharedToHide} + deleteRow={deleteCertainRow} + /> + )} + {PITSidebarOpen && ( + + )} +
+
+ ) : ( +
+ loading animation +
+ ) + +} Dashboard.requireAuth = true; export const getServerSideProps = getTranslatedServerSideProps(['dashboard']); - -export default Dashboard; diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index be9e3718f..46870805d 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -207,6 +207,9 @@ export default function Integrations() { case 'travisci': link = `${window.location.origin}/integrations/travisci/authorize`; break; + case 'supabase': + link = `${window.location.origin}/integrations/supabase/authorize`; + break; case 'railway': link = `${window.location.origin}/integrations/railway/authorize`; break; @@ -262,6 +265,9 @@ export default function Integrations() { case 'travisci': link = `${window.location.origin}/integrations/travisci/create?integrationAuthId=${integrationAuth._id}`; break; + case 'supabase': + link = `${window.location.origin}/integrations/supabase/create?integrationAuthId=${integrationAuth._id}`; + break; case 'railway': link = `${window.location.origin}/integrations/railway/create?integrationAuthId=${integrationAuth._id}`; break; diff --git a/frontend/src/pages/integrations/supabase/authorize.tsx b/frontend/src/pages/integrations/supabase/authorize.tsx new file mode 100644 index 000000000..95785be6f --- /dev/null +++ b/frontend/src/pages/integrations/supabase/authorize.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { useRouter } from 'next/router'; + +import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps'; +import { Button, Card, CardTitle, FormControl, Input } from '../../../components/v2'; +import saveIntegrationAccessToken from '../../api/integrations/saveIntegrationAccessToken'; + +export default function SupabaseCreateIntegrationPage() { + const router = useRouter(); + const [apiKey, setApiKey] = useState(''); + const [apiKeyErrorText, setApiKeyErrorText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setApiKeyErrorText(''); + if (apiKey.length === 0) { + setApiKeyErrorText('API Key cannot be blank'); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem('projectData.id'), + integration: 'supabase', + accessToken: apiKey, + accessId: null + }); + + setIsLoading(false); + + router.push(`/integrations/supabase/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + Supabase Integration + + setApiKey(e.target.value)} /> + + + +
+ ); +} + +SupabaseCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/supabase/create.tsx b/frontend/src/pages/integrations/supabase/create.tsx new file mode 100644 index 000000000..16e35e36e --- /dev/null +++ b/frontend/src/pages/integrations/supabase/create.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps'; +import { Button, Card, CardTitle, FormControl, Select, SelectItem } from '../../../components/v2'; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthById +} from '../../../hooks/api/integrationAuth'; +import { useGetWorkspaceById } from '../../../hooks/api/workspace'; +import createIntegration from '../../api/integrations/createIntegration'; + +export default function SupabaseCreateIntegrationPage() { + const router = useRouter(); + + const { integrationAuthId } = queryString.parse(router.asPath.split('?')[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); + const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ''); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: (integrationAuthId as string) ?? '' + }); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); + const [targetApp, setTargetApp] = useState(''); + + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp('none'); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: + integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp) + ?.appId ?? null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + targetEnvironmentId: null, + targetService: null, + targetServiceId: null, + owner: null, + path: null, + region: null + }); + + setIsLoading(false); + + router.push(`/integrations/${localStorage.getItem('projectData.id')}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && + workspace && + selectedSourceEnvironment && + integrationAuthApps && + targetApp ? ( +
+ + Supabase Integration + + + + + + + + +
+ ) : ( +
+ ); +} + +SupabaseCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/nginx/default.conf b/nginx/default.conf index f52edda3f..4c0e2434d 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -11,7 +11,8 @@ server { proxy_pass http://backend:4000; proxy_redirect off; - proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict"; + # proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict"; + proxy_cookie_path / "/; HttpOnly; SameSite=strict"; } location / {