diff --git a/.env.example b/.env.example index 3871841c0..ec832f9e9 100644 --- a/.env.example +++ b/.env.example @@ -9,13 +9,13 @@ ENCRYPTION_KEY=replace_with_lengthy_secure_hex JWT_SIGNUP_SECRET=replace_with_lengthy_secure_hex JWT_REFRESH_SECRET=replace_with_lengthy_secure_hex JWT_AUTH_SECRET=replace_with_lengthy_secure_hex +JWT_SERVICE_SECRET=replace_with_lengthy_secure_hex # JWT lifetime # Optional lifetimes for JWT tokens expressed in seconds or a string # describing a time span (e.g. 60, "2 days", "10h", "7d") JWT_AUTH_LIFETIME= JWT_REFRESH_LIFETIME= -JWT_SERVICE_SECRET= JWT_SIGNUP_LIFETIME= # Optional lifetimes for OTP expressed in seconds @@ -47,8 +47,12 @@ SMTP_PASSWORD= # Integration # Optional only if integration is used -OAUTH_CLIENT_SECRET_HEROKU= -OAUTH_TOKEN_URL_HEROKU= +CLIENT_ID_HEROKU= +CLIENT_ID_VERCEL= +CLIENT_ID_NETLIFY= +CLIENT_SECRET_HEROKU= +CLIENT_SECRET_VERCEL= +CLIENT_SECRET_NETLIFY= # Sentry (optional) for monitoring errors SENTRY_DSN= diff --git a/.github/workflows/release_docker_k8_operator.yaml b/.github/workflows/release_docker_k8_operator.yaml index 01aa3b625..788d414b6 100644 --- a/.github/workflows/release_docker_k8_operator.yaml +++ b/.github/workflows/release_docker_k8_operator.yaml @@ -26,13 +26,4 @@ jobs: context: k8-operator push: true platforms: linux/amd64,linux/arm64 - tags: infisical/kubernetes-operator:latest - - - uses: actions/setup-go@v2 - - - name: Upload CRD manifest - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: dist/install-secrets-operator.yaml - tag: ${{ github.ref }} \ No newline at end of file + tags: infisical/kubernetes-operator:latest \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e83973397..9ac38524b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -81,6 +81,7 @@ nfpms: - rpm - deb - apk + - archlinux bindir: /usr/bin scoop: bucket: diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index fa81e63de..000000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": true, - "trailingComma": "none", - "singleQuote": true, - "printWidth": 80, - "useTabs": false -} diff --git a/README.md b/README.md index ba76997f1..caf79191e 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ Slack community channel + + Infisical Twitter + Dashboard @@ -92,12 +95,6 @@ Not sure where to get started? You can: We're currently in Public Alpha. -## 🚨 Stay Up-to-Date - -Infisical officially launched as v.1.0 on November 21st, 2022. However, a lot of new features are coming very quickly. Watch **releases** of this repository to be notified about future updates: - -![infisical-star-github](https://github.com/Infisical/infisical/blob/main/.github/images/star-infisical.gif?raw=true) - ## 🔌 Integrations We're currently setting the foundation and building [integrations](https://infisical.com/docs/integrations/overview) so secrets can be synced everywhere. Any help is welcome! :) @@ -131,10 +128,14 @@ We're currently setting the foundation and building [integrations](https://infis - 🔜 Vercel (https://github.com/Infisical/infisical/issues/60) + + ✔️ Vercel + - 🔜 GitLab CI/CD + + ✔️ Kubernetes + 🔜 Fly.io @@ -156,10 +157,10 @@ We're currently setting the foundation and building [integrations](https://infis 🔜 GCP - 🔜 Kubernetes + 🔜 GitLab CI/CD (https://github.com/Infisical/infisical/issues/134) - 🔜 CircleCI + 🔜 CircleCI (https://github.com/Infisical/infisical/issues/91) @@ -192,7 +193,7 @@ We're currently setting the foundation and building [integrations](https://infis 🔜 Supabase - 🔜 Serverless + 🔜 Render (https://github.com/Infisical/infisical/issues/132) @@ -302,6 +303,12 @@ This repo is entirely MIT licensed, with the exception of the `ee` directory whi Looking to report a security vulnerability? Please don't post about it in GitHub issue. Instead, refer to our [SECURITY.md](./SECURITY.md) file. +## 🚨 Stay Up-to-Date + +Infisical officially launched as v.1.0 on November 21st, 2022. However, a lot of new features are coming very quickly. Watch **releases** of this repository to be notified about future updates: + +![infisical-star-github](https://github.com/Infisical/infisical/blob/main/.github/images/star-infisical.gif?raw=true) + ## 🦸 Contributors [//]: contributor-faces @@ -310,4 +317,4 @@ Looking to report a security vulnerability? Please don't post about it in GitHub - + diff --git a/backend/.eslintrc b/backend/.eslintrc index 7fe198901..c1ca1a1eb 100644 --- a/backend/.eslintrc +++ b/backend/.eslintrc @@ -1,11 +1,10 @@ { "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint", "prettier"], + "plugins": ["@typescript-eslint"], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", - "prettier" + "plugin:@typescript-eslint/recommended" ], "rules": { "no-console": 2 diff --git a/backend/Dockerfile b/backend/Dockerfile index ccc76e66e..85b7204fe 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,12 +4,12 @@ WORKDIR /app COPY package.json package-lock.json ./ -RUN npm ci --only-production +RUN npm ci --only-production --ignore-scripts COPY . . HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \ - CMD node healthcheck.js + CMD node healthcheck.js CMD ["npm", "run", "start"] diff --git a/backend/environment.d.ts b/backend/environment.d.ts index 33827fb2b..853f52e5b 100644 --- a/backend/environment.d.ts +++ b/backend/environment.d.ts @@ -14,8 +14,12 @@ declare global { JWT_SIGNUP_SECRET: string; MONGO_URL: string; NODE_ENV: 'development' | 'staging' | 'testing' | 'production'; - OAUTH_CLIENT_SECRET_HEROKU: string; - OAUTH_TOKEN_URL_HEROKU: string; + CLIENT_ID_HEROKU: string; + CLIENT_ID_VERCEL: string; + CLIENT_ID_NETLIFY: string; + CLIENT_SECRET_HEROKU: string; + CLIENT_SECRET_VERCEL: string; + CLIENT_SECRET_NETLIFY: string; POSTHOG_HOST: string; POSTHOG_PROJECT_API_KEY: string; PRIVATE_KEY: string; diff --git a/backend/package-lock.json b/backend/package-lock.json index c13f138d4..f74a71505 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -49,13 +49,10 @@ "@typescript-eslint/eslint-plugin": "^5.40.1", "@typescript-eslint/parser": "^5.40.1", "eslint": "^8.26.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-prettier": "^4.2.1", "install": "^0.13.0", "jest": "^29.3.1", "nodemon": "^2.0.19", "npm": "^8.19.3", - "prettier": "^2.7.1", "ts-node": "^10.9.1" } }, @@ -2918,12 +2915,6 @@ "@types/node": "*" } }, - "node_modules/@types/prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==", - "dev": true - }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -3770,6 +3761,7 @@ "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", + "fsevents": "~2.3.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", @@ -4266,39 +4258,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-config-prettier": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz", - "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", - "dev": true, - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", - "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", - "dev": true, - "dependencies": { - "prettier-linter-helpers": "^1.0.0" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "eslint": ">=7.28.0", - "prettier": ">=2.0.0" - }, - "peerDependenciesMeta": { - "eslint-config-prettier": { - "optional": true - } - } - }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -4616,12 +4575,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, - "node_modules/fast-diff": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", - "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", - "dev": true - }, "node_modules/fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -5015,6 +4968,7 @@ "minimist": "^1.2.5", "neo-async": "^2.6.0", "source-map": "^0.6.1", + "uglify-js": "^3.1.4", "wordwrap": "^1.0.0" }, "bin": { @@ -5664,6 +5618,7 @@ "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", + "fsevents": "^2.3.2", "graceful-fs": "^4.2.9", "jest-regex-util": "^29.2.0", "jest-util": "^29.3.1", @@ -5880,7 +5835,6 @@ "@jest/transform": "^29.3.1", "@jest/types": "^29.3.1", "@types/babel__traverse": "^7.0.6", - "@types/prettier": "^2.1.5", "babel-preset-current-node-syntax": "^1.0.0", "chalk": "^4.0.0", "expect": "^29.3.1", @@ -6455,9 +6409,11 @@ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.11.0.tgz", "integrity": "sha512-9l9n4Nk2BYZzljW3vHah3Z0rfS5npKw6ktnkmFgTcnzaXH1DRm3pDl6VMHu84EVb1lzmSaJC4OzWZqTkB5i2wg==", "dependencies": { + "@aws-sdk/credential-providers": "^3.186.0", "bson": "^4.7.0", "denque": "^2.1.0", "mongodb-connection-string-url": "^2.5.4", + "saslprep": "^1.0.3", "socks": "^2.7.1" }, "engines": { @@ -6649,9 +6605,6 @@ }, "bin": { "nopt": "bin/nopt.js" - }, - "engines": { - "node": "*" } }, "node_modules/normalize-path": { @@ -7505,6 +7458,7 @@ "inBundle": true, "license": "MIT", "dependencies": { + "@colors/colors": "1.5.0", "string-width": "^4.2.0" }, "engines": { @@ -8300,6 +8254,7 @@ "inBundle": true, "license": "MIT", "dependencies": { + "encoding": "^0.1.13", "minipass": "^3.1.6", "minipass-sized": "^1.0.3", "minizlib": "^2.1.2" @@ -9673,33 +9628,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", - "dev": true, - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/pretty-format": { "version": "29.3.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.3.1.tgz", @@ -13508,12 +13436,6 @@ "@types/node": "*" } }, - "@types/prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==", - "dev": true - }, "@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -14504,22 +14426,6 @@ } } }, - "eslint-config-prettier": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz", - "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", - "dev": true, - "requires": {} - }, - "eslint-plugin-prettier": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", - "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", - "dev": true, - "requires": { - "prettier-linter-helpers": "^1.0.0" - } - }, "eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -14742,12 +14648,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, - "fast-diff": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", - "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", - "dev": true - }, "fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -15672,7 +15572,6 @@ "@jest/transform": "^29.3.1", "@jest/types": "^29.3.1", "@types/babel__traverse": "^7.0.6", - "@types/prettier": "^2.1.5", "babel-preset-current-node-syntax": "^1.0.0", "chalk": "^4.0.0", "expect": "^29.3.1", @@ -18315,21 +18214,6 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true }, - "prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", - "dev": true - }, - "prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "requires": { - "fast-diff": "^1.1.2" - } - }, "pretty-format": { "version": "29.3.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.3.1.tgz", diff --git a/backend/package.json b/backend/package.json index 32ca68b19..bad2908d9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,12 +31,12 @@ "version": "1.0.0", "main": "src/index.js", "scripts": { + "prepare": "cd .. && npm install", "start": "npm run build && node build/index.js", "dev": "nodemon", - "build": "rimraf ./build && tsc && cp -R ./src/templates ./src/json ./build", + "build": "rimraf ./build && tsc && cp -R ./src/templates ./build", "lint": "eslint . --ext .ts", "lint-and-fix": "eslint . --ext .ts --fix", - "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write", "lint-staged": "lint-staged" }, "repository": { @@ -63,13 +63,10 @@ "@typescript-eslint/eslint-plugin": "^5.40.1", "@typescript-eslint/parser": "^5.40.1", "eslint": "^8.26.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-prettier": "^4.2.1", "install": "^0.13.0", "jest": "^29.3.1", "nodemon": "^2.0.19", "npm": "^8.19.3", - "prettier": "^2.7.1", "ts-node": "^10.9.1" } } diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 5575ceb44..e3d62c42c 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,8 +10,13 @@ const JWT_SIGNUP_LIFETIME = process.env.JWT_SIGNUP_LIFETIME! || '15m'; const JWT_SIGNUP_SECRET = process.env.JWT_SIGNUP_SECRET!; const MONGO_URL = process.env.MONGO_URL!; const NODE_ENV = process.env.NODE_ENV! || 'production'; -const OAUTH_CLIENT_SECRET_HEROKU = process.env.OAUTH_CLIENT_SECRET_HEROKU!; -const OAUTH_TOKEN_URL_HEROKU = process.env.OAUTH_TOKEN_URL_HEROKU!; +const CLIENT_SECRET_HEROKU = process.env.CLIENT_SECRET_HEROKU!; +const CLIENT_ID_HEROKU = process.env.CLIENT_ID_HEROKU!; +const CLIENT_ID_VERCEL = process.env.CLIENT_ID_VERCEL!; +const CLIENT_ID_NETLIFY = process.env.CLIENT_ID_NETLIFY!; +const CLIENT_SECRET_VERCEL = process.env.CLIENT_SECRET_VERCEL!; +const CLIENT_SECRET_NETLIFY = process.env.CLIENT_SECRET_NETLIFY!; +const CLIENT_SLUG_VERCEL= process.env.CLIENT_SLUG_VERCEL!; const POSTHOG_HOST = process.env.POSTHOG_HOST! || 'https://app.posthog.com'; const POSTHOG_PROJECT_API_KEY = process.env.POSTHOG_PROJECT_API_KEY! || @@ -46,8 +51,13 @@ export { JWT_SIGNUP_SECRET, MONGO_URL, NODE_ENV, - OAUTH_CLIENT_SECRET_HEROKU, - OAUTH_TOKEN_URL_HEROKU, + CLIENT_ID_HEROKU, + CLIENT_ID_VERCEL, + CLIENT_ID_NETLIFY, + CLIENT_SECRET_HEROKU, + CLIENT_SECRET_VERCEL, + CLIENT_SECRET_NETLIFY, + CLIENT_SLUG_VERCEL, POSTHOG_HOST, POSTHOG_PROJECT_API_KEY, PRIVATE_KEY, diff --git a/backend/src/controllers/botController.ts b/backend/src/controllers/botController.ts new file mode 100644 index 000000000..7819e32df --- /dev/null +++ b/backend/src/controllers/botController.ts @@ -0,0 +1,107 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { Bot, BotKey } from '../models'; +import { createBot } from '../helpers/bot'; + +interface BotKey { + encryptedKey: string; + nonce: string; +} + +/** + * Return bot for workspace with id [workspaceId]. If a workspace bot doesn't exist, + * then create and return a new bot. + * @param req + * @param res + * @returns + */ +export const getBotByWorkspaceId = async (req: Request, res: Response) => { + let bot; + try { + const { workspaceId } = req.params; + + bot = await Bot.findOne({ + workspace: workspaceId + }); + + if (!bot) { + // case: bot doesn't exist for workspace with id [workspaceId] + // -> create a new bot and return it + bot = await createBot({ + name: 'Infisical Bot', + workspaceId + }); + } + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get bot for workspace' + }); + } + + return res.status(200).send({ + bot + }); +}; + +/** + * Return bot with id [req.bot._id] with active state set to [isActive]. + * @param req + * @param res + * @returns + */ +export const setBotActiveState = async (req: Request, res: Response) => { + let bot; + try { + const { isActive, botKey }: { isActive: boolean, botKey: BotKey } = req.body; + + if (isActive) { + // bot state set to active -> share workspace key with bot + if (!botKey?.encryptedKey || !botKey?.nonce) { + return res.status(400).send({ + message: 'Failed to set bot state to active - missing bot key' + }); + } + + await BotKey.findOneAndUpdate({ + workspace: req.bot.workspace + }, { + encryptedKey: botKey.encryptedKey, + nonce: botKey.nonce, + sender: req.user._id, + bot: req.bot._id, + workspace: req.bot.workspace + }, { + upsert: true, + new: true + }); + } else { + // case: bot state set to inactive -> delete bot's workspace key + await BotKey.deleteOne({ + bot: req.bot._id + }); + } + + bot = await Bot.findOneAndUpdate({ + _id: req.bot._id + }, { + isActive + }, { + new: true + }); + + if (!bot) throw new Error('Failed to update bot active state'); + + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to update bot active state' + }); + } + + return res.status(200).send({ + bot + }); +}; diff --git a/backend/src/controllers/index.ts b/backend/src/controllers/index.ts index e52d021b1..a8756b4f3 100644 --- a/backend/src/controllers/index.ts +++ b/backend/src/controllers/index.ts @@ -1,4 +1,5 @@ import * as authController from './authController'; +import * as botController from './botController'; import * as integrationAuthController from './integrationAuthController'; import * as integrationController from './integrationController'; import * as keyController from './keyController'; @@ -17,6 +18,7 @@ import * as logController from './logController'; export { authController, + botController, integrationAuthController, integrationController, keyController, diff --git a/backend/src/controllers/integrationAuthController.ts b/backend/src/controllers/integrationAuthController.ts index 009bcd391..c242c239a 100644 --- a/backend/src/controllers/integrationAuthController.ts +++ b/backend/src/controllers/integrationAuthController.ts @@ -3,69 +3,45 @@ import * as Sentry from '@sentry/node'; import axios from 'axios'; import { readFileSync } from 'fs'; import { IntegrationAuth, Integration } from '../models'; -import { processOAuthTokenRes } from '../helpers/integrationAuth'; -import { INTEGRATION_SET, ENV_DEV } from '../variables'; -import { OAUTH_CLIENT_SECRET_HEROKU, OAUTH_TOKEN_URL_HEROKU } from '../config'; +import { INTEGRATION_SET, INTEGRATION_OPTIONS, ENV_DEV } from '../variables'; +import { IntegrationService } from '../services'; +import { getApps, revokeAccess } from '../integrations'; + +export const getIntegrationOptions = async ( + req: Request, + res: Response +) => { + return res.status(200).send({ + integrationOptions: INTEGRATION_OPTIONS + }); +} /** * Perform OAuth2 code-token exchange as part of integration [integration] for workspace with id [workspaceId] - * Note: integration [integration] must be set up compatible/designed for OAuth2 * @param req * @param res * @returns */ -export const integrationAuthOauthExchange = async ( +export const oAuthExchange = async ( req: Request, res: Response ) => { try { - let clientSecret; - const { workspaceId, code, integration } = req.body; if (!INTEGRATION_SET.has(integration)) throw new Error('Failed to validate integration'); - - // use correct client secret - switch (integration) { - case 'heroku': - clientSecret = OAUTH_CLIENT_SECRET_HEROKU; - } - - // TODO: unfinished - make compatible with other integration types - const res = await axios.post( - OAUTH_TOKEN_URL_HEROKU!, - new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - client_secret: clientSecret - } as any) - ); - - const integrationAuth = await processOAuthTokenRes({ + + await IntegrationService.handleOAuthExchange({ workspaceId, integration, - res + code }); - - // create or replace integration - const integrationObj = await Integration.findOneAndUpdate( - { workspace: workspaceId, integration }, - { - workspace: workspaceId, - environment: ENV_DEV, - isActive: false, - app: null, - integration, - integrationAuth: integrationAuth._id - }, - { upsert: true, new: true } - ); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); return res.status(400).send({ - message: 'Failed to get OAuth2 token' + message: 'Failed to get OAuth2 code-token exchange' }); } @@ -75,26 +51,25 @@ export const integrationAuthOauthExchange = async ( }; /** - * Return list of applications allowed for integration with id [integrationAuthId] + * Return list of applications allowed for integration with integration authorization id [integrationAuthId] * @param req * @param res * @returns */ export const getIntegrationAuthApps = async (req: Request, res: Response) => { - // TODO: unfinished - make compatible with other integration types let apps; try { - const res = await axios.get('https://api.heroku.com/apps', { - headers: { - Accept: 'application/vnd.heroku+json; version=3', - Authorization: 'Bearer ' + req.accessToken - } + apps = await getApps({ + integrationAuth: req.integrationAuth, + accessToken: req.accessToken }); - - apps = res.data.map((a: any) => ({ - name: a.name - })); - } catch (err) {} + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get integration authorization applications' + }); + } return res.status(200).send({ apps @@ -108,46 +83,22 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => { * @returns */ export const deleteIntegrationAuth = async (req: Request, res: Response) => { - // TODO: unfinished - disable application via Heroku API and make compatible with other integration types try { const { integrationAuthId } = req.params; - // TODO: disable application via Heroku API; figure out what authorization id is - - const integrations = JSON.parse( - readFileSync('./src/json/integrations.json').toString() - ); - - let authorizationId; - switch (req.integrationAuth.integration) { - case 'heroku': - authorizationId = integrations.heroku.clientId; - } - - // not sure what authorizationId is? - // // revoke authorization - // const res2 = await axios.delete( - // `https://api.heroku.com/oauth/authorizations/${authorizationId}`, - // { - // headers: { - // 'Accept': 'application/vnd.heroku+json; version=3', - // 'Authorization': 'Bearer ' + req.accessToken - // } - // } - // ); - - const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({ - _id: integrationAuthId + await revokeAccess({ + integrationAuth: req.integrationAuth, + accessToken: req.accessToken }); - - if (deletedIntegrationAuth) { - await Integration.deleteMany({ - integrationAuth: deletedIntegrationAuth._id - }); - } } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); return res.status(400).send({ message: 'Failed to delete integration authorization' }); } -}; + + return res.status(200).send({ + message: 'Successfully deleted integration authorization' + }); +} \ No newline at end of file diff --git a/backend/src/controllers/integrationController.ts b/backend/src/controllers/integrationController.ts index b75d9b74a..910c7e825 100644 --- a/backend/src/controllers/integrationController.ts +++ b/backend/src/controllers/integrationController.ts @@ -1,11 +1,9 @@ import { Request, Response } from 'express'; import { readFileSync } from 'fs'; import * as Sentry from '@sentry/node'; -import axios from 'axios'; -import { Integration } from '../models'; -import { decryptAsymmetric } from '../utils/crypto'; -import { decryptSecrets } from '../helpers/secret'; -import { PRIVATE_KEY } from '../config'; +import { Integration, Bot, BotKey } from '../models'; +import { EventService } from '../services'; +import { eventPushSecrets } from '../events'; interface Key { encryptedKey: string; @@ -24,104 +22,58 @@ interface PushSecret { type: 'shared' | 'personal'; } -/** - * Return list of all available integrations on Infisical - * @param req - * @param res - * @returns - */ -export const getIntegrations = async (req: Request, res: Response) => { - let integrations; - try { - integrations = JSON.parse( - readFileSync('./src/json/integrations.json').toString() - ); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get integrations' - }); - } - - return res.status(200).send({ - integrations - }); -}; - -/** - * Sync secrets [secrets] to integration with id [integrationId] - * @param req - * @param res - * @returns - */ -export const syncIntegration = async (req: Request, res: Response) => { - // TODO: unfinished - make more versatile to accomodate for other integrations - try { - const { key, secrets }: { key: Key; secrets: PushSecret[] } = req.body; - const symmetricKey = decryptAsymmetric({ - ciphertext: key.encryptedKey, - nonce: key.nonce, - publicKey: req.user.publicKey, - privateKey: PRIVATE_KEY - }); - - // decrypt secrets with symmetric key - const content = decryptSecrets({ - secrets, - key: symmetricKey, - format: 'object' - }); - - // TODO: make integration work for other integrations as well - const res = await axios.patch( - `https://api.heroku.com/apps/${req.integration.app}/config-vars`, - content, - { - headers: { - Accept: 'application/vnd.heroku+json; version=3', - Authorization: 'Bearer ' + req.accessToken - } - } - ); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to sync secrets with integration' - }); - } - - return res.status(200).send({ - message: 'Successfully synced secrets with integration' - }); -}; - /** * Change environment or name of integration with id [integrationId] * @param req * @param res * @returns */ -export const modifyIntegration = async (req: Request, res: Response) => { +export const updateIntegration = async (req: Request, res: Response) => { let integration; + + // TODO: add integration-specific validation to ensure that each + // integration has the correct fields populated in [Integration] + try { - const { update } = req.body; - + const { + app, + environment, + isActive, + target, // vercel-specific integration param + context, // netlify-specific integration param + siteId // netlify-specific integration param + } = req.body; + integration = await Integration.findOneAndUpdate( { _id: req.integration._id }, - update, + { + environment, + isActive, + app, + target, + context, + siteId + }, { new: true } ); + + if (integration) { + // trigger event - push secrets + EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId: integration.workspace.toString() + }) + }); + } } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); return res.status(400).send({ - message: 'Failed to modify integration' + message: 'Failed to update integration' }); } @@ -131,7 +83,8 @@ export const modifyIntegration = async (req: Request, res: Response) => { }; /** - * Delete integration with id [integrationId] + * Delete integration with id [integrationId] and deactivate bot if there are + * no integrations left * @param req * @param res * @returns @@ -144,6 +97,29 @@ export const deleteIntegration = async (req: Request, res: Response) => { deletedIntegration = await Integration.findOneAndDelete({ _id: integrationId }); + + if (!deletedIntegration) throw new Error('Failed to find integration'); + + const integrations = await Integration.find({ + workspace: deletedIntegration.workspace + }); + + if (integrations.length === 0) { + // case: no integrations left, deactivate bot + const bot = await Bot.findOneAndUpdate({ + workspace: deletedIntegration.workspace + }, { + isActive: false + }, { + new: true + }); + + if (bot) { + await BotKey.deleteOne({ + bot: bot._id + }); + } + } } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); diff --git a/backend/src/controllers/keyController.ts b/backend/src/controllers/keyController.ts index 1c9b5e15c..778d44b60 100644 --- a/backend/src/controllers/keyController.ts +++ b/backend/src/controllers/keyController.ts @@ -17,16 +17,6 @@ export const uploadKey = async (req: Request, res: Response) => { const { workspaceId } = req.params; const { key } = req.body; - // validate membership of sender - const senderMembership = await findMembership({ - user: req.user._id, - workspace: workspaceId - }); - - if (!senderMembership) { - throw new Error('Failed sender membership validation for workspace'); - } - // validate membership of receiver const receiverMembership = await findMembership({ user: key.userId, diff --git a/backend/src/controllers/secretController.ts b/backend/src/controllers/secretController.ts index d1cf5f65d..bfd9aee1f 100644 --- a/backend/src/controllers/secretController.ts +++ b/backend/src/controllers/secretController.ts @@ -7,8 +7,9 @@ import { reformatPullSecrets } from '../helpers/secret'; import { pushKeys } from '../helpers/key'; +import { eventPushSecrets } from '../events'; +import { EventService } from '../services'; import { ENV_SET } from '../variables'; - import { postHogClient } from '../services'; interface PushSecret { @@ -60,7 +61,8 @@ export const pushSecrets = async (req: Request, res: Response) => { workspaceId, keys }); - + + if (postHogClient) { postHogClient.capture({ event: 'secrets pushed', @@ -74,6 +76,13 @@ export const pushSecrets = async (req: Request, res: Response) => { }); } + // trigger event - push secrets + EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId + }) + }); + } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); @@ -192,7 +201,7 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => { }; if (postHogClient) { - // capture secrets pushed event in production + // capture secrets pulled event in production postHogClient.capture({ distinctId: req.serviceToken.user.email, event: 'secrets pulled', diff --git a/backend/src/events/index.ts b/backend/src/events/index.ts new file mode 100644 index 000000000..461a3ece6 --- /dev/null +++ b/backend/src/events/index.ts @@ -0,0 +1,5 @@ +import { eventPushSecrets } from "./secret" + +export { + eventPushSecrets +} \ No newline at end of file diff --git a/backend/src/events/secret.ts b/backend/src/events/secret.ts new file mode 100644 index 000000000..8bb3a86c3 --- /dev/null +++ b/backend/src/events/secret.ts @@ -0,0 +1,37 @@ +import { EVENT_PUSH_SECRETS } from '../variables'; + +interface PushSecret { + ciphertextKey: string; + ivKey: string; + tagKey: string; + hashKey: string; + ciphertextValue: string; + ivValue: string; + tagValue: string; + hashValue: string; + type: 'shared' | 'personal'; +} + +/** + * Return event for pushing secrets + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace to push secrets to + * @returns + */ +const eventPushSecrets = ({ + workspaceId, +}: { + workspaceId: string; +}) => { + return ({ + name: EVENT_PUSH_SECRETS, + workspaceId, + payload: { + + } + }); +} + +export { + eventPushSecrets +} diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts new file mode 100644 index 000000000..abaf73af4 --- /dev/null +++ b/backend/src/helpers/bot.ts @@ -0,0 +1,230 @@ +import * as Sentry from '@sentry/node'; +import { + Bot, + BotKey, + Secret, + ISecret, + IUser +} from '../models'; +import { + generateKeyPair, + encryptSymmetric, + decryptSymmetric, + decryptAsymmetric +} from '../utils/crypto'; +import { decryptSecrets } from '../helpers/secret'; +import { ENCRYPTION_KEY } from '../config'; +import { SECRET_SHARED } from '../variables'; + +/** + * Create an inactive bot with name [name] for workspace with id [workspaceId] + * @param {Object} obj + * @param {String} obj.name - name of bot + * @param {String} obj.workspaceId - id of workspace that bot belongs to + */ +const createBot = async ({ + name, + workspaceId, +}: { + name: string; + workspaceId: string; +}) => { + let bot; + try { + const { publicKey, privateKey } = generateKeyPair(); + const { ciphertext, iv, tag } = encryptSymmetric({ + plaintext: privateKey, + key: ENCRYPTION_KEY + }); + + bot = await new Bot({ + name, + workspace: workspaceId, + isActive: false, + publicKey, + encryptedPrivateKey: ciphertext, + iv, + tag + }).save(); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to create bot'); + } + + return bot; +} + +/** + * Return decrypted secrets for workspace with id [workspaceId] + * and [environment] using bot + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.environment - environment + */ +const getSecretsHelper = async ({ + workspaceId, + environment +}: { + workspaceId: string; + environment: string; +}) => { + const content = {} as any; + try { + const key = await getKey({ workspaceId }); + const secrets = await Secret.find({ + workspaceId, + environment, + type: SECRET_SHARED + }); + + secrets.forEach((secret: ISecret) => { + const secretKey = decryptSymmetric({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key + }); + + const secretValue = decryptSymmetric({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key + }); + + content[secretKey] = secretValue; + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get secrets'); + } + + return content; +} + +/** + * Return bot's copy of the workspace key for workspace + * with id [workspaceId] + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @returns {String} key - decrypted workspace key + */ +const getKey = async ({ workspaceId }: { workspaceId: string }) => { + let key; + try { + const botKey = await BotKey.findOne({ + workspace: workspaceId + }).populate<{ sender: IUser }>('sender', 'publicKey'); + + if (!botKey) throw new Error('Failed to find bot key'); + + const bot = await Bot.findOne({ + workspace: workspaceId + }).select('+encryptedPrivateKey +iv +tag'); + + if (!bot) throw new Error('Failed to find bot'); + if (!bot.isActive) throw new Error('Bot is not active'); + + const privateKeyBot = decryptSymmetric({ + ciphertext: bot.encryptedPrivateKey, + iv: bot.iv, + tag: bot.tag, + key: ENCRYPTION_KEY + }); + + key = decryptAsymmetric({ + ciphertext: botKey.encryptedKey, + nonce: botKey.nonce, + publicKey: botKey.sender.publicKey as string, + privateKey: privateKeyBot + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get workspace key'); + } + + return key; +} + +/** + * Return symmetrically encrypted [plaintext] using the + * key for workspace with id [workspaceId] + * @param {Object} obj1 + * @param {String} obj1.workspaceId - id of workspace + * @param {String} obj1.plaintext - plaintext to encrypt + */ +const encryptSymmetricHelper = async ({ + workspaceId, + plaintext +}: { + workspaceId: string; + plaintext: string; +}) => { + + try { + const key = await getKey({ workspaceId }); + const { ciphertext, iv, tag } = encryptSymmetric({ + plaintext, + key + }); + + return ({ + ciphertext, + iv, + tag + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to perform symmetric encryption with bot'); + } +} +/** + * Return symmetrically decrypted [ciphertext] using the + * key for workspace with id [workspaceId] + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.ciphertext - ciphertext to decrypt + * @param {String} obj.iv - iv + * @param {String} obj.tag - tag + */ +const decryptSymmetricHelper = async ({ + workspaceId, + ciphertext, + iv, + tag +}: { + workspaceId: string; + ciphertext: string; + iv: string; + tag: string; +}) => { + let plaintext; + try { + const key = await getKey({ workspaceId }); + const plaintext = decryptSymmetric({ + ciphertext, + iv, + tag, + key + }); + + return plaintext; + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to perform symmetric decryption with bot'); + } + + return plaintext; +} + +export { + createBot, + getSecretsHelper, + encryptSymmetricHelper, + decryptSymmetricHelper +} \ No newline at end of file diff --git a/backend/src/helpers/event.ts b/backend/src/helpers/event.ts new file mode 100644 index 000000000..4128752e5 --- /dev/null +++ b/backend/src/helpers/event.ts @@ -0,0 +1,51 @@ +import { Bot, IBot } from '../models'; +import * as Sentry from '@sentry/node'; +import { EVENT_PUSH_SECRETS } from '../variables'; +import { IntegrationService } from '../services'; + +interface Event { + name: string; + workspaceId: string; + payload: any; +} + +/** + * Handle event [event] + * @param {Object} obj + * @param {Event} obj.event - an event + * @param {String} obj.event.name - name of event + * @param {String} obj.event.workspaceId - id of workspace that event is part of + * @param {Object} obj.event.payload - payload of event (depends on event) + */ +const handleEventHelper = async ({ + event +}: { + event: Event; +}) => { + const { workspaceId } = event; + + // TODO: moduralize bot check into separate function + const bot = await Bot.findOne({ + workspace: workspaceId, + isActive: true + }); + + if (!bot) return; + + try { + switch (event.name) { + case EVENT_PUSH_SECRETS: + IntegrationService.syncIntegrations({ + workspaceId + }); + break; + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + } +} + +export { + handleEventHelper +} \ No newline at end of file diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index e69de29bb..ccfd72a53 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -0,0 +1,350 @@ +import * as Sentry from '@sentry/node'; +import { + Bot, + Integration, + IIntegration, + IntegrationAuth, + IIntegrationAuth +} from '../models'; +import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations'; +import { BotService, IntegrationService } from '../services'; +import { + ENV_DEV, + EVENT_PUSH_SECRETS, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY +} from '../variables'; + +interface Update { + workspace: string; + integration: string; + teamId?: string; + accountId?: string; +} + +/** + * Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration + * named [integration] + * - Store integration access and refresh tokens returned from the OAuth2 code-token exchange + * - Add placeholder inactive integration + * - Create bot sequence for integration + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.integration - name of integration + * @param {String} obj.code - code +*/ +const handleOAuthExchangeHelper = async ({ + workspaceId, + integration, + code +}: { + workspaceId: string; + integration: string; + code: string; +}) => { + let action; + let integrationAuth; + try { + const bot = await Bot.findOne({ + workspace: workspaceId, + isActive: true + }); + + if (!bot) throw new Error('Bot must be enabled for OAuth2 code-token exchange'); + + // exchange code for access and refresh tokens + const res = await exchangeCode({ + integration, + code + }); + + const update: Update = { + workspace: workspaceId, + integration + } + + switch (integration) { + case INTEGRATION_VERCEL: + update.teamId = res.teamId; + break; + case INTEGRATION_NETLIFY: + update.accountId = res.accountId; + break; + } + + integrationAuth = await IntegrationAuth.findOneAndUpdate({ + workspace: workspaceId, + integration + }, update, { + new: true, + upsert: true + }); + + if (res.refreshToken) { + // case: refresh token returned from exchange + // set integration auth refresh token + await setIntegrationAuthRefreshHelper({ + integrationAuthId: integrationAuth._id.toString(), + refreshToken: res.refreshToken + }); + } + + if (res.accessToken) { + // case: access token returned from exchange + // set integration auth access token + await setIntegrationAuthAccessHelper({ + integrationAuthId: integrationAuth._id.toString(), + accessToken: res.accessToken, + accessExpiresAt: res.accessExpiresAt + }); + } + + // initialize new integration after exchange + await new Integration({ + workspace: workspaceId, + environment: ENV_DEV, + isActive: false, + app: null, + integration, + integrationAuth: integrationAuth._id + }).save(); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to handle OAuth2 code-token exchange') + } +} +/** + * Sync/push environment variables in workspace with id [workspaceId] to + * all active integrations for that workspace + * @param {Object} obj + * @param {Object} obj.workspaceId - id of workspace + */ +const syncIntegrationsHelper = async ({ + workspaceId +}: { + workspaceId: string; +}) => { + let integrations; + try { + + integrations = await Integration.find({ + workspace: workspaceId, + isActive: true, + app: { $ne: null } + }); + + // for each workspace integration, sync/push secrets + // to that integration + for await (const integration of integrations) { + // get workspace, environment (shared) secrets + const secrets = await BotService.getSecrets({ // issue here? + workspaceId: integration.workspace.toString(), + environment: integration.environment + }); + + const integrationAuth = await IntegrationAuth.findById(integration.integrationAuth); + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + // get integration auth access token + const accessToken = await getIntegrationAuthAccessHelper({ + integrationAuthId: integration.integrationAuth.toString() + }); + + // sync secrets to integration + await syncSecrets({ + integration, + integrationAuth, + secrets, + accessToken + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to integrations'); + } +} + +/** + * Return decrypted refresh token using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} refreshToken - decrypted refresh token + */ + const getIntegrationAuthRefreshHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => { + let refreshToken; + try { + const integrationAuth = await IntegrationAuth + .findById(integrationAuthId) + .select('+refreshCiphertext +refreshIV +refreshTag'); + + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + refreshToken = await BotService.decryptSymmetric({ + workspaceId: integrationAuth.workspace.toString(), + ciphertext: integrationAuth.refreshCiphertext as string, + iv: integrationAuth.refreshIV as string, + tag: integrationAuth.refreshTag as string + }); + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get integration refresh token'); + } + + return refreshToken; +} + +/** + * Return decrypted access token using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @returns {String} accessToken - decrypted access token + */ +const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => { + let accessToken; + try { + const integrationAuth = await IntegrationAuth + .findById(integrationAuthId) + .select('workspace integration +accessCiphertext +accessIV +accessTag +accessExpiresAt + refreshCiphertext'); + + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + accessToken = await BotService.decryptSymmetric({ + workspaceId: integrationAuth.workspace.toString(), + ciphertext: integrationAuth.accessCiphertext as string, + iv: integrationAuth.accessIV as string, + tag: integrationAuth.accessTag as string + }); + + if (integrationAuth?.accessExpiresAt && integrationAuth?.refreshCiphertext) { + // there is a access token expiration date + // and refresh token to exchange with the OAuth2 server + + if (integrationAuth.accessExpiresAt < new Date()) { + // access token is expired + const refreshToken = await getIntegrationAuthRefreshHelper({ integrationAuthId }); + accessToken = await exchangeRefresh({ + integration: integrationAuth.integration, + refreshToken + }); + } + } + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get integration access token'); + } + + return accessToken; +} + +/** + * Encrypt refresh token [refreshToken] using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] and store it + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} obj.refreshToken - refresh token + */ +const setIntegrationAuthRefreshHelper = async ({ + integrationAuthId, + refreshToken +}: { + integrationAuthId: string; + refreshToken: string; +}) => { + + let integrationAuth; + try { + integrationAuth = await IntegrationAuth + .findById(integrationAuthId); + + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + const obj = await BotService.encryptSymmetric({ + workspaceId: integrationAuth.workspace.toString(), + plaintext: refreshToken + }); + + integrationAuth = await IntegrationAuth.findOneAndUpdate({ + _id: integrationAuthId + }, { + refreshCiphertext: obj.ciphertext, + refreshIV: obj.iv, + refreshTag: obj.tag + }, { + new: true + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to set integration auth refresh token'); + } + + return integrationAuth; +} + +/** + * Encrypt access token [accessToken] using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] and store it along with [accessExpiresAt] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} obj.accessToken - access token + * @param {Date} obj.accessExpiresAt - expiration date of access token + */ +const setIntegrationAuthAccessHelper = async ({ + integrationAuthId, + accessToken, + accessExpiresAt +}: { + integrationAuthId: string; + accessToken: string; + accessExpiresAt: Date; +}) => { + let integrationAuth; + try { + integrationAuth = await IntegrationAuth.findById(integrationAuthId); + + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + const obj = await BotService.encryptSymmetric({ + workspaceId: integrationAuth.workspace.toString(), + plaintext: accessToken + }); + + integrationAuth = await IntegrationAuth.findOneAndUpdate({ + _id: integrationAuthId + }, { + accessCiphertext: obj.ciphertext, + accessIV: obj.iv, + accessTag: obj.tag, + accessExpiresAt + }, { + new: true + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to save integration auth access token'); + } + + return integrationAuth; +} + +export { + handleOAuthExchangeHelper, + syncIntegrationsHelper, + getIntegrationAuthRefreshHelper, + getIntegrationAuthAccessHelper, + setIntegrationAuthRefreshHelper, + setIntegrationAuthAccessHelper +} \ No newline at end of file diff --git a/backend/src/helpers/integrationAuth.ts b/backend/src/helpers/integrationAuth.ts index 17f101676..e69de29bb 100644 --- a/backend/src/helpers/integrationAuth.ts +++ b/backend/src/helpers/integrationAuth.ts @@ -1,174 +0,0 @@ -import * as Sentry from '@sentry/node'; -import axios from 'axios'; -import { IntegrationAuth } from '../models'; -import { encryptSymmetric, decryptSymmetric } from '../utils/crypto'; -import { IIntegrationAuth } from '../models'; -import { - ENCRYPTION_KEY, - OAUTH_CLIENT_SECRET_HEROKU, - OAUTH_TOKEN_URL_HEROKU -} from '../config'; - -/** - * Process token exchange and refresh responses from respective OAuth2 authorization servers by - * encrypting access and refresh tokens, computing new access token expiration times [accessExpiresAt], - * and upserting them into the DB for workspace with id [workspaceId] and integration [integration]. - * @param {Object} obj - * @param {String} obj.workspaceId - id of workspace - * @param {String} obj.integration - name of integration (e.g. heroku) - * @param {Object} obj.res - response from OAuth2 authorization server - */ -const processOAuthTokenRes = async ({ - workspaceId, - integration, - res -}: { - workspaceId: string; - integration: string; - res: any; -}): Promise => { - let integrationAuth; - try { - // encrypt refresh + access tokens - const { - ciphertext: refreshCiphertext, - iv: refreshIV, - tag: refreshTag - } = encryptSymmetric({ - plaintext: res.data.refresh_token, - key: ENCRYPTION_KEY - }); - - const { - ciphertext: accessCiphertext, - iv: accessIV, - tag: accessTag - } = encryptSymmetric({ - plaintext: res.data.access_token, - key: ENCRYPTION_KEY - }); - - // compute access token expiration date - const accessExpiresAt = new Date(); - accessExpiresAt.setSeconds( - accessExpiresAt.getSeconds() + res.data.expires_in - ); - - // create or replace integration authorization with encrypted tokens - // and access token expiration date - integrationAuth = await IntegrationAuth.findOneAndUpdate( - { workspace: workspaceId, integration }, - { - workspace: workspaceId, - integration, - refreshCiphertext, - refreshIV, - refreshTag, - accessCiphertext, - accessIV, - accessTag, - accessExpiresAt - }, - { upsert: true, new: true } - ); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error( - 'Failed to process OAuth2 authorization server token response' - ); - } - - return integrationAuth; -}; - -/** - * Return access token for integration either by decrypting a non-expired access token [accessCiphertext] on - * the integration authorization document or by requesting a new one by decrypting and exchanging the - * refresh token [refreshCiphertext] with the respective OAuth2 authorization server. - * @param {Object} obj - * @param {IIntegrationAuth} obj.integrationAuth - an integration authorization document - * @returns {String} access token - new access token - */ -const getOAuthAccessToken = async ({ - integrationAuth -}: { - integrationAuth: IIntegrationAuth; -}) => { - let accessToken; - try { - const { - refreshCiphertext, - refreshIV, - refreshTag, - accessCiphertext, - accessIV, - accessTag, - accessExpiresAt - } = integrationAuth; - - if ( - refreshCiphertext && - refreshIV && - refreshTag && - accessCiphertext && - accessIV && - accessTag && - accessExpiresAt - ) { - if (accessExpiresAt < new Date()) { - // case: access token expired - // TODO: fetch another access token - - let clientSecret; - switch (integrationAuth.integration) { - case 'heroku': - clientSecret = OAUTH_CLIENT_SECRET_HEROKU; - } - - // record new access token and refresh token - // encrypt refresh + access tokens - const refreshToken = decryptSymmetric({ - ciphertext: refreshCiphertext, - iv: refreshIV, - tag: refreshTag, - key: ENCRYPTION_KEY - }); - - // TODO: make route compatible with other integration types - const res = await axios.post( - OAUTH_TOKEN_URL_HEROKU, // maybe shouldn't be a config variable? - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_secret: clientSecret - } as any) - ); - - accessToken = res.data.access_token; - - await processOAuthTokenRes({ - workspaceId: integrationAuth.workspace.toString(), - integration: integrationAuth.integration, - res - }); - } else { - // case: access token still works - accessToken = decryptSymmetric({ - ciphertext: accessCiphertext, - iv: accessIV, - tag: accessTag, - key: ENCRYPTION_KEY - }); - } - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to get OAuth2 access token'); - } - - return accessToken; -}; - -export { processOAuthTokenRes, getOAuthAccessToken }; diff --git a/backend/src/helpers/membership.ts b/backend/src/helpers/membership.ts index 14cd567bb..1ff542f2a 100644 --- a/backend/src/helpers/membership.ts +++ b/backend/src/helpers/membership.ts @@ -1,6 +1,51 @@ import * as Sentry from '@sentry/node'; import { Membership, Key } from '../models'; +/** + * Validate that user with id [userId] is a member of workspace with id [workspaceId] + * and has at least one of the roles in [acceptedRoles] and statuses in [acceptedStatuses] + * @param {Object} obj + * @param {String} obj.userId - id of user to validate + * @param {String} obj.workspaceId - id of workspace + */ +const validateMembership = async ({ + userId, + workspaceId, + acceptedRoles, + acceptedStatuses +}: { + userId: string; + workspaceId: string; + acceptedRoles: string[]; + acceptedStatuses: string[]; +}) => { + + let membership; + try { + membership = await Membership.findOne({ + user: userId, + workspace: workspaceId + }); + + if (!membership) throw new Error('Failed to find membership'); + + if (!acceptedRoles.includes(membership.role)) { + throw new Error('Failed to validate membership role'); + } + + if (!acceptedStatuses.includes(membership.status)) { + throw new Error('Failed to validate membership status'); + } + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to validate membership'); + } + + return membership; +} + /** * Return membership matching criteria specified in query [queryObj] * @param {Object} queryObj - query object @@ -97,4 +142,9 @@ const deleteMembership = async ({ membershipId }: { membershipId: string }) => { return deletedMembership; }; -export { addMemberships, findMembership, deleteMembership }; +export { + validateMembership, + addMemberships, + findMembership, + deleteMembership +}; diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts index 52d7d227b..b43252bf3 100644 --- a/backend/src/helpers/workspace.ts +++ b/backend/src/helpers/workspace.ts @@ -1,13 +1,16 @@ import * as Sentry from '@sentry/node'; import { Workspace, + Bot, Membership, Key, Secret } from '../models'; +import { createBot } from '../helpers/bot'; /** * Create a workspace with name [name] in organization with id [organizationId] + * and a bot for it. * @param {String} name - name of workspace to create. * @param {String} organizationId - id of organization to create workspace in * @param {Object} workspace - new workspace @@ -21,10 +24,16 @@ const createWorkspace = async ({ }) => { let workspace; try { + // create workspace workspace = await new Workspace({ name, organization: organizationId }).save(); + + const bot = await createBot({ + name: 'Infisical Bot', + workspaceId: workspace._id.toString() + }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -43,6 +52,9 @@ const createWorkspace = async ({ const deleteWorkspace = async ({ id }: { id: string }) => { try { await Workspace.deleteOne({ _id: id }); + await Bot.deleteOne({ + workspace: id + }); await Membership.deleteMany({ workspace: id }); diff --git a/backend/src/index.ts b/backend/src/index.ts index fd4f867af..7e1d1411f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -23,23 +23,24 @@ Sentry.init({ }); import { - signup as signupRouter, - auth as authRouter, - organization as organizationRouter, - workspace as workspaceRouter, - membershipOrg as membershipOrgRouter, - membership as membershipRouter, - key as keyRouter, - inviteOrg as inviteOrgRouter, - user as userRouter, - userAction as userActionRouter, - secret as secretRouter, - serviceToken as serviceTokenRouter, - password as passwordRouter, - stripe as stripeRouter, - integration as integrationRouter, - integrationAuth as integrationAuthRouter, - log as logRouter + signup as signupRouter, + auth as authRouter, + bot as botRouter, + organization as organizationRouter, + workspace as workspaceRouter, + membershipOrg as membershipOrgRouter, + membership as membershipRouter, + key as keyRouter, + inviteOrg as inviteOrgRouter, + user as userRouter, + userAction as userActionRouter, + secret as secretRouter, + serviceToken as serviceTokenRouter, + password as passwordRouter, + stripe as stripeRouter, + integration as integrationRouter, + integrationAuth as integrationAuthRouter, + log as logRouter } from './routes'; const connectWithRetry = () => { @@ -79,6 +80,7 @@ app.use(express.json()); // routers app.use('/api/v1/signup', signupRouter); app.use('/api/v1/auth', authRouter); +app.use('/api/v1/bot', botRouter); app.use('/api/v1/user', userRouter); app.use('/api/v1/user-action', userActionRouter); app.use('/api/v1/organization', organizationRouter); diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts new file mode 100644 index 000000000..70680ef7d --- /dev/null +++ b/backend/src/integrations/apps.ts @@ -0,0 +1,169 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/node'; +import { + IIntegrationAuth +} from '../models'; +import { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL +} from '../variables'; + +/** + * Return list of names of apps for integration named [integration] + * @param {Object} obj + * @param {String} obj.integration - name of integration + * @param {String} obj.accessToken - access token for integration + * @returns {Object[]} apps - names of integration apps + * @returns {String} apps.name - name of integration app + */ +const getApps = async ({ + integrationAuth, + accessToken +}: { + integrationAuth: IIntegrationAuth; + accessToken: string; +}) => { + + interface App { + name: string; + siteId?: string; + } + + let apps: App[]; // TODO: add type and define payloads for apps + try { + switch (integrationAuth.integration) { + case INTEGRATION_HEROKU: + apps = await getAppsHeroku({ + accessToken + }); + break; + case INTEGRATION_VERCEL: + apps = await getAppsVercel({ + accessToken + }); + break; + case INTEGRATION_NETLIFY: + apps = await getAppsNetlify({ + integrationAuth, + accessToken + }); + break; + } + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get integration apps'); + } + + return apps; +} + +/** + * Return list of names of apps for Heroku integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Heroku API + * @returns {Object[]} apps - names of Heroku apps + * @returns {String} apps.name - name of Heroku app + */ +const getAppsHeroku = async ({ + accessToken +}: { + accessToken: string; +}) => { + let apps; + try { + const res = (await axios.get(`${INTEGRATION_HEROKU_API_URL}/apps`, { + headers: { + Accept: 'application/vnd.heroku+json; version=3', + Authorization: `Bearer ${accessToken}` + } + })).data; + + apps = res.map((a: any) => ({ + name: a.name + })); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Heroku integration apps'); + } + + return apps; +} + +/** + * Return list of names of apps for Vercel integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Vercel API + * @returns {Object[]} apps - names of Vercel apps + * @returns {String} apps.name - name of Vercel app + */ +const getAppsVercel = async ({ + accessToken +}: { + accessToken: string; +}) => { + let apps; + try { + const res = (await axios.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + })).data; + + apps = res.projects.map((a: any) => ({ + name: a.name + })); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Vercel integration apps'); + } + + return apps; +} + +/** + * Return list of names of sites for Netlify integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Netlify API + * @returns {Object[]} apps - names of Netlify sites + * @returns {String} apps.name - name of Netlify site + */ +const getAppsNetlify = async ({ + integrationAuth, + accessToken +}: { + integrationAuth: IIntegrationAuth; + accessToken: string; +}) => { + let apps; + try { + const res = (await axios.get(`${INTEGRATION_NETLIFY_API_URL}/api/v1/sites`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + })).data; + + apps = res.map((a: any) => ({ + name: a.name, + siteId: a.site_id + })); + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Netlify integration apps'); + } + + return apps; +} + +export { + getApps +} \ No newline at end of file diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts new file mode 100644 index 000000000..265036ee0 --- /dev/null +++ b/backend/src/integrations/exchange.ts @@ -0,0 +1,241 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/node'; +import { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, + ACTION_PUSH_TO_HEROKU +} from '../variables'; +import { + SITE_URL, + CLIENT_SECRET_HEROKU, + CLIENT_ID_VERCEL, + CLIENT_ID_NETLIFY, + CLIENT_SECRET_VERCEL, + CLIENT_SECRET_NETLIFY +} from '../config'; + +interface ExchangeCodeHerokuResponse { + token_type: string; + access_token: string; + expires_in: number; + refresh_token: string; + user_id: string; + session_nonce?: string; +} + +interface ExchangeCodeVercelResponse { + token_type: string; + access_token: string; + installation_id: string; + user_id: string; + team_id?: string; +} + +interface ExchangeCodeNetlifyResponse { + access_token: string; + token_type: string; + refresh_token: string; + scope: string; + created_at: number; +} + +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for OAuth2 + * code-token exchange for integration named [integration] + * @param {Object} obj1 + * @param {String} obj1.integration - name of integration + * @param {String} obj1.code - code for code-token exchange + * @returns {Object} obj + * @returns {String} obj.accessToken - access token for integration + * @returns {String} obj.refreshToken - refresh token for integration + * @returns {Date} obj.accessExpiresAt - date of expiration for access token + * @returns {String} obj.action - integration action for bot sequence + */ +const exchangeCode = async ({ + integration, + code +}: { + integration: string; + code: string; +}) => { + let obj = {} as any; + + try { + switch (integration) { + case INTEGRATION_HEROKU: + obj = await exchangeCodeHeroku({ + code + }); + break; + case INTEGRATION_VERCEL: + obj = await exchangeCodeVercel({ + code + }); + break; + case INTEGRATION_NETLIFY: + obj = await exchangeCodeNetlify({ + code + }); + break; + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange'); + } + + return obj; +} + +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for Heroku + * OAuth2 code-token exchange + * @param {Object} obj1 + * @param {Object} obj1.code - code for code-token exchange + * @returns {Object} obj2 + * @returns {String} obj2.accessToken - access token for Heroku API + * @returns {String} obj2.refreshToken - refresh token for Heroku API + * @returns {Date} obj2.accessExpiresAt - date of expiration for access token + */ +const exchangeCodeHeroku = async ({ + code +}: { + code: string; +}) => { + let res: ExchangeCodeHerokuResponse; + const accessExpiresAt = new Date(); + try { + res = (await axios.post( + INTEGRATION_HEROKU_TOKEN_URL, + new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_secret: CLIENT_SECRET_HEROKU + } as any) + )).data; + + accessExpiresAt.setSeconds( + accessExpiresAt.getSeconds() + res.expires_in + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Heroku'); + } + + return ({ + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt + }); +} + +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel + * code-token exchange + * @param {Object} obj1 + * @param {Object} obj1.code - code for code-token exchange + * @returns {Object} obj2 + * @returns {String} obj2.accessToken - access token for Heroku API + * @returns {String} obj2.refreshToken - refresh token for Heroku API + * @returns {Date} obj2.accessExpiresAt - date of expiration for access token + */ +const exchangeCodeVercel = async ({ + code +}: { + code: string; +}) => { + let res: ExchangeCodeVercelResponse; + try { + res = (await axios.post( + INTEGRATION_VERCEL_TOKEN_URL, + new URLSearchParams({ + code: code, + client_id: CLIENT_ID_VERCEL, + client_secret: CLIENT_SECRET_VERCEL, + redirect_uri: `${SITE_URL}/vercel` + } as any) + )).data; + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Vercel'); + } + + return ({ + accessToken: res.access_token, + refreshToken: null, + accessExpiresAt: null, + teamId: res.team_id + }); +} + +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel + * code-token exchange + * @param {Object} obj1 + * @param {Object} obj1.code - code for code-token exchange + * @returns {Object} obj2 + * @returns {String} obj2.accessToken - access token for Heroku API + * @returns {String} obj2.refreshToken - refresh token for Heroku API + * @returns {Date} obj2.accessExpiresAt - date of expiration for access token + */ +const exchangeCodeNetlify = async ({ + code +}: { + code: string; +}) => { + let res: ExchangeCodeNetlifyResponse; + let accountId; + try { + res = (await axios.post( + INTEGRATION_NETLIFY_TOKEN_URL, + new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_id: CLIENT_ID_NETLIFY, + client_secret: CLIENT_SECRET_NETLIFY, + redirect_uri: `${SITE_URL}/netlify` + } as any) + )).data; + + const res2 = await axios.get( + 'https://api.netlify.com/api/v1/sites', + { + headers: { + Authorization: `Bearer ${res.access_token}` + } + } + ); + + const res3 = (await axios.get( + 'https://api.netlify.com/api/v1/accounts', + { + headers: { + Authorization: `Bearer ${res.access_token}` + } + } + )).data; + + accountId = res3[0].id; + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Netlify'); + } + + return ({ + accessToken: res.access_token, + refreshToken: res.refresh_token, + accountId + }); +} + +export { + exchangeCode +} \ No newline at end of file diff --git a/backend/src/integrations/index.ts b/backend/src/integrations/index.ts new file mode 100644 index 000000000..86c22de0c --- /dev/null +++ b/backend/src/integrations/index.ts @@ -0,0 +1,13 @@ +import { exchangeCode } from './exchange'; +import { exchangeRefresh } from './refresh'; +import { getApps } from './apps'; +import { syncSecrets } from './sync'; +import { revokeAccess } from './revoke'; + +export { + exchangeCode, + exchangeRefresh, + getApps, + syncSecrets, + revokeAccess +} \ No newline at end of file diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts new file mode 100644 index 000000000..16870944d --- /dev/null +++ b/backend/src/integrations/refresh.ts @@ -0,0 +1,78 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/node'; +import { INTEGRATION_HEROKU } from '../variables'; +import { + CLIENT_SECRET_HEROKU +} from '../config'; +import { + INTEGRATION_HEROKU_TOKEN_URL +} from '../variables'; + +/** + * Return new access token by exchanging refresh token [refreshToken] for integration + * named [integration] + * @param {Object} obj + * @param {String} obj.integration - name of integration + * @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku + */ +const exchangeRefresh = async ({ + integration, + refreshToken +}: { + integration: string; + refreshToken: string; +}) => { + let accessToken; + try { + switch (integration) { + case INTEGRATION_HEROKU: + accessToken = await exchangeRefreshHeroku({ + refreshToken + }); + break; + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get new OAuth2 access token'); + } + + return accessToken; +} + +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * Heroku integration + * @param {Object} obj + * @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku + * @returns + */ +const exchangeRefreshHeroku = async ({ + refreshToken +}: { + refreshToken: string; +}) => { + let accessToken; + try { + const res = await axios.post( + INTEGRATION_HEROKU_TOKEN_URL, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_secret: CLIENT_SECRET_HEROKU + } as any) + ); + + accessToken = res.data.access_token; + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get new OAuth2 access token for Heroku'); + } + + return accessToken; +} + +export { + exchangeRefresh +} \ No newline at end of file diff --git a/backend/src/integrations/revoke.ts b/backend/src/integrations/revoke.ts new file mode 100644 index 000000000..43a1582b7 --- /dev/null +++ b/backend/src/integrations/revoke.ts @@ -0,0 +1,50 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/node'; +import { + IIntegrationAuth, + IntegrationAuth, + Integration +} from '../models'; +import { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY +} from '../variables'; + +const revokeAccess = async ({ + integrationAuth, + accessToken +}: { + integrationAuth: IIntegrationAuth, + accessToken: string +}) => { + try { + // add any integration-specific revocation logic + switch (integrationAuth.integration) { + case INTEGRATION_HEROKU: + break; + case INTEGRATION_VERCEL: + break; + case INTEGRATION_NETLIFY: + break; + } + + const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({ + _id: integrationAuth._id + }); + + if (deletedIntegrationAuth) { + await Integration.deleteMany({ + integrationAuth: deletedIntegrationAuth._id + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to delete integration authorization'); + } +} + +export { + revokeAccess +} \ No newline at end of file diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts new file mode 100644 index 000000000..b26bdee74 --- /dev/null +++ b/backend/src/integrations/sync.ts @@ -0,0 +1,481 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/node'; +import { + IIntegration, IIntegrationAuth +} from '../models'; +import { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL +} from '../variables'; + +// TODO: need a helper function in the future to handle integration +// envar priorities (i.e. prioritize secrets within integration or those on Infisical) + +/** + * Sync/push [secrets] to [app] in integration named [integration] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {IIntegrationAuth} obj.integrationAuth - integration auth details + * @param {Object} obj.app - app in integration + * @param {Object} obj.target - (optional) target (environment) in integration + * @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 integration + */ +const syncSecrets = async ({ + integration, + integrationAuth, + secrets, + accessToken, +}: { + integration: IIntegration; + integrationAuth: IIntegrationAuth; + secrets: any; + accessToken: string; +}) => { + try { + switch (integration.integration) { + case INTEGRATION_HEROKU: + await syncSecretsHeroku({ + integration, + secrets, + accessToken + }); + break; + case INTEGRATION_VERCEL: + await syncSecretsVercel({ + integration, + secrets, + accessToken + }); + break; + case INTEGRATION_NETLIFY: + await syncSecretsNetlify({ + integration, + integrationAuth, + secrets, + accessToken + }); + break; + } + + // TODO: set integration to inactive if it was not synced correctly (send alert?) + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to integration'); + } +} + +/** + * Sync/push [secrets] to Heroku [app] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + */ +const syncSecretsHeroku = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration, + secrets: any; + accessToken: string; +}) => { + try { + const herokuSecrets = (await axios.get( + `${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`, + { + headers: { + Accept: 'application/vnd.heroku+json; version=3', + Authorization: `Bearer ${accessToken}` + } + } + )).data; + + Object.keys(herokuSecrets).forEach(key => { + if (!(key in secrets)) { + secrets[key] = null; + } + }); + + await axios.patch( + `${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`, + secrets, + { + headers: { + Accept: 'application/vnd.heroku+json; version=3', + Authorization: `Bearer ${accessToken}` + } + } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Heroku'); + } +} + +/** + * Sync/push [secrets] to Heroku [app] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + */ +const syncSecretsVercel = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration, + secrets: any; + accessToken: string; +}) => { + + interface VercelSecret { + id?: string; + type: string; + key: string; + value: string; + target: string[]; + } + + try { + // Get all (decrypted) secrets back from Vercel in + // decrypted format + const params = new URLSearchParams({ + decrypt: "true" + }); + + const res = (await Promise.all((await axios.get( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env`, + { + params, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + )) + .data + .envs + .filter((secret: VercelSecret) => secret.target.includes(integration.target)) + .map(async (secret: VercelSecret) => (await axios.get( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + + } + )).data) + )).reduce((obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret + }), {}); + + const updateSecrets: VercelSecret[] = []; + const deleteSecrets: VercelSecret[] = []; + const newSecrets: VercelSecret[] = []; + + // Identify secrets to create + Object.keys(secrets).map((key) => { + if (!(key in res)) { + // case: secret has been created + newSecrets.push({ + key: key, + value: secrets[key], + type: 'encrypted', + target: [integration.target] + }); + } + }); + + // Identify secrets to update and delete + Object.keys(res).map((key) => { + if (key in secrets) { + if (res[key].value !== secrets[key]) { + // case: secret value has changed + updateSecrets.push({ + id: res[key].id, + key: key, + value: secrets[key], + type: 'encrypted', + target: [integration.target] + }); + } + } else { + // case: secret has been deleted + deleteSecrets.push({ + id: res[key].id, + key: key, + value: res[key].value, + type: 'encrypted', + target: [integration.target], + }); + } + }); + + // Sync/push new secrets + if (newSecrets.length > 0) { + await axios.post( + `${INTEGRATION_VERCEL_API_URL}/v10/projects/${integration.app}/env`, + newSecrets, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + } + + // Sync/push updated secrets + if (updateSecrets.length > 0) { + updateSecrets.forEach(async (secret: VercelSecret) => { + const { + id, + ...updatedSecret + } = secret; + await axios.patch( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, + updatedSecret, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + + // Delete secrets + if (deleteSecrets.length > 0) { + deleteSecrets.forEach(async (secret: VercelSecret) => { + await axios.delete( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Vercel'); + } +} + +/** + * Sync/push [secrets] to Netlify site [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) + */ +const syncSecretsNetlify = async ({ + integration, + integrationAuth, + secrets, + accessToken +}: { + integration: IIntegration; + integrationAuth: IIntegrationAuth; + secrets: any; + accessToken: string; +}) => { + try { + + interface NetlifyValue { + id?: string; + context: string; // 'dev' | 'branch-deploy' | 'deploy-preview' | 'production', + value: string; + } + + interface NetlifySecret { + key: string; + values: NetlifyValue[]; + } + + interface NetlifySecretsRes { + [index: string]: NetlifySecret; + } + + const getParams = new URLSearchParams({ + context_name: 'all', // integration.context or all + site_id: integration.siteId + }); + + const res = (await axios.get( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, + { + params: getParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + )) + .data + .reduce((obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret + }), {}); + + const newSecrets: NetlifySecret[] = []; // createEnvVars + const deleteSecrets: string[] = []; // deleteEnvVar + const deleteSecretValues: NetlifySecret[] = []; // deleteEnvVarValue + const updateSecrets: NetlifySecret[] = []; // setEnvVarValue + + // identify secrets to create and update + Object.keys(secrets).map((key) => { + if (!(key in res)) { + // case: Infisical secret does not exist in Netlify -> create secret + newSecrets.push({ + key, + values: [{ + value: secrets[key], + context: integration.context + }] + }); + } else { + // case: Infisical secret exists in Netlify + const contexts = res[key].values + .reduce((obj: any, value: NetlifyValue) => ({ + ...obj, + [value.context]: value + }), {}); + + if (integration.context in contexts) { + // case: Netlify secret value exists in integration context + if (secrets[key] !== contexts[integration.context].value) { + // case: Infisical and Netlify secret values are different + // -> update Netlify secret context and value + updateSecrets.push({ + key, + values: [{ + context: integration.context, + value: secrets[key] + }] + }); + } + } else { + // case: Netlify secret value does not exist in integration context + // -> add the new Netlify secret context and value + updateSecrets.push({ + key, + values: [{ + context: integration.context, + value: secrets[key] + }] + }); + } + } + }) + + // identify secrets to delete + // TODO: revise (patch case where 1 context was deleted but others still there + Object.keys(res).map((key) => { + // loop through each key's context + if (!(key in secrets)) { + // case: Netlify secret does not exist in Infisical + + const numberOfValues = res[key].values.length; + + res[key].values.forEach((value: NetlifyValue) => { + if (value.context === integration.context) { + if (numberOfValues <= 1) { + // case: Netlify secret value has less than 1 context -> delete secret + deleteSecrets.push(key); + } else { + // case: Netlify secret value has more than 1 context -> delete secret value context + deleteSecretValues.push({ + key, + values: [{ + id: value.id, + context: integration.context, + value: value.value + }] + }); + } + } + }); + } + }); + + const syncParams = new URLSearchParams({ + site_id: integration.siteId + }); + + if (newSecrets.length > 0) { + await axios.post( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, + newSecrets, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + } + + if (updateSecrets.length > 0) { + updateSecrets.forEach(async (secret: NetlifySecret) => { + await axios.patch( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, + { + context: secret.values[0].context, + value: secret.values[0].value + }, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + + if (deleteSecrets.length > 0) { + deleteSecrets.forEach(async (key: string) => { + await axios.delete( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${key}`, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + + if (deleteSecretValues.length > 0) { + deleteSecretValues.forEach(async (secret: NetlifySecret) => { + await axios.delete( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}/value/${secret.values[0].id}`, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Heroku'); + } +} + + +export { + syncSecrets +} \ No newline at end of file diff --git a/backend/src/json/integrations.json b/backend/src/json/integrations.json deleted file mode 100644 index 16b09ebf4..000000000 --- a/backend/src/json/integrations.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "heroku": { - "name": "Heroku", - "type": "oauth2", - "clientId": "bc132901-935a-4590-b010-f1857efc380d", - "docsLink": "" - }, - "netlify": { - "name": "Netlify", - "type": "oauth2", - "clientId": "", - "docsLink": "" - }, - "digitalocean": { - "name": "Digital Ocean", - "type": "oauth2", - "clientId": "", - "docsLink": "" - }, - "gcp": { - "name": "Google Cloud Platform", - "type": "oauth2", - "clientId": "", - "docsLink": "" - }, - "aws": { - "name": "Amazon Web Services", - "type": "oauth2", - "clientId": "", - "docsLink": "" - }, - "azure": { - "name": "Microsoft Azure", - "type": "oauth2", - "clientId": "", - "docsLink": "" - }, - "travisci": { - "name": "Travis CI", - "type": "oauth2", - "clientId": "", - "docsLink": "" - }, - "circleci": { - "name": "Circle CI", - "type": "oauth2", - "clientId": "", - "docsLink": "" - } -} diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index e445b64cf..7fcba66e1 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -1,4 +1,5 @@ import requireAuth from './requireAuth'; +import requireBotAuth from './requireBotAuth'; import requireSignupAuth from './requireSignupAuth'; import requireWorkspaceAuth from './requireWorkspaceAuth'; import requireOrganizationAuth from './requireOrganizationAuth'; @@ -9,6 +10,7 @@ import validateRequest from './validateRequest'; export { requireAuth, + requireBotAuth, requireSignupAuth, requireWorkspaceAuth, requireOrganizationAuth, diff --git a/backend/src/middleware/requireBotAuth.ts b/backend/src/middleware/requireBotAuth.ts new file mode 100644 index 000000000..6c5a3820a --- /dev/null +++ b/backend/src/middleware/requireBotAuth.ts @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/node'; +import { Request, Response, NextFunction } from 'express'; +import { Bot } from '../models'; +import { validateMembership } from '../helpers/membership'; + +type req = 'params' | 'body' | 'query'; + +const requireBotAuth = ({ + acceptedRoles, + acceptedStatuses, + location = 'params' +}: { + acceptedRoles: string[]; + acceptedStatuses: string[]; + location?: req; +}) => { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const bot = await Bot.findOne({ _id: req[location].botId }); + + if (!bot) { + throw new Error('Failed to find bot'); + } + + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: bot.workspace.toString(), + acceptedRoles, + acceptedStatuses + }); + + req.bot = bot; + + next(); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(401).send({ + error: 'Failed bot authorization' + }); + } + } +} + +export default requireBotAuth; \ No newline at end of file diff --git a/backend/src/middleware/requireIntegrationAuth.ts b/backend/src/middleware/requireIntegrationAuth.ts index 70ca320c3..fe653dbc0 100644 --- a/backend/src/middleware/requireIntegrationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuth.ts @@ -1,7 +1,8 @@ import * as Sentry from '@sentry/node'; import { Request, Response, NextFunction } from 'express'; -import { Integration, IntegrationAuth, Membership } from '../models'; -import { getOAuthAccessToken } from '../helpers/integrationAuth'; +import { Bot, Integration, IntegrationAuth, Membership } from '../models'; +import { IntegrationService } from '../services'; +import { validateMembership } from '../helpers/membership'; /** * Validate if user on request is a member of workspace with proper roles associated @@ -31,24 +32,14 @@ const requireIntegrationAuth = ({ if (!integration) { throw new Error('Failed to find integration'); } - - const membership = await Membership.findOne({ - user: req.user._id, - workspace: integration.workspace + + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: integration.workspace.toString(), + acceptedRoles, + acceptedStatuses }); - if (!membership) { - throw new Error('Failed to find integration workspace membership'); - } - - if (!acceptedRoles.includes(membership.role)) { - throw new Error('Failed to validate workspace membership role'); - } - - if (!acceptedStatuses.includes(membership.status)) { - throw new Error('Failed to validate workspace membership status'); - } - const integrationAuth = await IntegrationAuth.findOne({ _id: integration.integrationAuth }).select( @@ -60,7 +51,9 @@ const requireIntegrationAuth = ({ } req.integration = integration; - req.accessToken = await getOAuthAccessToken({ integrationAuth }); + req.accessToken = await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString() + }); return next(); } catch (err) { diff --git a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts index 1f5c6dfc8..ed44ffec5 100644 --- a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts @@ -1,8 +1,8 @@ import * as Sentry from '@sentry/node'; import { Request, Response, NextFunction } from 'express'; -import { IntegrationAuth, Membership } from '../models'; -import { decryptSymmetric } from '../utils/crypto'; -import { getOAuthAccessToken } from '../helpers/integrationAuth'; +import { IntegrationAuth } from '../models'; +import { IntegrationService } from '../services'; +import { validateMembership } from '../helpers/membership'; /** * Validate if user on request is a member of workspace with proper roles associated @@ -10,18 +10,18 @@ import { getOAuthAccessToken } from '../helpers/integrationAuth'; * @param {Object} obj * @param {String[]} obj.acceptedRoles - accepted workspace roles * @param {String[]} obj.acceptedStatuses - accepted workspace statuses - * @param {Boolean} obj.attachRefresh - whether or not to decrypt and attach integration authorization refresh token onto request + * @param {Boolean} obj.attachAccessToken - whether or not to decrypt and attach integration authorization access token onto request */ const requireIntegrationAuthorizationAuth = ({ acceptedRoles, - acceptedStatuses + acceptedStatuses, + attachAccessToken = true }: { acceptedRoles: string[]; acceptedStatuses: string[]; + attachAccessToken?: boolean; }) => { return async (req: Request, res: Response, next: NextFunction) => { - // (authorization) integration authorization middleware - try { const { integrationAuthId } = req.params; @@ -34,30 +34,21 @@ const requireIntegrationAuthorizationAuth = ({ if (!integrationAuth) { throw new Error('Failed to find integration authorization'); } - - const membership = await Membership.findOne({ - user: req.user._id, - workspace: integrationAuth.workspace + + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: integrationAuth.workspace.toString(), + acceptedRoles, + acceptedStatuses }); - if (!membership) { - throw new Error( - 'Failed to find integration authorization workspace membership' - ); - } - - if (!acceptedRoles.includes(membership.role)) { - throw new Error('Failed to validate workspace membership role'); - } - - if (!acceptedStatuses.includes(membership.status)) { - throw new Error('Failed to validate workspace membership status'); - } - req.integrationAuth = integrationAuth; - - // TODO: make compatible with other integration types since they won't necessarily have access tokens - req.accessToken = await getOAuthAccessToken({ integrationAuth }); + if (attachAccessToken) { + req.accessToken = await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString() + }); + } + return next(); } catch (err) { Sentry.setUser(null); diff --git a/backend/src/middleware/requireWorkspaceAuth.ts b/backend/src/middleware/requireWorkspaceAuth.ts index 03fb7357f..a27611f2d 100644 --- a/backend/src/middleware/requireWorkspaceAuth.ts +++ b/backend/src/middleware/requireWorkspaceAuth.ts @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/node'; import { Request, Response, NextFunction } from 'express'; -import { Membership, IWorkspace } from '../models'; +import { validateMembership } from '../helpers/membership'; type req = 'params' | 'body' | 'query'; @@ -25,24 +25,12 @@ const requireWorkspaceAuth = ({ // workspace authorization middleware try { - // validate workspace membership - - const membership = await Membership.findOne({ - user: req.user._id, - workspace: req[location].workspaceId - }).populate<{ workspace: IWorkspace }>('workspace'); - - if (!membership) { - throw new Error('Failed to find workspace membership'); - } - - if (!acceptedRoles.includes(membership.role)) { - throw new Error('Failed to validate workspace membership role'); - } - - if (!acceptedStatuses.includes(membership.status)) { - throw new Error('Failed to validate workspace membership status'); - } + const membership = await validateMembership({ + userId: req.user._id.toString(), + workspaceId: req[location].workspaceId, + acceptedRoles, + acceptedStatuses + }); req.membership = membership; diff --git a/backend/src/models/bot.ts b/backend/src/models/bot.ts new file mode 100644 index 000000000..c7e5a9abe --- /dev/null +++ b/backend/src/models/bot.ts @@ -0,0 +1,57 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface IBot { + _id: Types.ObjectId; + name: string; + workspace: Types.ObjectId; + isActive: boolean; + publicKey: string; + encryptedPrivateKey: string; + iv: string; + tag: string; +} + +const botSchema = new Schema( + { + name: { + type: String, + required: true + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true + }, + isActive: { + type: Boolean, + required: true, + default: false + }, + publicKey: { + type: String, + required: true + }, + encryptedPrivateKey: { + type: String, + required: true, + select: false + }, + iv: { + type: String, + required: true, + select: false + }, + tag: { + type: String, + required: true, + select: false + } + }, + { + timestamps: true + } +); + +const Bot = model('Bot', botSchema); + +export default Bot; diff --git a/backend/src/models/botKey.ts b/backend/src/models/botKey.ts new file mode 100644 index 000000000..79555cd53 --- /dev/null +++ b/backend/src/models/botKey.ts @@ -0,0 +1,45 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface IBotKey { + _id: Types.ObjectId; + encryptedKey: string; + nonce: string; + sender: Types.ObjectId; + bot: Types.ObjectId; + workspace: Types.ObjectId; +} + +const botKeySchema = new Schema( + { + encryptedKey: { + type: String, + required: true + }, + nonce: { + type: String, + required: true + }, + sender: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true + }, + bot: { + type: Schema.Types.ObjectId, + ref: 'Bot', + required: true + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true + } + }, + { + timestamps: true + } +); + +const BotKey = model('BotKey', botKeySchema); + +export default BotKey; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 53f5a395a..8d125499c 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -1,4 +1,6 @@ import BackupPrivateKey, { IBackupPrivateKey } from './backupPrivateKey'; +import Bot, { IBot } from './bot'; +import BotKey, { IBotKey } from './botKey'; import IncidentContactOrg, { IIncidentContactOrg } from './incidentContactOrg'; import Integration, { IIntegration } from './integration'; import IntegrationAuth, { IIntegrationAuth } from './integrationAuth'; @@ -17,6 +19,10 @@ import Log, { ILog } from './log'; export { BackupPrivateKey, IBackupPrivateKey, + Bot, + IBot, + BotKey, + IBotKey, IncidentContactOrg, IIncidentContactOrg, Integration, diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 5e72e8b54..edbe0234e 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -5,6 +5,7 @@ import { ENV_STAGING, ENV_PROD, INTEGRATION_HEROKU, + INTEGRATION_VERCEL, INTEGRATION_NETLIFY } from '../variables'; @@ -14,7 +15,10 @@ export interface IIntegration { environment: 'dev' | 'test' | 'staging' | 'prod'; isActive: boolean; app: string; - integration: 'heroku' | 'netlify'; + target: string; + context: string; + siteId: string; + integration: 'heroku' | 'vercel' | 'netlify'; integrationAuth: Types.ObjectId; } @@ -34,15 +38,29 @@ const integrationSchema = new Schema( type: Boolean, required: true }, - app: { - // name of app in provider + app: { // name of app in provider type: String, - default: null, - required: true + default: null + }, + target: { // vercel-specific target (environment) + type: String, + default: null + }, + context: { // netlify-specific context (deploy) + type: String, + default: null + }, + siteId: { // netlify-specific site (app) id + type: String, + default: null }, integration: { type: String, - enum: [INTEGRATION_HEROKU, INTEGRATION_NETLIFY], + enum: [ + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY + ], required: true }, integrationAuth: { diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 0e9542a20..0da3eb0d8 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -1,10 +1,16 @@ import { Schema, model, Types } from 'mongoose'; -import { INTEGRATION_HEROKU, INTEGRATION_NETLIFY } from '../variables'; +import { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY +} from '../variables'; export interface IIntegrationAuth { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: 'heroku' | 'netlify'; + integration: 'heroku' | 'vercel' | 'netlify'; + teamId: string; + accountId: string; refreshCiphertext?: string; refreshIV?: string; refreshTag?: string; @@ -22,9 +28,19 @@ const integrationAuthSchema = new Schema( }, integration: { type: String, - enum: [INTEGRATION_HEROKU, INTEGRATION_NETLIFY], + enum: [ + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY + ], required: true }, + teamId: { // vercel-specific integration param + type: String + }, + accountId: { // netlify-specific integration param + type: String + }, refreshCiphertext: { type: String, select: false diff --git a/backend/src/models/log.ts b/backend/src/models/log.ts index e3716ab84..81dd9fb52 100644 --- a/backend/src/models/log.ts +++ b/backend/src/models/log.ts @@ -5,17 +5,14 @@ export interface ILog { user?: Types.ObjectId; workspace: Types.ObjectId; event: string; - source: string; + payload: { + numberofSecrets?: number; + environment?: string; + }, + channel: string; ipAddress?: string; } -// TODO: need a way to store payload info for each -// log - -// which secret is being ref etc. - -// user logged in - const logSchema = new Schema( { user: { @@ -30,7 +27,7 @@ const logSchema = new Schema( type: String, required: true }, - payload: { // should this just be a payload attr? + payload: { numberOfSecrets: { type: Number }, diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index aa1c5d239..3b0aa1862 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -11,7 +11,7 @@ export interface IUser { tag?: string; salt?: string; verifier?: string; - refreshVersion?: Number; + refreshVersion?: number; } const userSchema = new Schema( diff --git a/backend/src/routes/bot.ts b/backend/src/routes/bot.ts new file mode 100644 index 000000000..3189bec44 --- /dev/null +++ b/backend/src/routes/bot.ts @@ -0,0 +1,38 @@ +import express from 'express'; +const router = express.Router(); +import { body, param } from 'express-validator'; +import { + requireAuth, + requireBotAuth, + requireWorkspaceAuth, + validateRequest +} from '../middleware'; +import { botController } from '../controllers'; +import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../variables'; + +router.get( + '/:workspaceId', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED] + }), + param('workspaceId').exists().trim().notEmpty(), + validateRequest, + botController.getBotByWorkspaceId +); + +router.patch( + '/:botId/active', + requireAuth, + requireBotAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED] + }), + body('isActive').isBoolean(), + body('botKey'), + validateRequest, + botController.setBotActiveState +); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 97dc72c83..597aa2b34 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -1,4 +1,5 @@ import signup from './signup'; +import bot from './bot'; import auth from './auth'; import user from './user'; import userAction from './userAction'; @@ -19,6 +20,7 @@ import log from './log'; export { signup, auth, + bot, user, userAction, organization, diff --git a/backend/src/routes/integration.ts b/backend/src/routes/integration.ts index d16154172..e6738a803 100644 --- a/backend/src/routes/integration.ts +++ b/backend/src/routes/integration.ts @@ -9,22 +9,6 @@ import { ADMIN, MEMBER, GRANTED } from '../variables'; import { body, param } from 'express-validator'; import { integrationController } from '../controllers'; -router.get('/integrations', requireAuth, integrationController.getIntegrations); - -router.post( - '/:integrationId/sync', - requireAuth, - requireIntegrationAuth({ - acceptedRoles: [ADMIN, MEMBER], - acceptedStatuses: [GRANTED] - }), - param('integrationId').exists().trim(), - body('key').exists(), - body('secrets').exists(), - validateRequest, - integrationController.syncIntegration -); - router.patch( '/:integrationId', requireAuth, @@ -32,10 +16,15 @@ router.patch( acceptedRoles: [ADMIN, MEMBER], acceptedStatuses: [GRANTED] }), - param('integrationId'), - body('update'), + param('integrationId').exists().trim(), + body('app').exists().trim(), + body('environment').exists().trim(), + body('isActive').exists().isBoolean(), + body('target').exists(), + body('context').exists(), + body('siteId').exists(), validateRequest, - integrationController.modifyIntegration + integrationController.updateIntegration ); router.delete( @@ -45,7 +34,7 @@ router.delete( acceptedRoles: [ADMIN, MEMBER], acceptedStatuses: [GRANTED] }), - param('integrationId'), + param('integrationId').exists().trim(), validateRequest, integrationController.deleteIntegration ); diff --git a/backend/src/routes/integrationAuth.ts b/backend/src/routes/integrationAuth.ts index 61e5f56bf..ef80a2dcc 100644 --- a/backend/src/routes/integrationAuth.ts +++ b/backend/src/routes/integrationAuth.ts @@ -10,6 +10,12 @@ import { import { ADMIN, MEMBER, GRANTED } from '../variables'; import { integrationAuthController } from '../controllers'; +router.get( + '/integration-options', + requireAuth, + integrationAuthController.getIntegrationOptions +); + router.post( '/oauth-token', requireAuth, @@ -22,7 +28,7 @@ router.post( body('code').exists().trim().notEmpty(), body('integration').exists().trim().notEmpty(), validateRequest, - integrationAuthController.integrationAuthOauthExchange + integrationAuthController.oAuthExchange ); router.get( @@ -42,7 +48,8 @@ router.delete( requireAuth, requireIntegrationAuthorizationAuth({ acceptedRoles: [ADMIN, MEMBER], - acceptedStatuses: [GRANTED] + acceptedStatuses: [GRANTED], + attachAccessToken: false }), param('integrationAuthId'), validateRequest, diff --git a/backend/src/routes/password.ts b/backend/src/routes/password.ts index 955e532a0..8032cba83 100644 --- a/backend/src/routes/password.ts +++ b/backend/src/routes/password.ts @@ -27,7 +27,6 @@ router.post( passwordController.changePassword ); -// NEW router.post( '/email/password-reset', passwordLimiter, @@ -36,7 +35,6 @@ router.post( passwordController.emailPasswordReset ); -// NEW router.post( '/email/password-reset-verify', passwordLimiter, @@ -46,7 +44,6 @@ router.post( passwordController.emailPasswordResetVerify ); -// NEW router.get( '/backup-private-key', passwordLimiter, @@ -68,7 +65,6 @@ router.post( passwordController.createBackupPrivateKey ); -// NEW router.post( '/password-reset', requireSignupAuth, diff --git a/backend/src/services/BotService.ts b/backend/src/services/BotService.ts new file mode 100644 index 000000000..792bd8e35 --- /dev/null +++ b/backend/src/services/BotService.ts @@ -0,0 +1,82 @@ +import { + getSecretsHelper, + encryptSymmetricHelper, + decryptSymmetricHelper +} from '../helpers/bot'; + +/** + * Class to handle bot actions + */ +class BotService { + + /** + * Return decrypted secrets for workspace with id [workspaceId] and + * environment [environmen] shared to bot. + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace of secrets + * @param {String} obj.environment - environment for secrets + * @returns {Object} secretObj - object where keys are secret keys and values are secret values + */ + static async getSecrets({ + workspaceId, + environment + }: { + workspaceId: string; + environment: string; + }) { + return await getSecretsHelper({ + workspaceId, + environment + }); + } + + /** + * Return symmetrically encrypted [plaintext] using the + * bot's copy of the workspace key for workspace with id [workspaceId] + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.plaintext - plaintext to encrypt + */ + static async encryptSymmetric({ + workspaceId, + plaintext + }: { + workspaceId: string; + plaintext: string; + }) { + return await encryptSymmetricHelper({ + workspaceId, + plaintext + }); + } + + /** + * Return symmetrically decrypted [ciphertext] using the + * bot's copy of the workspace key for workspace with id [workspaceId] + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.ciphertext - ciphertext to decrypt + * @param {String} obj.iv - iv + * @param {String} obj.tag - tag + */ + static async decryptSymmetric({ + workspaceId, + ciphertext, + iv, + tag + }: { + workspaceId: string; + ciphertext: string; + iv: string; + tag: string; + }) { + return await decryptSymmetricHelper({ + workspaceId, + ciphertext, + iv, + tag + }); + } +} + +export default BotService; \ No newline at end of file diff --git a/backend/src/services/EventService.ts b/backend/src/services/EventService.ts new file mode 100644 index 000000000..fcbac9ad0 --- /dev/null +++ b/backend/src/services/EventService.ts @@ -0,0 +1,30 @@ +import { Bot, IBot } from '../models'; +import * as Sentry from '@sentry/node'; +import { handleEventHelper } from '../helpers/event'; + +interface Event { + name: string; + workspaceId: string; + payload: any; +} + +/** + * Class to handle events. + */ +class EventService { + /** + * Handle event [event] + * @param {Object} obj + * @param {Event} obj.event - an event + * @param {String} obj.event.name - name of event + * @param {String} obj.event.workspaceId - id of workspace that event is part of + * @param {Object} obj.event.payload - payload of event (depends on event) + */ + static async handleEvent({ event }: { event: Event }): Promise { + await handleEventHelper({ + event + }); + } +} + +export default EventService; \ No newline at end of file diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts new file mode 100644 index 000000000..32f5f5a88 --- /dev/null +++ b/backend/src/services/IntegrationService.ts @@ -0,0 +1,145 @@ +import * as Sentry from '@sentry/node'; +import { + Integration +} from '../models'; +import { + handleOAuthExchangeHelper, + syncIntegrationsHelper, + getIntegrationAuthRefreshHelper, + getIntegrationAuthAccessHelper, + setIntegrationAuthRefreshHelper, + setIntegrationAuthAccessHelper, +} from '../helpers/integration'; +import { exchangeCode } from '../integrations'; +import { + ENV_DEV, + EVENT_PUSH_SECRETS +} from '../variables'; + +// should sync stuff be here too? Probably. +// TODO: move bot functions to IntegrationService. + +/** + * Class to handle integrations + */ +class IntegrationService { + + /** + * Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration + * named [integration] + * - Store integration access and refresh tokens returned from the OAuth2 code-token exchange + * - Add placeholder inactive integration + * - Create bot sequence for integration + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.integration - name of integration + * @param {String} obj.code - code + */ + static async handleOAuthExchange({ + workspaceId, + integration, + code + }: { + workspaceId: string; + integration: string; + code: string; + }) { + await handleOAuthExchangeHelper({ + workspaceId, + integration, + code + }); + } + + /** + * Sync/push environment variables in workspace with id [workspaceId] to + * all associated integrations + * @param {Object} obj + * @param {Object} obj.workspaceId - id of workspace + */ + static async syncIntegrations({ + workspaceId + }: { + workspaceId: string; + }) { + return await syncIntegrationsHelper({ + workspaceId + }); + } + + /** + * Return decrypted refresh token for integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} refreshToken - decrypted refresh token + */ + static async getIntegrationAuthRefresh({ integrationAuthId }: { integrationAuthId: string}) { + return await getIntegrationAuthRefreshHelper({ + integrationAuthId + }); + } + + /** + * Return decrypted access token for integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} accessToken - decrypted access token + */ + static async getIntegrationAuthAccess({ integrationAuthId }: { integrationAuthId: string}) { + return await getIntegrationAuthAccessHelper({ + integrationAuthId + }); + } + + /** + * Encrypt refresh token [refreshToken] using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} obj.refreshToken - refresh token + * @returns {IntegrationAuth} integrationAuth - updated integration auth + */ + static async setIntegrationAuthRefresh({ + integrationAuthId, + refreshToken + }: { + integrationAuthId: string; + refreshToken: string; + }) { + return await setIntegrationAuthRefreshHelper({ + integrationAuthId, + refreshToken + }); + } + + /** + * Encrypt access token [accessToken] using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} obj.accessToken - access token + * @param {String} obj.accessExpiresAt - expiration date of access token + * @returns {IntegrationAuth} - updated integration auth + */ + static async setIntegrationAuthAccess({ + integrationAuthId, + accessToken, + accessExpiresAt + }: { + integrationAuthId: string; + accessToken: string; + accessExpiresAt: Date; + }) { + return await setIntegrationAuthAccessHelper({ + integrationAuthId, + accessToken, + accessExpiresAt + }); + } +} + +export default IntegrationService; \ No newline at end of file diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index 54cdf94f4..531033f30 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -1,5 +1,11 @@ import postHogClient from './PostHogClient'; +import BotService from './BotService'; +import EventService from './EventService'; +import IntegrationService from './IntegrationService'; export { - postHogClient + postHogClient, + BotService, + EventService, + IntegrationService } \ No newline at end of file diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts index 8fdb2b3fe..9a9e81449 100644 --- a/backend/src/types/express/index.d.ts +++ b/backend/src/types/express/index.d.ts @@ -11,6 +11,7 @@ declare global { membershipOrg: any; integration: any; integrationAuth: any; + bot: any; serviceToken: any; accessToken: any; query?: any; diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts index 742e65e80..28f96b0cf 100644 --- a/backend/src/utils/crypto.ts +++ b/backend/src/utils/crypto.ts @@ -1,6 +1,22 @@ import nacl from 'tweetnacl'; import util from 'tweetnacl-util'; import AesGCM from './aes-gcm'; +import * as Sentry from '@sentry/node'; + +/** + * Return new base64, NaCl, public-private key pair. + * @returns {Object} obj + * @returns {String} obj.publicKey - base64, NaCl, public key + * @returns {String} obj.privateKey - base64, NaCl, private key + */ +const generateKeyPair = () => { + const pair = nacl.box.keyPair(); + + return ({ + publicKey: util.encodeBase64(pair.publicKey), + privateKey: util.encodeBase64(pair.secretKey) + }); +} /** * Return assymmetrically encrypted [plaintext] using [publicKey] where @@ -32,6 +48,8 @@ const encryptAsymmetric = ({ util.decodeBase64(privateKey) ); } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); throw new Error('Failed to perform asymmetric encryption'); } @@ -71,6 +89,8 @@ const decryptAsymmetric = ({ util.decodeBase64(privateKey) ); } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); throw new Error('Failed to perform asymmetric decryption'); } @@ -81,7 +101,7 @@ const decryptAsymmetric = ({ * Return symmetrically encrypted [plaintext] using [key]. * @param {Object} obj * @param {String} obj.plaintext - plaintext to encrypt - * @param {String} obj.key - 16-byte hex key + * @param {String} obj.key - hex key */ const encryptSymmetric = ({ plaintext, @@ -97,6 +117,8 @@ const encryptSymmetric = ({ iv = obj.iv; tag = obj.tag; } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); throw new Error('Failed to perform symmetric encryption'); } @@ -114,7 +136,7 @@ const encryptSymmetric = ({ * @param {String} obj.ciphertext - ciphertext to decrypt * @param {String} obj.iv - iv * @param {String} obj.tag - tag - * @param {String} obj.key - 32-byte hex key + * @param {String} obj.key - hex key * */ const decryptSymmetric = ({ @@ -132,6 +154,8 @@ const decryptSymmetric = ({ try { plaintext = AesGCM.decrypt(ciphertext, iv, tag, key); } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); throw new Error('Failed to perform symmetric decryption'); } @@ -139,6 +163,7 @@ const decryptSymmetric = ({ }; export { + generateKeyPair, encryptAsymmetric, decryptAsymmetric, encryptSymmetric, diff --git a/backend/src/variables.ts b/backend/src/variables.ts deleted file mode 100644 index cdd771b71..000000000 --- a/backend/src/variables.ts +++ /dev/null @@ -1,60 +0,0 @@ -// membership roles -const OWNER = 'owner'; -const ADMIN = 'admin'; -const MEMBER = 'member'; - -// membership statuses -const INVITED = 'invited'; - -// -- organization -const ACCEPTED = 'accepted'; - -// -- workspace -const COMPLETED = 'completed'; -const GRANTED = 'granted'; - -// subscriptions -const PLAN_STARTER = 'starter'; -const PLAN_PRO = 'pro'; - -// secrets -const SECRET_SHARED = 'shared'; -const SECRET_PERSONAL = 'personal'; - -// environments -const ENV_DEV = 'dev'; -const ENV_TESTING = 'test'; -const ENV_STAGING = 'staging'; -const ENV_PROD = 'prod'; -const ENV_SET = new Set([ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD]); - -// integrations -const INTEGRATION_HEROKU = 'heroku'; -const INTEGRATION_NETLIFY = 'netlify'; -const INTEGRATION_SET = new Set([INTEGRATION_HEROKU, INTEGRATION_NETLIFY]); - -// integration types -const INTEGRATION_OAUTH2 = 'oauth2'; - -export { - OWNER, - ADMIN, - MEMBER, - INVITED, - ACCEPTED, - COMPLETED, - GRANTED, - PLAN_STARTER, - PLAN_PRO, - SECRET_SHARED, - SECRET_PERSONAL, - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD, - ENV_SET, - INTEGRATION_HEROKU, - INTEGRATION_NETLIFY, - INTEGRATION_SET, - INTEGRATION_OAUTH2 -}; diff --git a/backend/src/variables/action.ts b/backend/src/variables/action.ts new file mode 100644 index 000000000..1f913bbe9 --- /dev/null +++ b/backend/src/variables/action.ts @@ -0,0 +1,5 @@ +const ACTION_PUSH_TO_HEROKU = 'pushToHeroku'; + +export { + ACTION_PUSH_TO_HEROKU +} \ No newline at end of file diff --git a/backend/src/variables/environment.ts b/backend/src/variables/environment.ts new file mode 100644 index 000000000..44d7cdbb2 --- /dev/null +++ b/backend/src/variables/environment.ts @@ -0,0 +1,14 @@ +// environments +const ENV_DEV = 'dev'; +const ENV_TESTING = 'test'; +const ENV_STAGING = 'staging'; +const ENV_PROD = 'prod'; +const ENV_SET = new Set([ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD]); + +export { + ENV_DEV, + ENV_TESTING, + ENV_STAGING, + ENV_PROD, + ENV_SET +} \ No newline at end of file diff --git a/backend/src/variables/event.ts b/backend/src/variables/event.ts new file mode 100644 index 000000000..4477e8e02 --- /dev/null +++ b/backend/src/variables/event.ts @@ -0,0 +1,7 @@ +const EVENT_PUSH_SECRETS = 'pushSecrets'; +const EVENT_PULL_SECRETS = 'pullSecrets'; + +export { + EVENT_PUSH_SECRETS, + EVENT_PULL_SECRETS +} \ No newline at end of file diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts new file mode 100644 index 000000000..b21324423 --- /dev/null +++ b/backend/src/variables/index.ts @@ -0,0 +1,79 @@ +import { + ENV_DEV, + ENV_TESTING, + ENV_STAGING, + ENV_PROD, + ENV_SET +} from './environment'; +import { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_SET, + INTEGRATION_OAUTH2, + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL, + INTEGRATION_OPTIONS +} from './integration'; +import { + OWNER, + ADMIN, + MEMBER, + INVITED, + ACCEPTED, + COMPLETED, + GRANTED +} from './organization'; +import { + SECRET_SHARED, + SECRET_PERSONAL +} from './secret'; +import { + PLAN_STARTER, + PLAN_PRO +} from './stripe'; +import { + EVENT_PUSH_SECRETS, + EVENT_PULL_SECRETS +} from './event'; +import { + ACTION_PUSH_TO_HEROKU +} from './action'; + +export { + OWNER, + ADMIN, + MEMBER, + INVITED, + ACCEPTED, + COMPLETED, + GRANTED, + PLAN_STARTER, + PLAN_PRO, + SECRET_SHARED, + SECRET_PERSONAL, + ENV_DEV, + ENV_TESTING, + ENV_STAGING, + ENV_PROD, + ENV_SET, + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_SET, + INTEGRATION_OAUTH2, + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL, + EVENT_PUSH_SECRETS, + EVENT_PULL_SECRETS, + ACTION_PUSH_TO_HEROKU, + INTEGRATION_OPTIONS +}; \ No newline at end of file diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts new file mode 100644 index 000000000..55ae271a1 --- /dev/null +++ b/backend/src/variables/integration.ts @@ -0,0 +1,119 @@ +import { + CLIENT_ID_HEROKU, + CLIENT_ID_NETLIFY, + CLIENT_SLUG_VERCEL +} from '../config'; + +// integrations +const INTEGRATION_HEROKU = 'heroku'; +const INTEGRATION_VERCEL = 'vercel'; +const INTEGRATION_NETLIFY = 'netlify'; +const INTEGRATION_SET = new Set([ + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY +]); + +// integration types +const INTEGRATION_OAUTH2 = 'oauth2'; + +// integration oauth endpoints +const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; +const INTEGRATION_VERCEL_TOKEN_URL = 'https://api.vercel.com/v2/oauth/access_token'; +const INTEGRATION_NETLIFY_TOKEN_URL = 'https://api.netlify.com/oauth/token'; + +// integration apps endpoints +const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com'; +const INTEGRATION_VERCEL_API_URL = 'https://api.vercel.com'; +const INTEGRATION_NETLIFY_API_URL = 'https://api.netlify.com'; + +const INTEGRATION_OPTIONS = [ + { + name: 'Heroku', + slug: 'heroku', + image: 'Heroku', + isAvailable: true, + type: 'oauth2', + clientId: CLIENT_ID_HEROKU, + docsLink: '' + }, + { + name: 'Vercel', + slug: 'vercel', + image: 'Vercel', + isAvailable: true, + type: 'vercel', + clientId: '', + clientSlug: CLIENT_SLUG_VERCEL, + docsLink: '' + }, + { + name: 'Netlify', + slug: 'netlify', + image: 'Netlify', + isAvailable: true, + type: 'oauth2', + clientId: CLIENT_ID_NETLIFY, + docsLink: '' + }, + { + name: 'Google Cloud Platform', + slug: 'gcp', + image: 'Google Cloud Platform', + isAvailable: false, + type: '', + clientId: '', + docsLink: '' + }, + { + name: 'Amazon Web Services', + slug: 'aws', + image: 'Amazon Web Services', + isAvailable: false, + type: '', + clientId: '', + docsLink: '' + }, + { + name: 'Microsoft Azure', + slug: 'azure', + image: 'Microsoft Azure', + isAvailable: false, + type: '', + clientId: '', + docsLink: '' + }, + { + name: 'Travis CI', + slug: 'travisci', + image: 'Travis CI', + isAvailable: false, + type: '', + clientId: '', + docsLink: '' + }, + { + name: 'Circle CI', + slug: 'circleci', + image: 'Circle CI', + isAvailable: false, + type: '', + clientId: '', + docsLink: '' + } +] + +export { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_SET, + INTEGRATION_OAUTH2, + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL, + INTEGRATION_OPTIONS +} \ No newline at end of file diff --git a/backend/src/variables/organization.ts b/backend/src/variables/organization.ts new file mode 100644 index 000000000..f91e1f5d3 --- /dev/null +++ b/backend/src/variables/organization.ts @@ -0,0 +1,24 @@ +// membership roles +const OWNER = 'owner'; +const ADMIN = 'admin'; +const MEMBER = 'member'; + +// membership statuses +const INVITED = 'invited'; + +// -- organization +const ACCEPTED = 'accepted'; + +// -- workspace +const COMPLETED = 'completed'; +const GRANTED = 'granted'; + +export { + OWNER, + ADMIN, + MEMBER, + INVITED, + ACCEPTED, + COMPLETED, + GRANTED +} \ No newline at end of file diff --git a/backend/src/variables/secret.ts b/backend/src/variables/secret.ts new file mode 100644 index 000000000..31cbcf951 --- /dev/null +++ b/backend/src/variables/secret.ts @@ -0,0 +1,8 @@ +// secrets +const SECRET_SHARED = 'shared'; +const SECRET_PERSONAL = 'personal'; + +export { + SECRET_SHARED, + SECRET_PERSONAL +} \ No newline at end of file diff --git a/backend/src/variables/stripe.ts b/backend/src/variables/stripe.ts new file mode 100644 index 000000000..ecdbd98ae --- /dev/null +++ b/backend/src/variables/stripe.ts @@ -0,0 +1,7 @@ +const PLAN_STARTER = 'starter'; +const PLAN_PRO = 'pro'; + +export { + PLAN_STARTER, + PLAN_PRO +} \ No newline at end of file diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index ec5210b1f..7518fe98d 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "os/signal" + "runtime" "strings" "syscall" @@ -19,12 +20,38 @@ import ( // runCmd represents the run command var runCmd = &cobra.Command{ + Example: ` + infisical run --env=dev -- npm run dev + infisical run --command "first-command && second-command; more-commands..." + `, Use: "run [any infisical run command flags] -- [your application start command]", Short: "Used to inject environments variables into your application process", DisableFlagsInUseLine: true, - Example: "infisical run --env=prod -- npm run dev", - Args: cobra.MinimumNArgs(1), PreRun: toggleDebug, + Args: func(cmd *cobra.Command, args []string) error { + // Check if the --command flag has been set + commandFlagSet := cmd.Flags().Changed("command") + + // If the --command flag has been set, check if a value was provided + if commandFlagSet { + command := cmd.Flag("command").Value.String() + if command == "" { + return fmt.Errorf("you need to provide a command after the flag --command") + } + + // If the --command flag has been set, args should not be provided + if len(args) > 0 { + return fmt.Errorf("you cannot set any arguments after --command flag. --command only takes a string command") + } + } else { + // If the --command flag has not been set, at least one arg should be provided + if len(args) == 0 { + return fmt.Errorf("at least one argument is required after the run command, received %d", len(args)) + } + } + + return nil + }, Run: func(cmd *cobra.Command, args []string) { envName, err := cmd.Flags().GetString("env") if err != nil { @@ -54,10 +81,23 @@ var runCmd = &cobra.Command{ } if shouldExpandSecrets { - secretsWithSubstitutions := util.SubstituteSecrets(secrets) - execCmd(args[0], args[1:], secretsWithSubstitutions) + secrets = util.SubstituteSecrets(secrets) + } + + if cmd.Flags().Changed("command") { + command := cmd.Flag("command").Value.String() + err = executeMultipleCommandWithEnvs(command, secrets) + if err != nil { + log.Errorf("Something went wrong when executing your command [error=%s]", err) + return + } } else { - execCmd(args[0], args[1:], secrets) + err = executeSingleCommandWithEnvs(args, secrets) + if err != nil { + log.Errorf("Something went wrong when executing your command [error=%s]", err) + return + } + return } }, @@ -68,22 +108,51 @@ func init() { runCmd.Flags().StringP("env", "e", "dev", "Set the environment (dev, prod, etc.) from which your secrets should be pulled from") runCmd.Flags().String("projectId", "", "The project ID from which your secrets should be pulled from") runCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") + runCmd.Flags().StringP("command", "c", "", "chained commands to execute (e.g. \"npm install && npm run dev; echo ...\")") } -// Credit: inspired by AWS Valut -func execCmd(command string, args []string, envs []models.SingleEnvironmentVariable) error { - numberOfSecretsInjected := fmt.Sprintf("\u2713 Injected %v Infisical secrets into your application process successfully", len(envs)) - +// Will execute a single command and pass in the given secrets into the process +func executeSingleCommandWithEnvs(args []string, secrets []models.SingleEnvironmentVariable) error { + command := args[0] + argsForCommand := args[1:] + numberOfSecretsInjected := fmt.Sprintf("\u2713 Injected %v Infisical secrets into your application process successfully", len(secrets)) log.Infof("\x1b[%dm%s\x1b[0m", 32, numberOfSecretsInjected) - log.Debugf("executing command: %s %s \n", command, strings.Join(args, " ")) - log.Debugln("Secrets injected:", envs) + log.Debugf("executing command: %s %s \n", command, strings.Join(argsForCommand, " ")) + log.Debugln("Secrets injected:", secrets) - cmd := exec.Command(command, args...) + cmd := exec.Command(command, argsForCommand...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - cmd.Env = getAllEnvs(envs) + cmd.Env = getAllEnvs(secrets) + return execCmd(cmd) +} + +func executeMultipleCommandWithEnvs(fullCommand string, secrets []models.SingleEnvironmentVariable) error { + shell := [2]string{"sh", "-c"} + if runtime.GOOS == "windows" { + shell = [2]string{"cmd", "/C"} + } else { + shell[0] = os.Getenv("SHELL") + } + + cmd := exec.Command(shell[0], shell[1], fullCommand) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = getAllEnvs(secrets) + + numberOfSecretsInjected := fmt.Sprintf("\u2713 Injected %v Infisical secrets into your application process successfully", len(secrets)) + log.Infof("\x1b[%dm%s\x1b[0m", 32, numberOfSecretsInjected) + log.Debugf("executing command: %s %s %s \n", shell[0], shell[1], fullCommand) + log.Debugln("Secrets injected:", secrets) + + return execCmd(cmd) +} + +// Credit: inspired by AWS Valut +func execCmd(cmd *exec.Cmd) error { sigChannel := make(chan os.Signal, 1) signal.Notify(sigChannel) @@ -100,7 +169,7 @@ func execCmd(command string, args []string, envs []models.SingleEnvironmentVaria if err := cmd.Wait(); err != nil { _ = cmd.Process.Signal(os.Kill) - return fmt.Errorf("Failed to wait for command termination: %v", err) + return fmt.Errorf("failed to wait for command termination: %v", err) } waitStatus := cmd.ProcessState.Sys().(syscall.WaitStatus) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 623462d5b..15a200783 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -51,6 +51,7 @@ services: env_file: .env environment: - NEXT_PUBLIC_ENV=development + - INFISICAL_TELEMETRY_ENABLED=${TELEMETRY_ENABLED} - NEXT_PUBLIC_STRIPE_PRODUCT_PRO=${STRIPE_PRODUCT_PRO} - NEXT_PUBLIC_STRIPE_PRODUCT_STARTER=${STRIPE_PRODUCT_STARTER} networks: diff --git a/docs/cli/commands/export.mdx b/docs/cli/commands/export.mdx index fd58868ff..b80fb7470 100644 --- a/docs/cli/commands/export.mdx +++ b/docs/cli/commands/export.mdx @@ -30,4 +30,7 @@ infisical export --format=csv > secrets.csv # Export variables to a JSON file infisical export --format=json > secrets.json + +# Export variables to a YAML file +infisical export --format=yaml > secrets.yaml ``` diff --git a/docs/cli/commands/run.mdx b/docs/cli/commands/run.mdx index 7fb207612..2c65ef53e 100644 --- a/docs/cli/commands/run.mdx +++ b/docs/cli/commands/run.mdx @@ -2,9 +2,25 @@ title: "infisical run" --- -```bash -infisical run [options] -- [your application start command] -``` + + + ```bash + infisical run [options] -- [your application start command] + + # Example + infisical run [options] -- npm run dev + ``` + + + + ```bash + infisical run [options] --command [string command] + + # Example + infisical run [options] --command "npm run bootstrap && npm run dev start; other-bash-command" + ``` + + ## Description @@ -15,5 +31,6 @@ Inject environment variables from the platform into an application process. | Option | Description | Default value | | -------------- | ----------------------------------------------------------------------------------------------------------- | ------------- | | `--env` | Used to set the environment that secrets are pulled from. Accepted values: `dev`, `staging`, `test`, `prod` | `dev` | -| `--projectId` | Used to link a local project to the platform (required only if injecting via the service token method) | `None` | +| `--projectId` | Used to link a local project to the platform (required only if injecting via the service token method) | None | | `--expand` | Parse shell parameter expansions in your secrets (e.g., `${DOMAIN}`) | `true` | +| `--command` | Pass secrets into chained commands (e.g., `"first-command && second-command; more-commands..."`) | None | diff --git a/docs/contributing/developing.mdx b/docs/contributing/developing.mdx index 3be1a1b6b..25a1f72ee 100644 --- a/docs/contributing/developing.mdx +++ b/docs/contributing/developing.mdx @@ -23,7 +23,7 @@ Mandatory variables in the `.env` file: 1. Keys and JWT variables -![image](https://user-images.githubusercontent.com/118568289/206791534-9c9d1431-e83d-49c0-8a54-b373ed0df820.png) +![image](https://user-images.githubusercontent.com/8071263/208800914-f468c2ad-c6a8-4da7-8ffd-eece5d7f08d2.png) The `.env.example` has these variables empty, you can self generate the `JWT and ENCRYPTION_KEY` with this [32-byte random hex strings generator](https://www.browserling.com/tools/random-hex). diff --git a/docs/images/integrations-heroku-auth.png b/docs/images/integrations-heroku-auth.png new file mode 100644 index 000000000..da9b4cf52 Binary files /dev/null and b/docs/images/integrations-heroku-auth.png differ diff --git a/docs/images/integrations-heroku.png b/docs/images/integrations-heroku.png new file mode 100644 index 000000000..cc225f286 Binary files /dev/null and b/docs/images/integrations-heroku.png differ diff --git a/docs/images/integrations-netlify-auth.png b/docs/images/integrations-netlify-auth.png new file mode 100644 index 000000000..fe25d7acf Binary files /dev/null and b/docs/images/integrations-netlify-auth.png differ diff --git a/docs/images/integrations-netlify.png b/docs/images/integrations-netlify.png new file mode 100644 index 000000000..60261043e Binary files /dev/null and b/docs/images/integrations-netlify.png differ diff --git a/docs/images/integrations-vercel-auth.png b/docs/images/integrations-vercel-auth.png new file mode 100644 index 000000000..d8f3d2d18 Binary files /dev/null and b/docs/images/integrations-vercel-auth.png differ diff --git a/docs/images/integrations-vercel.png b/docs/images/integrations-vercel.png new file mode 100644 index 000000000..f3a814c7a Binary files /dev/null and b/docs/images/integrations-vercel.png differ diff --git a/docs/images/integrations.png b/docs/images/integrations.png new file mode 100644 index 000000000..88b0c7aec Binary files /dev/null and b/docs/images/integrations.png differ diff --git a/docs/integrations/cloud/heroku.mdx b/docs/integrations/cloud/heroku.mdx index 5f0debd3e..e16cd0d54 100644 --- a/docs/integrations/cloud/heroku.mdx +++ b/docs/integrations/cloud/heroku.mdx @@ -1,26 +1,29 @@ --- title: "Heroku" -description: "With this integration, you can automatically sync your secrets to Heroku as soon as you update secrets in Infisical." --- -## Instructions +Prerequisites: -### Step 1: Open the integrations console +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) -Open the Infisical Dashboard. Choose the project in which you want to set up the intergation. Go to the integrations tab in the left sidebar. +## Navigate to your project's integrations tab -### Step 2: Authenticate with Heroku +![integrations](../../images/integrations.png) -Click on "Heroku" tile. Log in if required and provide the necessary permissions to Infisical. You will afterwards be redirected back to the integrations page. +## Authorize Infisical for Heroku -Note: during an integration with Heroku, for security reasons, it is impossible to maintain end-to-end encryption. In theory, this lets Infisical decrypt yor environment variables. In practice, we can assure you that this will never be done, and it allows us to protect your secrets from bad actors online. With any questions, reach out support@infisical.com. +Press on the Heroku tile and grant Infisical access to your Heroku account. -### Step 3: Start integration +![integrations heroku authorization](../../images/integrations-heroku-auth.png) -Choose a Heroku App that you want to sync the secrets to, and the Infisical project environment that you want to sync the secrets from. Start the integration. - -The integration should now show status 'In Sync'. Every time you edit secrets, they will be automatically pushed to Heroku. - - - If you need to update your integration, you will have to delete the current one and create a new one. + + 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 Heroku app and press start integration to start syncing secrets to Heroku. + +![integrations heroku](../../images/integrations-heroku.png) + diff --git a/docs/integrations/cloud/netlify.mdx b/docs/integrations/cloud/netlify.mdx new file mode 100644 index 000000000..e78f01368 --- /dev/null +++ b/docs/integrations/cloud/netlify.mdx @@ -0,0 +1,32 @@ +--- +title: "Netlify" +--- + + + Infisical integrates with Netlify's new environment variable experience. If your site uses Netlify's old environment variable experience, you'll have to upgrade it to the new one to use this integration. + + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Authorize Infisical for Netlify + +Press on the Netlify tile and grant Infisical access to your Netlify account. + +![integrations netlify authorization](../../images/integrations-netlify-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant Infisical access to your project's environment variables. + Although this step breaks E2EE, it'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 Netlify app and context. Lastly, press start integration to start syncing secrets to Netlify. + +![integrations netlify](../../images/integrations-netlify.png) \ No newline at end of file diff --git a/docs/integrations/cloud/vercel.mdx b/docs/integrations/cloud/vercel.mdx index eb09203b5..59b416c44 100644 --- a/docs/integrations/cloud/vercel.mdx +++ b/docs/integrations/cloud/vercel.mdx @@ -2,4 +2,22 @@ title: "Vercel" --- -Coming soon. +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Authorize Infisical for Vercel + +Press on the Vercel tile and grant Infisical access to your Vercel account. + +![integrations vercel authorization](../../images/integrations-vercel-auth.png) + +## Start integration + +Select which Infisical environment secrets you want to sync to which Vercel app and environment. Lastly, press start integration to start syncing secrets to Vercel. + +![integrations vercel](../../images/integrations-vercel.png) \ No newline at end of file diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 922221a99..fb8a5d631 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -1,5 +1,5 @@ --- -title: "Overview" +title: 'Overview' --- Integrations allow environment variables to be synced from Infisical into your local development workflow, CI/CD pipelines, and production infrastructure. @@ -10,20 +10,10 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi | -------------------------------------------------------- | --------- | ----------- | | [Docker](/integrations/platforms/docker) | Platform | Available | | [Docker-Compose](/integrations/platforms/docker-compose) | Platform | Available | -| Kubernetes | Platform | Coming soon | +| [Kubernetes](/integrations/platforms/kubernetes) | Platform | Available | | [Heroku](/integrations/cloud/heroku) | Cloud | Available | -| [Vercel](/integrations/cloud/vercel) | Cloud | Coming soon | -| [Render](/integrations/cloud/render) | Cloud | Coming soon | -| [Fly.io](/integrations/cloud/flyio) | Cloud | Coming soon | -| AWS | Cloud | Coming soon | -| GCP | Cloud | Coming soon | -| Azure | Cloud | Coming soon | -| DigitalOcean | Cloud | Coming soon | -| GitLab | CI/CD | Coming soon | -| [CircleCI](/integrations/cicd/circleci) | CI/CD | Coming soon | -| TravisCI | CI/CD | Coming soon | -| GitHub Actions | CI/CD | Coming soon | -| Jenkins | CI/CD | Coming soon | +| [Vercel](/integrations/cloud/vercel) | Cloud | Available | +| [Netlify](/integrations/cloud/netlify) | Cloud | Available | | [React](/integrations/frameworks/react) | Framework | Available | | [Vue](/integrations/frameworks/vue) | Framework | Available | | [Express](/integrations/frameworks/express) | Framework | Available | @@ -38,3 +28,14 @@ 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 | +| [Render](/integrations/cloud/render) | Cloud | Coming soon | +| [Fly.io](/integrations/cloud/flyio) | Cloud | Coming soon | +| AWS | Cloud | Coming soon | +| GCP | Cloud | Coming soon | +| Azure | Cloud | Coming soon | +| DigitalOcean | Cloud | Coming soon | +| GitLab | CI/CD | Coming soon | +| [CircleCI](/integrations/cicd/circleci) | CI/CD | Coming soon | +| TravisCI | CI/CD | Coming soon | +| GitHub Actions | CI/CD | Coming soon | +| Jenkins | CI/CD | Coming soon | diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx new file mode 100644 index 000000000..51a9d7ec4 --- /dev/null +++ b/docs/integrations/platforms/kubernetes.mdx @@ -0,0 +1,161 @@ +--- +title: 'Kubernetes' +--- + +The Infisical Secrets Operator is a custom Kubernetes controller that helps keep secrets in a cluster up to date by synchronizing them. +It is installed in its own namespace within the cluster and follows strict RBAC policies. +The operator uses InfisicalSecret custom resources to identify which secrets to sync and where to store them. +It is responsible for continuously updating managed secrets, and in the future may also automatically reload deployments that use them as needed. + +## Install Operator + +The operator can be install via [Helm](helm.sh) or [kubectl](https://github.com/kubernetes/kubectl) + + + + Install Infisical Helm repository + ```bash + helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' + + helm repo update + ``` + + Install the Helm chart + ```bash + helm install --generate-name infisical-helm-charts/secrets-operator + ``` + + + + The operator will be installed in `infisical-operator-system` namespace + ``` + kubectl apply -f https://raw.githubusercontent.com/Infisical/infisical/main/k8-operator/kubectl-install/install-secrets-operator.yaml + ``` + + + +## Sync Infisical Secrets to your cluster + +To retrieve secrets from an Infisical project and store them in your Kubernetes cluster, you can use the InfisicalSecret custom resource. +This resource is available after installing the Infisical operator. In order to specify the Infisical Token location and the location where the retrieved secrets should be stored, you can use the `tokenSecretReference` and `managedSecretReference` fields within the InfisicalSecret resource. + + + The `tokenSecretReference` field in the InfisicalSecret resource is used to specify the location of the Infisical Token, which is required for authenticating and retrieving secrets from an Infisical project. + + To create a Kubernetes secret containing an [Infisical Token](../../getting-started/dashboard/token), you can run the following command. + ``` bash + kubectl create secret generic service-token --from-literal=infisicalToken= + ``` + +Once the secret is created, add the name and namespace of the secret under `tokenSecretReference` field in the InfisicalSecret custom resource. + +{' '} + + + No matter what the name of the secret is or its namespace, it must contain a + key named `infisicalToken` with a valid Infisical Token as the value + + + + + +The `managedSecretReference` field in the InfisicalSecret resource is used to specify the location where secrets retrieved from an Infisical project should be stored. +You should specify the name and namespace of the Kubernetes secret that will hold these secrets. The operator will create the secret for you, you just need to provide its name and namespace. + +It is recommended that the managed secret be created in the same namespace as the deployment that will use it. + + + +```yaml +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + # Name of of this InfisicalSecret resource + name: infisicalsecret-sample +spec: + # The host that should be used to pull secrets from. The default value is https://infisical.com/api. + hostAPI: https://infisical.com/api + + # The Infisical project from which to pull secrets from + projectId: 62faf98ae0b05e8529b5da46 + + # The environment (dev, prod, testing, etc.) of the above project from where secrets should be pulled from + environment: dev + + # The Kubernetes secret the stores the Infisical token + tokenSecretReference: + # Kubernetes secret name + secretName: service-token + # The secret namespace + secretNamespace: default + + # The Kubernetes secret that Infisical Operator will create and populate with secrets from the above project + managedSecretReference: + # The name of managed Kubernetes secret that should be created + secretName: managed-secret + # The namespace the managed secret should be installed in + secretNamespace: default +``` + +## Verify + +To use the InfisicalSecret custom resource in your deployment, you can simply reference the managed secret specified in the `managedSecretReference` field as you would any other Kubernetes secret. +To verify that the operator has successfully created the managed secret, you can check the secrets in the namespace that was specified. + +```bash +# Verify managed secret is created +kubectl get secrets -n +``` + + + The Infisical secrets will be synced and stored into the managed secret every + 5 minutes. + + +## Troubleshoot + +If the operator is unable to fetch secrets from the API, it will not affect the managed Kubernetes secret. +It will continue attempting to reconnect to the API indefinitely. +The InfisicalSecret resource uses the `status.conditions` field to report its current state and any errors encountered. + +```yaml +$ kubectl get infisicalSecrets +NAME AGE +infisicalsecret-sample 12s + +$ kubectl describe infisicalSecret infisicalsecret-sample +... +Spec: +... +Status: + Conditions: + Last Transition Time: 2022-12-18T04:29:09Z + Message: Infisical controller has located the Infisical token in provided Kubernetes secret + Reason: OK + Status: True + Type: secrets.infisical.com/LoadedInfisicalToken + Last Transition Time: 2022-12-18T04:29:10Z + Message: Failed to update secret because: 400 Bad Request + Reason: Error + Status: False + Type: secrets.infisical.com/ReadyToSyncSecrets +Events: +``` + +## Uninstall Operator + +The managed secret created by the operator will not be deleted when the operator is uninstalled. + + + + Install Infisical Helm repository + ```bash + helm uninstall add + ``` + + + ``` + kubectl delete -f https://raw.githubusercontent.com/Infisical/infisical/main/k8-operator/kubectl-install/install-secrets-operator.yaml + ``` + + diff --git a/docs/mint.json b/docs/mint.json index 2ac025ca6..ff00ca83a 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -124,7 +124,8 @@ "group": "Platforms", "pages": [ "integrations/platforms/docker", - "integrations/platforms/docker-compose" + "integrations/platforms/docker-compose", + "integrations/platforms/kubernetes" ] }, { @@ -132,6 +133,7 @@ "pages": [ "integrations/cloud/heroku", "integrations/cloud/vercel", + "integrations/cloud/netlify", "integrations/cloud/render", "integrations/cloud/flyio" ] diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index a55efbebc..9c6697df5 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -28,6 +28,9 @@ Configuring Infisical requires setting some environment variables. There is a fi | `SMTP_USERNAME` | ❗️ Credential to connect to host (e.g. `team@infisical.com`) | `None` | | `SMTP_PASSWORD` | ❗️ Credential to connect to host | `None` | | `TELEMETRY_ENABLED` | `true` or `false`. [More](../overview). | `true` | -| `OAUTH_CLIENT_SECRET_HEROKU` | OAuth client secret for Heroku integration | `None` | -| `OAUTH_TOKEN_URL_HEROKU` | OAuth token URL for Heroku integration | `None` | +| `CLIENT_ID_VERCEL` | OAuth client id for Vercel integration | `None` | +| `CLIENT_ID_NETLIFY` | OAuth client id for Netlify integration | `None` | +| `CLIENT_SECRET_HEROKU` | OAuth client secret for Heroku integration | `None` | +| `CLIENT_SECRET_VERCEL` | OAuth client secret for Vercel integration | `None` | +| `CLIENT_SECRET_NETLIFY` | OAuth client secret for Netlify integration | `None` | | `SENTRY_DSN` | DSN for error-monitoring with Sentry | `None` | diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 5deb468f7..520f0fb7f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -10,7 +10,7 @@ WORKDIR /app COPY package.json package-lock.json next.config.js ./ # Install dependencies -RUN npm ci --only-production +RUN npm ci --only-production --ignore-scripts # Rebuild the source code only when needed diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev index 2bae23823..cb462bbc4 100644 --- a/frontend/Dockerfile.dev +++ b/frontend/Dockerfile.dev @@ -9,7 +9,7 @@ COPY package.json ./ COPY package-lock.json ./ # Install -RUN npm install +RUN npm install --ignore-scripts # Copy over next.js config COPY next.config.js ./next.config.js @@ -17,4 +17,4 @@ COPY next.config.js ./next.config.js # Copy all files COPY . . -CMD ["npm", "run", "dev"] \ No newline at end of file +CMD ["npm", "run", "dev"] diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod deleted file mode 100644 index d95c00883..000000000 --- a/frontend/Dockerfile.prod +++ /dev/null @@ -1,20 +0,0 @@ -# Base layer -FROM node:16-alpine - -# Set the working directory -WORKDIR /app - -# Copy over dependency files -COPY package.json ./ -COPY package-lock.json ./ - -# Install -RUN npm install - -# Copy over next.js config -COPY next.config.js ./next.config.js - -# Copy all files -COPY . . - -CMD ["npm", "run", "start:docker"] diff --git a/frontend/components/basic/Listbox.tsx b/frontend/components/basic/Listbox.tsx index e87bab208..95e3f33c6 100644 --- a/frontend/components/basic/Listbox.tsx +++ b/frontend/components/basic/Listbox.tsx @@ -10,8 +10,8 @@ import { Listbox, Transition } from '@headlessui/react'; interface ListBoxProps { selected: string; - onChange: () => void; - data: string[]; + onChange: (arg: string) => void; + data: string[] | null; text?: string; buttonAction?: () => void; isFull?: boolean; diff --git a/frontend/components/basic/buttons/Button.tsx b/frontend/components/basic/buttons/Button.tsx index 9f8d481b4..562a82a36 100644 --- a/frontend/components/basic/buttons/Button.tsx +++ b/frontend/components/basic/buttons/Button.tsx @@ -9,7 +9,7 @@ import { const classNames = require("classnames"); type ButtonProps = { - text: string; + text?: string; onButtonPressed: () => void; loading?: boolean; color?: string; diff --git a/frontend/components/basic/dialog/ActivateBotDialog.js b/frontend/components/basic/dialog/ActivateBotDialog.js new file mode 100644 index 000000000..79d8cd693 --- /dev/null +++ b/frontend/components/basic/dialog/ActivateBotDialog.js @@ -0,0 +1,92 @@ +import { Fragment } from "react"; +import { Dialog, Transition } from "@headlessui/react"; + +import setBotActiveStatus from "../../../pages/api/bot/setBotActiveStatus"; +import getLatestFileKey from "../../../pages/api/workspace/getLatestFileKey"; +import { + decryptAssymmetric, + encryptAssymmetric +} from "../../utilities/cryptography/crypto"; +import Button from "../buttons/Button"; + +const ActivateBotDialog = ({ + isOpen, + closeModal, + selectedIntegrationOption, + handleBotActivate, + handleIntegrationOption +}) => { + + const submit = async () => { + try { + // 1. activate bot + await handleBotActivate(); + + // 2. start integration + await handleIntegrationOption({ + integrationOption: selectedIntegrationOption + }); + } catch (err) { + console.log(err); + } + + closeModal(); + } + + return ( +
+ + + +
+ +
+
+ + + + Grant Infisical access to your secrets + +
+

+ Most cloud integrations require Infisical to be able to decrypt your secrets so they can be forwarded over. +

+
+
+
+
+
+
+
+
+
+
+ ); +} + +export default ActivateBotDialog; \ No newline at end of file diff --git a/frontend/components/basic/dialog/IntegrationAccessTokenDialog.js b/frontend/components/basic/dialog/IntegrationAccessTokenDialog.js new file mode 100644 index 000000000..375dc8804 --- /dev/null +++ b/frontend/components/basic/dialog/IntegrationAccessTokenDialog.js @@ -0,0 +1,101 @@ +import { Fragment } from "react"; +import { Dialog, Transition } from "@headlessui/react"; + +import setBotActiveStatus from "../../../pages/api/bot/setBotActiveStatus"; +import getLatestFileKey from "../../../pages/api/workspace/getLatestFileKey"; +import { + decryptAssymmetric, + encryptAssymmetric +} from "../../utilities/cryptography/crypto"; +import Button from "../buttons/Button"; +import InputField from "../InputField"; + +const IntegrationAccessTokenDialog = ({ + isOpen, + closeModal, + selectedIntegrationOption, + handleBotActivate, + handleIntegrationOption +}) => { + + const submit = async () => { + try { + // 1. activate bot + await handleBotActivate(); + + // 2. start integration + await handleIntegrationOption({ + integrationOption: selectedIntegrationOption + }); + } catch (err) { + console.log(err); + } + + closeModal(); + } + + return ( +
+ + + +
+ +
+
+ + + + Grant Infisical access to your secrets + +
+

+ Most cloud integrations require Infisical to be able to decrypt your secrets so they can be forwarded over. +

+
+
+ {/*
+
+
+
+
+
+
+
+ ); +} + +export default IntegrationAccessTokenDialog; \ No newline at end of file diff --git a/frontend/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx new file mode 100644 index 000000000..75a8019a5 --- /dev/null +++ b/frontend/components/integrations/CloudIntegration.tsx @@ -0,0 +1,121 @@ +import React from "react"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import { + faCheck, + faX, + } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import deleteIntegrationAuth from "../../pages/api/integrations/DeleteIntegrationAuth"; + +interface CloudIntegrationOption { + isAvailable: boolean; + name: string; + type: string; + clientId: string; + docsLink: string; + slug: string; +} + +interface IntegrationAuth { + _id: string; + integration: string; +} + +interface Props { + cloudIntegrationOption: CloudIntegrationOption; + setSelectedIntegrationOption: (cloudIntegration: CloudIntegrationOption) => void; + integrationOptionPress: (cloudIntegrationOption: CloudIntegrationOption) => void; + integrationAuths: IntegrationAuth[]; +} + +const CloudIntegration = ({ + cloudIntegrationOption, + setSelectedIntegrationOption, + integrationOptionPress, + integrationAuths +}: Props) => { + const router = useRouter(); + return integrationAuths ? ( +
{ + if (!cloudIntegrationOption.isAvailable) return; + setSelectedIntegrationOption(cloudIntegrationOption); + integrationOptionPress(cloudIntegrationOption); + }} + key={cloudIntegrationOption.name} + > + integration logo + {cloudIntegrationOption.name.split(" ").length > 2 ? ( +
+
{cloudIntegrationOption.name.split(" ")[0]}
+
+ {cloudIntegrationOption.name.split(" ")[1]}{" "} + {cloudIntegrationOption.name.split(" ")[2]} +
+
+ ) : ( +
+ {cloudIntegrationOption.name} +
+ )} + {cloudIntegrationOption.isAvailable && + integrationAuths + .map((authorization) => authorization.integration) + .includes(cloudIntegrationOption.name.toLowerCase()) && ( +
+
{ + event.stopPropagation(); + deleteIntegrationAuth({ + integrationAuthId: integrationAuths + .filter( + (authorization) => + authorization.integration == + cloudIntegrationOption.name.toLowerCase() + ) + .map((authorization) => authorization._id)[0], + }); + + router.reload(); + }} + className="cursor-pointer w-max bg-red py-0.5 px-2 rounded-b-md text-xs flex flex-row items-center opacity-0 group-hover:opacity-100 duration-200" + > + + Revoke +
+
+ + Authorized +
+
+ )} + {!cloudIntegrationOption.isAvailable && ( +
+
+ Coming Soon +
+
+ )} +
+ ) :
+} + +export default CloudIntegration; \ No newline at end of file diff --git a/frontend/components/integrations/CloudIntegrationSection.tsx b/frontend/components/integrations/CloudIntegrationSection.tsx new file mode 100644 index 000000000..58fb92cb2 --- /dev/null +++ b/frontend/components/integrations/CloudIntegrationSection.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +import CloudIntegration from "./CloudIntegration"; + +interface CloudIntegrationOption { + isAvailable: boolean; + name: string; + type: string; + clientId: string; + docsLink: string; + slug: string; +} + +interface Props { + cloudIntegrationOptions: CloudIntegrationOption[]; + setSelectedIntegrationOption: () => void; + integrationOptionPress: () => void; + integrationAuths: any; +} + +const CloudIntegrationSection = ({ + cloudIntegrationOptions, + setSelectedIntegrationOption, + integrationOptionPress, + integrationAuths +}: Props) => { + return ( + <> +
+

Cloud Integrations

+

+ Click on an integration to begin syncing secrets to it. +

+
+
+ {cloudIntegrationOptions.map((cloudIntegrationOption) => ( + + ))} +
+ + ); +} + +export default CloudIntegrationSection; \ No newline at end of file diff --git a/frontend/components/integrations/FrameworkIntegration.tsx b/frontend/components/integrations/FrameworkIntegration.tsx new file mode 100644 index 000000000..432dbad51 --- /dev/null +++ b/frontend/components/integrations/FrameworkIntegration.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import Image from "next/image"; + +interface Framework { + name: string; + slug: string; + image: string; + docsLink: string; +} + +const FrameworkIntegration = ({ + framework +}: { + framework: Framework; +}) => { + return ( + +
1 ? "text-sm px-1" : "text-xl px-2"} text-center w-full max-w-xs`}> + {framework?.image && integration logo} + {framework?.name && framework?.image &&
} + {framework?.name && framework.name} +
+
+ ); +} + +export default FrameworkIntegration; diff --git a/frontend/components/integrations/FrameworkIntegrationSection.tsx b/frontend/components/integrations/FrameworkIntegrationSection.tsx new file mode 100644 index 000000000..8535c595b --- /dev/null +++ b/frontend/components/integrations/FrameworkIntegrationSection.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +import FrameworkIntegration from "./FrameworkIntegration"; + +interface Framework { + name: string; + image: string; + link: string; + slug: string; + docsLink: string; +} + +interface Props { + frameworks: [Framework] +} + +const FrameworkIntegrationSection = ({ frameworks }: Props) => { + return ( + <> +
+

Framework Integrations

+

+ Click on a framework to get the setup instructions. +

+
+
+ {frameworks.map((framework) => ( + + ))} +
+ + ); +} + +export default FrameworkIntegrationSection; + diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx new file mode 100644 index 000000000..053269be4 --- /dev/null +++ b/frontend/components/integrations/Integration.tsx @@ -0,0 +1,231 @@ +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { + faArrowRight, + faRotate, + faX, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import Button from "~/components/basic/buttons/Button"; +import ListBox from "~/components/basic/Listbox"; + +import deleteIntegration from "../../pages/api/integrations/DeleteIntegration" +import getIntegrationApps from "../../pages/api/integrations/GetIntegrationApps"; +import updateIntegration from "../../pages/api/integrations/updateIntegration" +import { + contextNetlifyMapping, + envMapping, + reverseContextNetlifyMapping, + reverseEnvMapping, +} from "../../public/data/frequentConstants"; + +interface Integration { + _id: string; + app?: string; + environment: string; + integration: string; + integrationAuth: string; + isActive: boolean; + context: string; +} + +interface IntegrationApp { + name: string; + siteId: string; +} + +const Integration = ({ + integration +}: { + integration: Integration; +}) => { + const [integrationEnvironment, setIntegrationEnvironment] = useState( + reverseEnvMapping[integration.environment] + ); + const [fileState, setFileState] = useState([]); + const router = useRouter(); + const [apps, setApps] = useState([]); // integration app objects + const [integrationApp, setIntegrationApp] = useState(""); // integration app name + const [integrationTarget, setIntegrationTarget] = useState(""); // vercel-specific integration param + const [integrationContext, setIntegrationContext] = useState(""); // netlify-specific integration param + + useEffect(() => { + + const loadIntegration = async () => { + interface App { + name: string; + siteId?: string; + } + + const tempApps: [IntegrationApp] = await getIntegrationApps({ + integrationAuthId: integration.integrationAuth, + }); + + setApps(tempApps); + setIntegrationApp( + integration.app ? integration.app : tempApps[0].name + ); + + switch (integration.integration) { + case "vercel": + setIntegrationTarget("Development"); + break; + case "netlify": + setIntegrationContext(integration?.context ? contextNetlifyMapping[integration.context] : "Local development"); + break; + default: + break; + } + } + + loadIntegration(); + }, []); + + const renderIntegrationSpecificParams = (integration: Integration) => { + try { + switch (integration.integration) { + case "vercel": + return ( +
+
+ ENVIRONMENT +
+ +
+ ); + case "netlify": + return ( +
+
+ CONTEXT +
+ +
+ ); + default: + return
; + } + } catch (err) { + console.error(err); + } + } + + if (!integrationApp || apps.length === 0) return
+ + return ( +
+
+
+

ENVIRONMENT

+ { + setIntegrationEnvironment(environment); + }} + isFull={true} + /> +
+
+ +
+
+

+ INTEGRATION +

+
+ {integration.integration.charAt(0).toUpperCase() + + integration.integration.slice(1)} +
+
+
+
+ APP +
+ app.name) : null} + selected={integrationApp} + onChange={(app) => { + setIntegrationApp(app); + }} + /> +
+ {renderIntegrationSpecificParams(integration)} +
+
+ {integration.isActive ? ( +
+ +
In Sync
+
+ ) : ( +
+
+ + ); + }; + +export default Integration; \ No newline at end of file diff --git a/frontend/components/integrations/IntegrationSection.tsx b/frontend/components/integrations/IntegrationSection.tsx new file mode 100644 index 000000000..52d5565ff --- /dev/null +++ b/frontend/components/integrations/IntegrationSection.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +import guidGenerator from "~/utilities/randomId"; + +import Integration from "./Integration"; + +interface Props { + integrations: any +} + +interface IntegrationType { + _id: string; + app?: string; + environment: string; + integration: string; + integrationAuth: string; + isActive: boolean; + context: string; +} + +const ProjectIntegrationSection = ({ + integrations +}: Props) => { + return integrations.length > 0 ? ( +
+
+

Current Integrations

+

+ Manage your integrations of Infisical with third-party services. +

+
+ {integrations.map((integration: IntegrationType) => ( + + ))} +
+ ) :
+} + +export default ProjectIntegrationSection; \ No newline at end of file diff --git a/frontend/components/utilities/attemptLogin.js b/frontend/components/utilities/attemptLogin.js index 228f58727..6bf575bd1 100644 --- a/frontend/components/utilities/attemptLogin.js +++ b/frontend/components/utilities/attemptLogin.js @@ -42,9 +42,9 @@ const attemptLogin = async ( async () => { const clientPublicKey = client.getPublicKey(); - const { serverPublicKey, salt } = await login1(email, clientPublicKey); - try { + const { serverPublicKey, salt } = await login1(email, clientPublicKey); + client.setSalt(salt); client.setServerPublicKey(serverPublicKey); const clientProof = client.getProof(); // called M1 @@ -73,7 +73,7 @@ const attemptLogin = async ( tag, privateKey }); - + const userOrgs = await getOrganizations(); const userOrgsData = userOrgs.map((org) => org._id); diff --git a/frontend/components/utilities/config/index.ts b/frontend/components/utilities/config/index.ts index d0ffed00c..7570785ba 100644 --- a/frontend/components/utilities/config/index.ts +++ b/frontend/components/utilities/config/index.ts @@ -10,5 +10,5 @@ export { POSTHOG_API_KEY, POSTHOG_HOST, STRIPE_PRODUCT_PRO, - STRIPE_PRODUCT_STARTER, -}; + STRIPE_PRODUCT_STARTER +}; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e3bb010b..b65657f85 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -60,7 +60,6 @@ "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-simple-import-sort": "^8.0.0", "postcss": "^8.4.14", - "prettier": "2.7.1", "tailwindcss": "^3.1.4", "typescript": "^4.9.3" } @@ -5999,21 +5998,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", - "dev": true, - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -11950,12 +11934,6 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true }, - "prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", - "dev": true - }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7f7926c88..12b2e9ec2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,7 @@ { "private": true, "scripts": { + "prepare": "cd .. && npm install", "dev": "next dev", "build": "next build", "start": "next start", @@ -63,7 +64,6 @@ "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-simple-import-sort": "^8.0.0", "postcss": "^8.4.14", - "prettier": "2.7.1", "tailwindcss": "^3.1.4", "typescript": "^4.9.3" } diff --git a/frontend/pages/api/bot/getBot.ts b/frontend/pages/api/bot/getBot.ts new file mode 100644 index 000000000..145b50891 --- /dev/null +++ b/frontend/pages/api/bot/getBot.ts @@ -0,0 +1,31 @@ +import SecurityClient from "~/utilities/SecurityClient"; + +interface Props { + workspaceId: string; +} + +/** + * This function fetches the bot for a project + * @param {Object} obj + * @param {String} obj.workspaceId + * @returns + */ +const getBot = async ({ workspaceId }: Props) => { + return SecurityClient.fetchCall( + "/api/v1/bot/" + workspaceId, + { + method: "GET", + headers: { + "Content-Type": "application/json", + } + } + ).then(async (res) => { + if (res && res.status == 200) { + return (await res.json()).bot; + } else { + console.log("Failed to get bot for project"); + } + }); +}; + +export default getBot; \ No newline at end of file diff --git a/frontend/pages/api/bot/setBotActiveStatus.ts b/frontend/pages/api/bot/setBotActiveStatus.ts new file mode 100644 index 000000000..0a974a588 --- /dev/null +++ b/frontend/pages/api/bot/setBotActiveStatus.ts @@ -0,0 +1,46 @@ +import SecurityClient from "~/utilities/SecurityClient"; + +interface BotKey { + encryptedKey: string; + nonce: string; +} + +interface Props { + botId: string; + isActive: boolean; + botKey: BotKey; +} + +/** + * This function sets the active status of a bot and shares a copy of + * the project key (encrypted under the bot's public key) with the + * project's bot + * @param {Object} obj + * @param {String} obj.botId + * @param {String} obj.isActive + * @param {Object} obj.botKey + * @returns + */ +const setBotActiveStatus = async ({ botId, isActive, botKey }: Props) => { + return SecurityClient.fetchCall( + "/api/v1/bot/" + botId + "/active", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + isActive, + botKey + }) + } + ).then(async (res) => { + if (res && res.status == 200) { + return await res.json(); + } else { + console.log("Failed to get bot for project"); + } + }); +}; + +export default setBotActiveStatus; \ No newline at end of file diff --git a/frontend/pages/api/integrations/GetIntegrationOptions.ts b/frontend/pages/api/integrations/GetIntegrationOptions.ts new file mode 100644 index 000000000..caf0c8626 --- /dev/null +++ b/frontend/pages/api/integrations/GetIntegrationOptions.ts @@ -0,0 +1,21 @@ +import SecurityClient from '~/utilities/SecurityClient'; + +const getIntegrationOptions = () => { + return SecurityClient.fetchCall( + '/api/v1/integration-auth/integration-options', + { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + } + ).then(async (res) => { + if (res && res.status == 200) { + return (await res.json()).integrationOptions; + } else { + console.log('Failed to get (cloud) integration options'); + } + }); +}; + +export default getIntegrationOptions; diff --git a/frontend/pages/api/integrations/GetIntegrations.ts b/frontend/pages/api/integrations/GetIntegrations.ts deleted file mode 100644 index c189010a4..000000000 --- a/frontend/pages/api/integrations/GetIntegrations.ts +++ /dev/null @@ -1,18 +0,0 @@ -import SecurityClient from '~/utilities/SecurityClient'; - -const getIntegrations = () => { - return SecurityClient.fetchCall('/api/v1/integration/integrations', { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - } - }).then(async (res) => { - if (res && res.status == 200) { - return (await res.json()).integrations; - } else { - console.log('Failed to get project integrations'); - } - }); -}; - -export default getIntegrations; diff --git a/frontend/pages/api/integrations/updateIntegration.ts b/frontend/pages/api/integrations/updateIntegration.ts new file mode 100644 index 000000000..8afcfc0a9 --- /dev/null +++ b/frontend/pages/api/integrations/updateIntegration.ts @@ -0,0 +1,60 @@ +import SecurityClient from "~/utilities/SecurityClient"; + +/** + * This route starts the integration after teh default one if gonna set up. + * Update integration with id [integrationId] to sync envars from the project's + * [environment] to the integration [app] with active state [isActive] + * @param {Object} obj + * @param {String} obj.integrationId - id of integration + * @param {String} obj.app - name of app + * @param {String} obj.environment - project environment to push secrets from + * @param {Boolean} obj.isActive - active state + * @param {String} obj.target - (optional) target (environment) for Vercel integration + * @param {String} obj.context - (optional) context (environment) for Netlify integration + * @param {String} obj.siteId - (optional) app (site_id) for Netlify integration + * @returns + */ +const updateIntegration = ({ + integrationId, + app, + environment, + isActive, + target, + context, + siteId +}: { + integrationId: string, + app: string, + environment: string, + isActive: boolean, + target: string | null, + context: string | null, + siteId: string | null + +}) => { + return SecurityClient.fetchCall( + "/api/v1/integration/" + integrationId, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + app, + environment, + isActive, + target, + context, + siteId + }), + } + ).then(async (res) => { + if (res && res.status == 200) { + return res; + } else { + console.log("Failed to start an integration"); + } + }); +}; + +export default updateIntegration; diff --git a/frontend/pages/dashboard/[id].js b/frontend/pages/dashboard/[id].js index 4bb09c80f..36f5eb3f2 100644 --- a/frontend/pages/dashboard/[id].js +++ b/frontend/pages/dashboard/[id].js @@ -413,29 +413,6 @@ export default function Dashboard() { setButtonReady(false); pushKeys({ obj, workspaceId: router.query.id, env }); - /** - * Check which integrations are active for this project and environment - * If there are any, update environment variables for those integrations - */ - let integrations = await getWorkspaceIntegrations({ - workspaceId: router.query.id - }); - integrations.map(async (integration) => { - if ( - envMapping[env] == integration.environment && - integration.isActive == true - ) { - let objIntegration = Object.assign( - {}, - ...data.map((row) => ({ [row.key]: row.value })) - ); - await pushKeysIntegration({ - obj: objIntegration, - integrationId: integration._id - }); - } - }); - // If this user has never saved environment variables before, show them a prompt to read docs if (!hasUserEverPushed) { setCheckDocsPopUpVisible(true); diff --git a/frontend/pages/heroku.js b/frontend/pages/heroku.js index 82947b1b9..088c96500 100644 --- a/frontend/pages/heroku.js +++ b/frontend/pages/heroku.js @@ -16,16 +16,17 @@ export default function Heroku() { // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(async () => { try { - if (state == localStorage.getItem("latestCSRFToken")) { + if (state === localStorage.getItem('latestCSRFToken')) { + localStorage.removeItem('latestCSRFToken'); await AuthorizeIntegration({ - workspaceId: localStorage.getItem("projectData.id"), + workspaceId: localStorage.getItem('projectData.id'), code, integration: "heroku", }); router.push("/integrations/" + localStorage.getItem("projectData.id")); } } catch (error) { - console.log("Error - Not logged in yet"); + console.error('Heroku integration error: ', error); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index dc13d284c..a7b5f6b13 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -1,199 +1,179 @@ -import React, { useEffect, useState } from 'react'; -import Head from 'next/head'; -import Image from 'next/image'; -import { useRouter } from 'next/router'; -import { - faArrowRight, - faCheck, - faRotate, - faX -} from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useEffect, useState } from "react"; +import Head from "next/head"; +import Image from "next/image"; +import { useRouter } from "next/router"; -import Button from '~/components/basic/buttons/Button'; -import ListBox from '~/components/basic/Listbox'; -import NavHeader from '~/components/navigation/NavHeader'; -import getSecretsForProject from '~/components/utilities/secrets/getSecretsForProject'; -import pushKeysIntegration from '~/components/utilities/secrets/pushKeysIntegration'; -import guidGenerator from '~/utilities/randomId'; +import ActivateBotDialog from "~/components/basic/dialog/ActivateBotDialog"; +import IntegrationAccessTokenDialog from "~/components/basic/dialog/IntegrationAccessTokenDialog"; +import CloudIntegrationSection from "~/components/integrations/CloudIntegrationSection"; +import FrameworkIntegrationSection from "~/components/integrations/FrameworkIntegrationSection"; +import Integration from "~/components/integrations/Integration"; +import IntegrationSection from "~/components/integrations/IntegrationSection"; +import NavHeader from "~/components/navigation/NavHeader"; -import { - envMapping, - frameworks, - reverseEnvMapping -} from '../../public/data/frequentConstants'; -import deleteIntegration from '../api/integrations/DeleteIntegration'; -import deleteIntegrationAuth from '../api/integrations/DeleteIntegrationAuth'; -import getIntegrationApps from '../api/integrations/GetIntegrationApps'; -import getIntegrations from '../api/integrations/GetIntegrations'; -import getWorkspaceAuthorizations from '../api/integrations/getWorkspaceAuthorizations'; -import getWorkspaceIntegrations from '../api/integrations/getWorkspaceIntegrations'; -import startIntegration from '../api/integrations/StartIntegration'; - -const crypto = require('crypto'); - -const Integration = ({ projectIntegration }) => { - const [integrationEnvironment, setIntegrationEnvironment] = useState( - reverseEnvMapping[projectIntegration.environment] - ); - const [fileState, setFileState] = useState([]); - const [data, setData] = useState(); - const [isKeyAvailable, setIsKeyAvailable] = useState(true); - const router = useRouter(); - const [apps, setApps] = useState([]); - const [integrationApp, setIntegrationApp] = useState( - projectIntegration.app ? projectIntegration.app : apps[0] - ); - - useEffect(async () => { - const tempHerokuApps = await getIntegrationApps({ - integrationAuthId: projectIntegration.integrationAuth - }); - const tempHerokuAppNames = tempHerokuApps.map((app) => app.name); - setApps(tempHerokuAppNames); - setIntegrationApp( - projectIntegration.app ? projectIntegration.app : tempHerokuAppNames[0] - ); - }, []); - - return ( -
-
-
-
-
- ENVIRONMENT -
- -
- -
-
- INTEGRATION -
-
- {projectIntegration.integration.charAt(0).toUpperCase() + - projectIntegration.integration.slice(1)} -
-
-
-
- HEROKU APP -
- -
-
-
- {projectIntegration.isActive ? ( -
- -
In Sync
-
- ) : ( -
-
-
- - ); -}; +import frameworkIntegrationOptions from "../../public/json/frameworkIntegrations.json"; +import getBot from "../api/bot/getBot"; +import setBotActiveStatus from "../api/bot/setBotActiveStatus"; +import getIntegrationOptions from "../api/integrations/GetIntegrationOptions"; +import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations"; +import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations"; +import getLatestFileKey from "../api/workspace/getLatestFileKey"; +const { + decryptAssymmetric, + encryptAssymmetric +} = require('../../components/utilities/cryptography/crypto'); +const crypto = require("crypto"); export default function Integrations() { - const [integrations, setIntegrations] = useState(); - const [projectIntegrations, setProjectIntegrations] = useState(); - const [authorizations, setAuthorizations] = useState(); + const [cloudIntegrationOptions, setCloudIntegrationOptions] = useState([]); + const [integrationAuths, setIntegrationAuths] = useState([]); + const [integrations, setIntegrations] = useState([]); + const [bot, setBot] = useState(null); + const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false); + // const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(true); + const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null); + const router = useRouter(); - const [csrfToken, setCsrfToken] = useState(''); useEffect(async () => { - const tempCSRFToken = crypto.randomBytes(16).toString('hex'); - setCsrfToken(tempCSRFToken); - localStorage.setItem('latestCSRFToken', tempCSRFToken); - - let projectAuthorizations = await getWorkspaceAuthorizations({ - workspaceId: router.query.id - }); - setAuthorizations(projectAuthorizations); - - const projectIntegrations = await getWorkspaceIntegrations({ - workspaceId: router.query.id - }); - setProjectIntegrations(projectIntegrations); - try { - const integrationsData = await getIntegrations(); - setIntegrations(integrationsData); - } catch (error) { - console.log('Error', error); + // get cloud integration options + setCloudIntegrationOptions( + await getIntegrationOptions() + ); + + // get project integration authorizations + setIntegrationAuths( + await getWorkspaceAuthorizations({ + workspaceId: router.query.id, + }) + ); + + // get project integrations + setIntegrations( + await getWorkspaceIntegrations({ + workspaceId: router.query.id, + }) + ); + + // get project bot + setBot( + await getBot({ + workspaceId: router.query.id + } + )); + + } catch (err) { + console.log(err); } }, []); - return integrations ? ( + /** + * Activate bot for project by performing the following steps: + * 1. Get the (encrypted) project key + * 2. Decrypt project key with user's private key + * 3. Encrypt project key with bot's public key + * 4. Send encrypted project key to backend and set bot status to active + */ + const handleBotActivate = async () => { + let botKey; + try { + + if (bot) { + // case: there is a bot + const key = await getLatestFileKey({ workspaceId: router.query.id }); + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); + + const WORKSPACE_KEY = decryptAssymmetric({ + ciphertext: key.latestKey.encryptedKey, + nonce: key.latestKey.nonce, + publicKey: key.latestKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: WORKSPACE_KEY, + publicKey: bot.publicKey, + privateKey: PRIVATE_KEY + }); + + botKey = { + encryptedKey: ciphertext, + nonce + } + + setBot((await setBotActiveStatus({ + botId: bot._id, + isActive: bot.isActive ? false : true, + botKey + })).bot); + } + } catch (err) { + console.error(err); + } + } + + /** + * Start integration for a given integration option [integrationOption] + * @param {Object} obj + * @param {Object} obj.integrationOption - an integration option + * @param {String} obj.name + * @param {String} obj.type + * @param {String} obj.docsLink + * @returns + */ + const handleIntegrationOption = async ({ integrationOption }) => { + + try { + // generate CSRF token for OAuth2 code-token exchange integrations + const state = crypto.randomBytes(16).toString("hex"); + localStorage.setItem('latestCSRFToken', state); + + switch (integrationOption.name) { + case 'Heroku': + window.location = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`; + break; + case 'Vercel': + window.location = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`; + break; + case 'Netlify': + window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/netlify`; + break; + // case 'Fly.io': + // console.log('fly.io'); + // setIntegrationAccessTokenDialogOpen(true); + // break; + } + } catch (err) { + console.log(err); + } + } + + /** + * Open dialog to activate bot if bot is not active. + * Otherwise, start integration [integrationOption] + * @param {Object} integrationOption - an integration option + * @param {String} integrationOption.name + * @param {String} integrationOption.type + * @param {String} integrationOption.docsLink + * @returns + */ + const integrationOptionPress = (integrationOption) => { + try { + if (bot.isActive) { + // case: bot is active -> proceed with integration + handleIntegrationOption({ integrationOption }); + return; + } + + // case: bot is not active -> open modal to activate bot + setIsActivateBotDialogOpen(true); + } catch (err) { + console.error(err); + } + } + + return (
Dashboard @@ -205,200 +185,41 @@ export default function Integrations() { content="Infisical a simple end-to-end encrypted platform that enables teams to sync and manage their .env files." /> -
-
- -
-
-

Current Project Integrations

-
-

- Manage your integrations of Infisical with third-party services. -

-
- {projectIntegrations.length > 0 ? ( - projectIntegrations.map((projectIntegration) => ( - - )) - ) : ( -
-
-
- You {"don't"} have any integrations set up yet. When you do, - they will appear here. -
-
- To start, click on any of the options below. It takes 5 clicks - to set up. -
-
-
- )} -
-
-

- Platform & Cloud Integrations -

-
-

- Click on the itegration you want to connect. This will let your - environment variables flow automatically into selected third-party - services. -

-

- Note: during an integration with Heroku, for security reasons, it - is impossible to maintain end-to-end encryption. In theory, this - lets Infisical decrypt yor environment variables. In practice, we - can assure you that this will never be done, and it allows us to - protect your secrets from bad actors online. The core Infisical - service will always stay end-to-end encrypted. With any questions, - reach out support@infisical.com. -

-
-
- {Object.keys(integrations).map((integration) => ( -
- - integration logo - {integrations[integration].name.split(' ').length > 2 ? ( -
-
{integrations[integration].name.split(' ')[0]}
-
- {integrations[integration].name.split(' ')[1]}{' '} - {integrations[integration].name.split(' ')[2]} -
-
- ) : ( -
- {integrations[integration].name} -
- )} -
- {['Heroku'].includes(integrations[integration].name) && - authorizations - .map((authorization) => authorization.integration) - .includes(integrations[integration].name.toLowerCase()) && ( -
-
{ - deleteIntegrationAuth({ - integrationAuthId: authorizations - .filter( - (authorization) => - authorization.integration == - integrations[integration].name.toLowerCase() - ) - .map((authorization) => authorization._id)[0] - }); - router.reload(); - }} - className="cursor-pointer w-max bg-red py-0.5 px-2 rounded-b-md text-xs flex flex-row items-center opacity-0 group-hover:opacity-100 duration-200" - > - - Revoke -
-
- - Authorized -
-
- )} - {!['Heroku'].includes(integrations[integration].name) && ( -
-
- Coming Soon -
-
- )} -
- ))} -
-
-
-

Framework Integrations

-
-

- Click on a framework to get the setup instructions. -

-
- -
+
+ + setIsActivateBotDialogOpen(false)} + selectedIntegrationOption={selectedIntegrationOption} + handleBotActivate={handleBotActivate} + handleIntegrationOption={handleIntegrationOption} + /> + {/* setIntegrationAccessTokenDialogOpen(false)} + selectedIntegrationOption={selectedIntegrationOption} + handleBotActivate={handleBotActivate} + handleIntegrationOption={handleIntegrationOption} + /> */} + + {cloudIntegrationOptions.length > 0 ? ( + + ) : ( +
+ )} +
- ) : ( -
-
- loading animation -
); } diff --git a/frontend/pages/netlify.js b/frontend/pages/netlify.js new file mode 100644 index 000000000..6907d6db4 --- /dev/null +++ b/frontend/pages/netlify.js @@ -0,0 +1,41 @@ +import React, { useEffect } from "react"; +import Head from "next/head"; +import { useRouter } from "next/router"; +const queryString = require("query-string"); +import AuthorizeIntegration from "./api/integrations/authorizeIntegration"; + +export default function Netlify() { + const router = useRouter(); + const parsedUrl = queryString.parse(router.asPath.split("?")[1]); + const code = parsedUrl.code; + const state = parsedUrl.state; + // modify comment here + + /** + * Here we forward to the default workspace if a user opens this url + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(async () => { + try { + if (state === localStorage.getItem('latestCSRFToken')) { + localStorage.removeItem('latestCSRFToken'); + + await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id'), + code, + integration: "netlify" + }); + + router.push("/integrations/" + localStorage.getItem("projectData.id")); + } + } catch (err) { + console.error('Netlify integration error: ', err); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return
; +} + +Netlify.requireAuth = true; diff --git a/frontend/pages/vercel.js b/frontend/pages/vercel.js new file mode 100644 index 000000000..adfffe77e --- /dev/null +++ b/frontend/pages/vercel.js @@ -0,0 +1,40 @@ +import React, { useEffect } from "react"; +import Head from "next/head"; +import { useRouter } from "next/router"; +const queryString = require("query-string"); +import AuthorizeIntegration from "./api/integrations/authorizeIntegration"; + +export default function Vercel() { + const router = useRouter(); + const parsedUrl = queryString.parse(router.asPath.split("?")[1]); + const code = parsedUrl.code; + const state = parsedUrl.state + + /** + * Here we forward to the default workspace if a user opens this url + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(async () => { + try { + if (state === localStorage.getItem('latestCSRFToken')) { + localStorage.removeItem('latestCSRFToken'); + + await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id'), + code, + integration: "vercel" + }); + + router.push("/integrations/" + localStorage.getItem("projectData.id")); + } + } catch (err) { + console.error('Vercel integration error: ', err); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return
; +} + +Vercel.requireAuth = true; diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index fbc0034d3..d2beee6d2 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -1,82 +1,37 @@ -const envMapping = { +interface Mapping { + [key: string]: string; +} + +const envMapping: Mapping = { Development: "dev", Staging: "staging", Production: "prod", Testing: "test", }; - -const reverseEnvMapping = { + +const reverseEnvMapping: Mapping = { dev: "Development", staging: "Staging", prod: "Production", test: "Testing", }; -const frameworks = [{ - "name": "Docker", - "image": "Docker", - "link": "https://infisical.com/docs/integrations/platforms/docker" - }, { - "name": "Docker Compose", - "image": "Docker Compose", - "link": "https://infisical.com/docs/integrations/platforms/docker-compose" - }, { - "name": "React", - "image": "React", - "link": "https://infisical.com/docs/integrations/frameworks/react" - }, { - "name": "Vue", - "image": "Vue", - "link": "https://infisical.com/docs/integrations/frameworks/vue" - }, { - "image": "Express", - "link": "https://infisical.com/docs/integrations/frameworks/express" - },{ - "image": "Next.js", - "link": "https://infisical.com/docs/integrations/frameworks/nextjs" - }, { - "name": "Django", - "image": "Django", - "link": "https://infisical.com/docs/integrations/frameworks/django" - }, { - "name": "NestJS", - "image": "NestJS", - "link": "https://infisical.com/docs/integrations/frameworks/nestjs" - }, { - "name": "Nuxt", - "image": "Nuxt", - "link": "https://infisical.com/docs/integrations/frameworks/nuxt" - }, { - "name": "Gatsby", - "image": "Gatsby", - "link": "https://infisical.com/docs/integrations/frameworks/gatsby" - }, { - "name": "Remix", - "image": "Remix", - "link": "https://infisical.com/docs/integrations/frameworks/remix" - }, { - "name": "Vite", - "image": "Vite", - "link": "https://infisical.com/docs/integrations/frameworks/vite" - }, { - "image": "Fiber", - "link": "https://infisical.com/docs/integrations/frameworks/fiber" - }, { - "name": "Flask", - "image": "Flask", - "link": "https://infisical.com/docs/integrations/frameworks/flask" - }, { - "name": "Laravel", - "image": "Laravel", - "link": "https://infisical.com/docs/integrations/frameworks/laravel" - }, { - "image": "Rails", - "link": "https://infisical.com/docs/integrations/frameworks/rails" - } -] +const contextNetlifyMapping: Mapping = { + "dev": "Local development", + "branch-deploy": "Branch deploys", + "deploy-review": "Deploy Previews", + "production": "Production" +} + +const reverseContextNetlifyMapping: Mapping = { + "Local development": "dev", + "Branch deploys": "branch-deploy", + "Deploy Previews": "deploy-preview", + "Production": "production" +} export { + contextNetlifyMapping, envMapping, - frameworks, - reverseEnvMapping -}; + reverseContextNetlifyMapping, + reverseEnvMapping} diff --git a/frontend/public/images/integrations/Vercel.png b/frontend/public/images/integrations/Vercel.png new file mode 100644 index 000000000..7bdcd2a19 Binary files /dev/null and b/frontend/public/images/integrations/Vercel.png differ diff --git a/frontend/public/json/frameworkIntegrations.json b/frontend/public/json/frameworkIntegrations.json new file mode 100644 index 000000000..fb8b30c9e --- /dev/null +++ b/frontend/public/json/frameworkIntegrations.json @@ -0,0 +1,98 @@ +[ + { + "name": "Docker", + "slug": "docker", + "image": "Docker", + "docsLink": "https://infisical.com/docs/integrations/platforms/docker" + }, + { + "name": "Docker Compose", + "slug": "docker-compose", + "image": "Docker Compose", + "docsLink": "https://infisical.com/docs/integrations/platforms/docker-compose" + }, + { + "name": "React", + "slug": "react", + "image": "React", + "docsLink": "https://infisical.com/docs/integrations/frameworks/react" + }, + { + "name": "Vue", + "slug": "vue", + "image": "Vue", + "docsLink": "https://infisical.com/docs/integrations/frameworks/vue" + }, + { + "name": "Express", + "slug": "express", + "image": "Express", + "docsLink": "https://infisical.com/docs/integrations/frameworks/express" + }, + { + "name": "Next.js", + "slug": "nextjs", + "image": "Next.js", + "docsLink": "https://infisical.com/docs/integrations/frameworks/nextjs" + }, + { + "name": "Django", + "slug": "django", + "image": "Django", + "docsLink": "https://infisical.com/docs/integrations/frameworks/django" + }, + { + "name": "NestJS", + "slug": "nestjs", + "image": "NestJS", + "docsLink": "https://infisical.com/docs/integrations/frameworks/nestjs" + }, + { + "name": "Nuxt", + "slug": "nuxt", + "image": "Nuxt", + "docsLink": "https://infisical.com/docs/integrations/frameworks/nuxt" + }, + { + "name": "Gatsby", + "slug": "gatsby", + "image": "Gatsby", + "docsLink": "https://infisical.com/docs/integrations/frameworks/gatsby" + }, + { + "name": "Remix", + "slug": "remix", + "image": "Remix", + "docsLink": "https://infisical.com/docs/integrations/frameworks/remix" + }, + { + "name": "Vite", + "slug": "vite", + "image": "Vite", + "docsLink": "https://infisical.com/docs/integrations/frameworks/vite" + }, + { + "name": "Fiber", + "slug": "fiber", + "image": "Fiber", + "docsLink": "https://infisical.com/docs/integrations/frameworks/fiber" + }, + { + "name": "Flask", + "slug": "flask", + "image": "Flask", + "docsLink": "https://infisical.com/docs/integrations/frameworks/flask" + }, + { + "name": "Laravel", + "slug": "laravel", + "image": "Laravel", + "docsLink": "https://infisical.com/docs/integrations/frameworks/laravel" + }, + { + "name": "Rails", + "slug": "rails", + "image": "Rails", + "docsLink": "https://infisical.com/docs/integrations/frameworks/rails" + } +] \ No newline at end of file diff --git a/helm-charts/infisical/Chart.yaml b/helm-charts/infisical/Chart.yaml index a9297b385..961a13f76 100644 --- a/helm-charts/infisical/Chart.yaml +++ b/helm-charts/infisical/Chart.yaml @@ -7,7 +7,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.3 +version: 0.1.5 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to diff --git a/helm-charts/infisical/templates/backend-deployment.yaml b/helm-charts/infisical/templates/backend-deployment.yaml index 6a2faa688..437995e58 100644 --- a/helm-charts/infisical/templates/backend-deployment.yaml +++ b/helm-charts/infisical/templates/backend-deployment.yaml @@ -20,6 +20,11 @@ spec: imagePullPolicy: {{ .Values.backend.image.pullPolicy }} ports: - containerPort: 4000 + {{- if .Values.backend.kubeSecretRef }} + envFrom: + - secretRef: + name: {{ .Values.backend.kubeSecretRef }} + {{- end }} env: {{- range $key, $value := .Values.backendEnvironmentVariables }} {{- if $value | quote | eq "MUST_REPLACE" }} diff --git a/helm-charts/infisical/templates/frontend-deployment.yaml b/helm-charts/infisical/templates/frontend-deployment.yaml index 3cd29f326..3930fda2b 100644 --- a/helm-charts/infisical/templates/frontend-deployment.yaml +++ b/helm-charts/infisical/templates/frontend-deployment.yaml @@ -18,6 +18,12 @@ spec: - name: frontend image: infisical/frontend imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + {{- if .Values.frontend.kubeSecretRef }} + envFrom: + - secretRef: + name: {{ .Values.frontend.kubeSecretRef }} + {{- end }} + {{- if .Values.frontendEnvironmentVariables }} env: {{- range $key, $value := .Values.frontendEnvironmentVariables }} {{- if $value | quote | eq "MUST_REPLACE" }} @@ -26,6 +32,7 @@ spec: - name: {{ $key }} value: {{ quote $value }} {{- end }} + {{- end }} ports: - containerPort: 4000 --- diff --git a/helm-charts/infisical/values.yaml b/helm-charts/infisical/values.yaml index a93a4769d..b78543774 100644 --- a/helm-charts/infisical/values.yaml +++ b/helm-charts/infisical/values.yaml @@ -3,14 +3,14 @@ # PLEASE REPLACE VALUES/EDIT AS REQUIRED ##### -namespace: infisical - frontend: replicaCount: 1 image: repository: pullPolicy: IfNotPresent tag: "latest" + # kubeSecretRef: some-kube-secret-name + backend: replicaCount: 1 @@ -18,6 +18,7 @@ backend: repository: pullPolicy: IfNotPresent tag: "latest" + # kubeSecretRef: some-kube-secret-name ingress: enabled: true @@ -54,8 +55,6 @@ ingress: ### backendEnvironmentVariables: # Required keys for platform encryption/decryption ops. Replace with nacl sk keys - PRIVATE_KEY: MUST_REPLACE - PUBLIC_KEY: MUST_REPLACE ENCRYPTION_KEY: MUST_REPLACE # JWT @@ -71,9 +70,8 @@ backendEnvironmentVariables: SMTP_USERNAME: MUST_REPLACE SMTP_PASSWORD: MUST_REPLACE - # You may replace with Mongo Cloud URI + # Recommended to replace with Mongo Cloud URI as the DB instance in the cluster does not have persistence yet MONGO_URL: mongodb://root:root@mongodb-service:27017/ # frontendEnvironmentVariables: -# INFISICAL_TELEMETRY_ENABLED: true \ No newline at end of file diff --git a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml index 8628a57d9..c5b82fa14 100644 --- a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml +++ b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml @@ -38,19 +38,11 @@ spec: environment: description: The Infisical environment such as dev, prod, testing type: string - infisicalToken: - properties: - secretName: - description: The name of the Kubernetes Secret - type: string - secretNamespace: - description: The name space where the Kubernetes Secret is located - type: string - required: - - secretName - - secretNamespace - type: object - managedSecret: + hostAPI: + default: https://app.infisical.com/api + description: Infisical host to pull secrets from + type: string + managedSecretReference: properties: secretName: description: The name of the Kubernetes Secret @@ -65,6 +57,18 @@ spec: projectId: description: The Infisical project id type: string + tokenSecretReference: + properties: + secretName: + description: The name of the Kubernetes Secret + type: string + secretNamespace: + description: The name space where the Kubernetes Secret is located + type: string + required: + - secretName + - secretNamespace + type: object required: - environment - projectId diff --git a/k8-operator/Makefile b/k8-operator/Makefile index 541e26b60..a8b056376 100644 --- a/k8-operator/Makefile +++ b/k8-operator/Makefile @@ -1,6 +1,6 @@ # Image URL to use all building/pushing image targets -IMG ?= controller:latest +IMG ?= infisical/kubernetes-operator:latest # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. ENVTEST_K8S_VERSION = 1.25.0 @@ -41,6 +41,12 @@ help: ## Display this help. helm-chart: $(KUSTOMIZE) build config/default | helmify ../helm-charts/secrets-operator +## Yaml for Kubectl +kubectl-install: manifests kustomize + mkdir -p kubectl-install + cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} + $(KUSTOMIZE) build config/default > kubectl-install/install-secrets-operator.yaml + ##@ Development .PHONY: manifests diff --git a/k8-operator/README.md b/k8-operator/README.md index 6d953997e..807476c0c 100644 --- a/k8-operator/README.md +++ b/k8-operator/README.md @@ -1,8 +1,8 @@ # k8-operator -// TODO(user): Add simple overview of use/purpose +// TODO ## Description -// TODO(user): An in-depth paragraph about your project and overview of use +// TODO ## Getting Started You’ll need a Kubernetes cluster to run against. You can use [KIND](https://sigs.k8s.io/kind) to get a local cluster for testing, or run against a remote cluster. @@ -42,7 +42,7 @@ make undeploy ``` ## Contributing -// TODO(user): Add detailed information on how you would like others to contribute to this project +// TODO ### How it works This project aims to follow the Kubernetes [Operator pattern](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/) @@ -76,19 +76,3 @@ make manifests More information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html) -## License - -Copyright 2022. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - diff --git a/k8-operator/api/v1alpha1/infisicalsecret_types.go b/k8-operator/api/v1alpha1/infisicalsecret_types.go index 74e3f9788..7940474f9 100644 --- a/k8-operator/api/v1alpha1/infisicalsecret_types.go +++ b/k8-operator/api/v1alpha1/infisicalsecret_types.go @@ -16,8 +16,8 @@ type KubeSecretReference struct { // InfisicalSecretSpec defines the desired state of InfisicalSecret type InfisicalSecretSpec struct { - InfisicalToken KubeSecretReference `json:"infisicalToken,omitempty"` - ManagedSecret KubeSecretReference `json:"managedSecret,omitempty"` + TokenSecretReference KubeSecretReference `json:"tokenSecretReference,omitempty"` + ManagedSecretReference KubeSecretReference `json:"managedSecretReference,omitempty"` // The Infisical project id // +kubebuilder:validation:Required @@ -26,6 +26,10 @@ type InfisicalSecretSpec struct { // The Infisical environment such as dev, prod, testing // +kubebuilder:validation:Required Environment string `json:"environment"` + + // Infisical host to pull secrets from + // +kubebuilder:default="https://app.infisical.com/api" + HostAPI string `json:"hostAPI,omitempty"` } // InfisicalSecretStatus defines the observed state of InfisicalSecret diff --git a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go index e6053ba74..af9eff318 100644 --- a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -88,8 +88,8 @@ func (in *InfisicalSecretList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InfisicalSecretSpec) DeepCopyInto(out *InfisicalSecretSpec) { *out = *in - out.InfisicalToken = in.InfisicalToken - out.ManagedSecret = in.ManagedSecret + out.TokenSecretReference = in.TokenSecretReference + out.ManagedSecretReference = in.ManagedSecretReference } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InfisicalSecretSpec. diff --git a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml index ca00676b4..c885fbdde 100644 --- a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml +++ b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml @@ -38,19 +38,11 @@ spec: environment: description: The Infisical environment such as dev, prod, testing type: string - infisicalToken: - properties: - secretName: - description: The name of the Kubernetes Secret - type: string - secretNamespace: - description: The name space where the Kubernetes Secret is located - type: string - required: - - secretName - - secretNamespace - type: object - managedSecret: + hostAPI: + default: https://app.infisical.com/api + description: Infisical host to pull secrets from + type: string + managedSecretReference: properties: secretName: description: The name of the Kubernetes Secret @@ -65,6 +57,18 @@ spec: projectId: description: The Infisical project id type: string + tokenSecretReference: + properties: + secretName: + description: The name of the Kubernetes Secret + type: string + secretNamespace: + description: The name space where the Kubernetes Secret is located + type: string + required: + - secretName + - secretNamespace + type: object required: - environment - projectId diff --git a/k8-operator/config/default/kustomization.yaml b/k8-operator/config/default/kustomization.yaml index 4cab9014c..1237b893d 100644 --- a/k8-operator/config/default/kustomization.yaml +++ b/k8-operator/config/default/kustomization.yaml @@ -1,12 +1,12 @@ # Adds namespace to all resources. -namespace: k8-operator-system +namespace: infisical-operator-system # Value of this field is prepended to the # names of all resources, e.g. a deployment named # "wordpress" becomes "alices-wordpress". # Note that it should also match with the prefix (text before '-') of the namespace # field above. -namePrefix: k8-operator- +namePrefix: infisical-operator- # Labels to add to all resources and selectors. #commonLabels: diff --git a/k8-operator/config/manager/kustomization.yaml b/k8-operator/config/manager/kustomization.yaml index 5c5f0b84c..96ea36924 100644 --- a/k8-operator/config/manager/kustomization.yaml +++ b/k8-operator/config/manager/kustomization.yaml @@ -1,2 +1,8 @@ resources: - manager.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +images: +- name: controller + newName: infisical/kubernetes-operator + newTag: latest diff --git a/k8-operator/config/samples/sample.yaml b/k8-operator/config/samples/sample.yaml new file mode 100644 index 000000000..ad352d3e7 --- /dev/null +++ b/k8-operator/config/samples/sample.yaml @@ -0,0 +1,13 @@ +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample +spec: + projectId: 62faf98ae0b05e8529b5da46 + environment: dev + tokenSecretReference: + secretName: service-token + secretNamespace: first-project + managedSecretReference: + secretName: managed-secret + secretNamespace: first-project diff --git a/k8-operator/config/samples/secrets_v1alpha1_infisicalsecret.yaml b/k8-operator/config/samples/secrets_v1alpha1_infisicalsecret.yaml deleted file mode 100644 index 7ab08e9cd..000000000 --- a/k8-operator/config/samples/secrets_v1alpha1_infisicalsecret.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: secrets.infisical.com/v1alpha1 -kind: InfisicalSecret -metadata: - labels: - app.kubernetes.io/name: infisicalsecret - app.kubernetes.io/instance: infisicalsecret-sample - app.kubernetes.io/part-of: k8-operator - app.kubernetes.io/managed-by: kustomize - app.kubernetes.io/created-by: k8-operator - name: infisicalsecret-sample -spec: - projectId: 62faf98ae0b05e8529b5da46 - environment: dev - infisicalToken: - secretName: service-token - secretNamespace: default - managedSecret: - secretName: managed-secret - secretNamespace: default diff --git a/k8-operator/controllers/infisicalsecret_controller.go b/k8-operator/controllers/infisicalsecret_controller.go index 297e29bfb..f6422e177 100644 --- a/k8-operator/controllers/infisicalsecret_controller.go +++ b/k8-operator/controllers/infisicalsecret_controller.go @@ -36,6 +36,8 @@ func (r *InfisicalSecretReconciler) Reconcile(ctx context.Context, req ctrl.Requ var infisicalSecretCR v1alpha1.InfisicalSecret err := r.Get(ctx, req.NamespacedName, &infisicalSecretCR) + requeueTime := time.Minute * 5 + if err != nil { if errors.IsNotFound(err) { log.Info("Infisical Secret not found") @@ -43,7 +45,7 @@ func (r *InfisicalSecretReconciler) Reconcile(ctx context.Context, req ctrl.Requ } else { log.Error(err, "Unable to fetch Infisical Secret from cluster. Will retry") return ctrl.Result{ - RequeueAfter: time.Minute, + RequeueAfter: requeueTime, }, nil } } @@ -58,13 +60,13 @@ func (r *InfisicalSecretReconciler) Reconcile(ctx context.Context, req ctrl.Requ if err != nil { log.Error(err, "Unable to reconcile Infisical Secret and will try again") return ctrl.Result{ - RequeueAfter: time.Minute, + RequeueAfter: requeueTime, }, nil } // Sync again after the specified time return ctrl.Result{ - RequeueAfter: time.Minute, + RequeueAfter: requeueTime, }, nil } diff --git a/k8-operator/controllers/infisicalsecret_helper.go b/k8-operator/controllers/infisicalsecret_helper.go index fbcacadea..be4e35dd5 100644 --- a/k8-operator/controllers/infisicalsecret_helper.go +++ b/k8-operator/controllers/infisicalsecret_helper.go @@ -29,12 +29,12 @@ func (r *InfisicalSecretReconciler) GetKubeSecretByNamespacedName(ctx context.Co func (r *InfisicalSecretReconciler) GetInfisicalToken(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret) (string, error) { tokenSecret, err := r.GetKubeSecretByNamespacedName(ctx, types.NamespacedName{ - Namespace: infisicalSecret.Spec.InfisicalToken.SecretNamespace, - Name: infisicalSecret.Spec.InfisicalToken.SecretName, + Namespace: infisicalSecret.Spec.TokenSecretReference.SecretNamespace, + Name: infisicalSecret.Spec.TokenSecretReference.SecretName, }) if err != nil { - return "", fmt.Errorf("failed to read Infisical token secret from secret named [%s] in namespace [%s]: with error [%w]", infisicalSecret.Spec.ManagedSecret.SecretName, infisicalSecret.Spec.ManagedSecret.SecretNamespace, err) + return "", fmt.Errorf("failed to read Infisical token secret from secret named [%s] in namespace [%s]: with error [%w]", infisicalSecret.Spec.ManagedSecretReference.SecretName, infisicalSecret.Spec.ManagedSecretReference.SecretNamespace, err) } infisicalServiceToken := tokenSecret.Data[INFISICAL_TOKEN_SECRET_KEY_NAME] @@ -54,8 +54,8 @@ func (r *InfisicalSecretReconciler) CreateInfisicalManagedKubeSecret(ctx context // create a new secret as specified by the managed secret spec of CRD newKubeSecretInstance := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: infisicalSecret.Spec.ManagedSecret.SecretName, - Namespace: infisicalSecret.Spec.ManagedSecret.SecretNamespace, + Name: infisicalSecret.Spec.ManagedSecretReference.SecretName, + Namespace: infisicalSecret.Spec.ManagedSecretReference.SecretNamespace, }, Type: "Opaque", Data: plainProcessedSecrets, @@ -94,15 +94,15 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context } managedKubeSecret, err := r.GetKubeSecretByNamespacedName(ctx, types.NamespacedName{ - Name: infisicalSecret.Spec.ManagedSecret.SecretName, - Namespace: infisicalSecret.Spec.ManagedSecret.SecretNamespace, + Name: infisicalSecret.Spec.ManagedSecretReference.SecretName, + Namespace: infisicalSecret.Spec.ManagedSecretReference.SecretNamespace, }) if err != nil && !errors.IsNotFound(err) { return fmt.Errorf("something went wrong when fetching the managed Kubernetes secret [%w]", err) } - secretsFromApi, err := api.GetAllEnvironmentVariables(infisicalSecret.Spec.ProjectId, infisicalSecret.Spec.Environment, infisicalToken) + secretsFromApi, err := api.GetAllEnvironmentVariables(infisicalSecret.Spec.ProjectId, infisicalSecret.Spec.Environment, infisicalToken, infisicalSecret.Spec.HostAPI) if err != nil { return err diff --git a/k8-operator/kubectl-install/install-secrets-operator.yaml b/k8-operator/kubectl-install/install-secrets-operator.yaml new file mode 100644 index 000000000..6a3cb8e6d --- /dev/null +++ b/k8-operator/kubectl-install/install-secrets-operator.yaml @@ -0,0 +1,475 @@ +apiVersion: v1 +kind: Namespace +metadata: + labels: + app.kubernetes.io/component: manager + app.kubernetes.io/created-by: k8-operator + app.kubernetes.io/instance: system + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: namespace + app.kubernetes.io/part-of: k8-operator + control-plane: controller-manager + name: infisical-operator-system +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: infisicalsecrets.secrets.infisical.com +spec: + group: secrets.infisical.com + names: + kind: InfisicalSecret + listKind: InfisicalSecretList + plural: infisicalsecrets + singular: infisicalsecret + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: InfisicalSecret is the Schema for the infisicalsecrets API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: InfisicalSecretSpec defines the desired state of InfisicalSecret + properties: + environment: + description: The Infisical environment such as dev, prod, testing + type: string + hostAPI: + default: https://app.infisical.com/api + description: Infisical host to pull secrets from + type: string + managedSecretReference: + properties: + secretName: + description: The name of the Kubernetes Secret + type: string + secretNamespace: + description: The name space where the Kubernetes Secret is located + type: string + required: + - secretName + - secretNamespace + type: object + projectId: + description: The Infisical project id + type: string + tokenSecretReference: + properties: + secretName: + description: The name of the Kubernetes Secret + type: string + secretNamespace: + description: The name space where the Kubernetes Secret is located + type: string + required: + - secretName + - secretNamespace + type: object + required: + - environment + - projectId + type: object + status: + description: InfisicalSecretStatus defines the observed state of InfisicalSecret + properties: + conditions: + items: + description: "Condition contains details for one aspect of the current state of this API Resource. --- This struct is intended for direct use as an array at the field path .status.conditions. For example, \n type FooStatus struct{ // Represents the observations of a foo's current state. // Known .status.conditions.type are: \"Available\", \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. --- Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be useful (see .node.status.conditions), the ability to deconflict is important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + required: + - conditions + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: k8-operator + app.kubernetes.io/instance: controller-manager + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: serviceaccount + app.kubernetes.io/part-of: k8-operator + name: infisical-operator-controller-manager + namespace: infisical-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: k8-operator + app.kubernetes.io/instance: leader-election-role + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: role + app.kubernetes.io/part-of: k8-operator + name: infisical-operator-leader-election-role + namespace: infisical-operator-system +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + name: infisical-operator-manager-role +rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - apps + resources: + - deployments + verbs: + - get + - list + - update + - watch +- apiGroups: + - secrets.infisical.com + resources: + - infisicalsecrets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - secrets.infisical.com + resources: + - infisicalsecrets/finalizers + verbs: + - update +- apiGroups: + - secrets.infisical.com + resources: + - infisicalsecrets/status + verbs: + - get + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: k8-operator + app.kubernetes.io/instance: metrics-reader + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: clusterrole + app.kubernetes.io/part-of: k8-operator + name: infisical-operator-metrics-reader +rules: +- nonResourceURLs: + - /metrics + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: k8-operator + app.kubernetes.io/instance: proxy-role + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: clusterrole + app.kubernetes.io/part-of: k8-operator + name: infisical-operator-proxy-role +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: k8-operator + app.kubernetes.io/instance: leader-election-rolebinding + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: rolebinding + app.kubernetes.io/part-of: k8-operator + name: infisical-operator-leader-election-rolebinding + namespace: infisical-operator-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: infisical-operator-leader-election-role +subjects: +- kind: ServiceAccount + name: infisical-operator-controller-manager + namespace: infisical-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: k8-operator + app.kubernetes.io/instance: manager-rolebinding + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: clusterrolebinding + app.kubernetes.io/part-of: k8-operator + name: infisical-operator-manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: infisical-operator-manager-role +subjects: +- kind: ServiceAccount + name: infisical-operator-controller-manager + namespace: infisical-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: k8-operator + app.kubernetes.io/instance: proxy-rolebinding + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: clusterrolebinding + app.kubernetes.io/part-of: k8-operator + name: infisical-operator-proxy-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: infisical-operator-proxy-role +subjects: +- kind: ServiceAccount + name: infisical-operator-controller-manager + namespace: infisical-operator-system +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: k8-operator + app.kubernetes.io/instance: controller-manager-metrics-service + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: service + app.kubernetes.io/part-of: k8-operator + control-plane: controller-manager + name: infisical-operator-controller-manager-metrics-service + namespace: infisical-operator-system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: https + selector: + control-plane: controller-manager +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/component: manager + app.kubernetes.io/created-by: k8-operator + app.kubernetes.io/instance: controller-manager + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: deployment + app.kubernetes.io/part-of: k8-operator + control-plane: controller-manager + name: infisical-operator-controller-manager + namespace: infisical-operator-system +spec: + replicas: 1 + selector: + matchLabels: + control-plane: controller-manager + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + control-plane: controller-manager + spec: + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: + - amd64 + - arm64 + - ppc64le + - s390x + - key: kubernetes.io/os + operator: In + values: + - linux + containers: + - args: + - --secure-listen-address=0.0.0.0:8443 + - --upstream=http://127.0.0.1:8080/ + - --logtostderr=true + - --v=0 + image: gcr.io/kubebuilder/kube-rbac-proxy:v0.13.1 + name: kube-rbac-proxy + ports: + - containerPort: 8443 + name: https + protocol: TCP + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 5m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + - args: + - --health-probe-bind-address=:8081 + - --metrics-bind-address=127.0.0.1:8080 + - --leader-elect + command: + - /manager + image: infisical/kubernetes-operator:latest + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + name: manager + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + securityContext: + runAsNonRoot: true + serviceAccountName: infisical-operator-controller-manager + terminationGracePeriodSeconds: 10 diff --git a/k8-operator/packages/api/api.go b/k8-operator/packages/api/api.go index ac2cc5b25..64f8add46 100644 --- a/k8-operator/packages/api/api.go +++ b/k8-operator/packages/api/api.go @@ -13,10 +13,8 @@ import ( "golang.org/x/crypto/nacl/box" ) -const INFISICAL_URL = "https://app.infisical.com/api" - -func GetAllEnvironmentVariables(projectId string, envName string, infisicalToken string) ([]models.SingleEnvironmentVariable, error) { - envsFromApi, err := GetSecretsFromAPIUsingInfisicalToken(infisicalToken, envName, projectId) +func GetAllEnvironmentVariables(projectId string, envName string, infisicalToken string, hostAPI string) ([]models.SingleEnvironmentVariable, error) { + envsFromApi, err := GetSecretsFromAPIUsingInfisicalToken(infisicalToken, envName, projectId, hostAPI) if err != nil { return nil, err } @@ -24,7 +22,7 @@ func GetAllEnvironmentVariables(projectId string, envName string, infisicalToken return SubstituteSecrets(envsFromApi), nil } -func GetSecretsFromAPIUsingInfisicalToken(infisicalToken string, envName string, projectId string) ([]models.SingleEnvironmentVariable, error) { +func GetSecretsFromAPIUsingInfisicalToken(infisicalToken string, envName string, projectId string, hostAPI string) ([]models.SingleEnvironmentVariable, error) { if infisicalToken == "" || projectId == "" || envName == "" { return nil, errors.New("infisical token, project id and or environment name cannot be empty") } @@ -44,7 +42,7 @@ func GetSecretsFromAPIUsingInfisicalToken(infisicalToken string, envName string, SetQueryParam("environment", envName). SetQueryParam("channel", "cli"). SetResult(&pullSecretsByInfisicalTokenResponse). - Get(fmt.Sprintf("%v/v1/secret/%v/service-token", INFISICAL_URL, projectId)) + Get(fmt.Sprintf("%v/v1/secret/%v/service-token", hostAPI, projectId)) if err != nil { return nil, err diff --git a/package-lock.json b/package-lock.json index 3a4157902..cf02a1760 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,7 @@ "license": "ISC", "devDependencies": { "eslint": "^8.29.0", - "husky": "^8.0.2", - "prettier": "^2.8.1" + "husky": "^8.0.2" } }, "node_modules/@eslint/eslintrc": { @@ -899,21 +898,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz", - "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==", - "dev": true, - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -1809,12 +1793,6 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true }, - "prettier": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz", - "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==", - "dev": true - }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", diff --git a/package.json b/package.json index b0d02f3a0..4d042a13d 100644 --- a/package.json +++ b/package.json @@ -14,15 +14,12 @@ "prepare": "husky install" }, "lint-staged": { - "**/*": "prettier --write --ignore-unknown", "*.{js,jsx,ts,tsx}": [ - "eslint --fix", - "prettier --write" + "eslint --fix" ] }, "devDependencies": { "eslint": "^8.29.0", - "husky": "^8.0.2", - "prettier": "^2.8.1" + "husky": "^8.0.2" } }