Merge branch 'main' into language-support-ptbr

This commit is contained in:
mv-turtle
2023-01-13 16:50:19 -08:00
committed by GitHub
186 changed files with 11293 additions and 4945 deletions

36
.github/values.yaml vendored Normal file
View File

@@ -0,0 +1,36 @@
frontend:
replicaCount: 1
image:
repository:
pullPolicy: Always
tag: "latest"
kubeSecretRef: managed-secret-frontend
backend:
replicaCount: 1
image:
repository:
pullPolicy: Always
tag: "latest"
kubeSecretRef: managed-backend-secret
ingress:
enabled: true
annotations:
kubernetes.io/ingress.class: "nginx"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
hostName: gamma.infisical.com
frontend:
path: /
pathType: Prefix
backend:
path: /api
pathType: Prefix
tls:
- secretName: echo-tls
hosts:
- gamma.infisical.com
backendEnvironmentVariables:
frontendEnvironmentVariables:

View File

@@ -1,5 +1,4 @@
name: Push to Docker Hub name: Build, Publish and Deploy to Gamma
on: [workflow_dispatch] on: [workflow_dispatch]
jobs: jobs:
@@ -10,8 +9,9 @@ jobs:
steps: steps:
- name: ☁️ Checkout source - name: ☁️ Checkout source
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: 🔧 Set up QEMU - name: Save commit hashes for tag
uses: docker/setup-qemu-action@v2 id: commit
uses: pr-mpt/actions-commit-hash@v2
- name: 🔧 Set up Docker Buildx - name: 🔧 Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: 🐋 Login to Docker Hub - name: 🐋 Login to Docker Hub
@@ -19,9 +19,13 @@ jobs:
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Depot CLI
uses: depot/setup-action@v1
- name: 📦 Build backend and export to Docker - name: 📦 Build backend and export to Docker
uses: docker/build-push-action@v3 uses: depot/build-push-action@v1
with: with:
project: 64mmf0n610
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
load: true load: true
context: backend context: backend
tags: infisical/backend:test tags: infisical/backend:test
@@ -35,11 +39,14 @@ jobs:
run: | run: |
docker compose -f .github/resources/docker-compose.be-test.yml down docker compose -f .github/resources/docker-compose.be-test.yml down
- name: 🏗️ Build backend and push - name: 🏗️ Build backend and push
uses: docker/build-push-action@v3 uses: depot/build-push-action@v1
with: with:
project: 64mmf0n610
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
push: true push: true
context: backend context: backend
tags: infisical/backend:latest tags: infisical/backend:${{ steps.commit.outputs.short }},
infisical/backend:latest
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
frontend-image: frontend-image:
@@ -49,8 +56,9 @@ jobs:
steps: steps:
- name: ☁️ Checkout source - name: ☁️ Checkout source
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: 🔧 Set up QEMU - name: Save commit hashes for tag
uses: docker/setup-qemu-action@v2 id: commit
uses: pr-mpt/actions-commit-hash@v2
- name: 🔧 Set up Docker Buildx - name: 🔧 Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: 🐋 Login to Docker Hub - name: 🐋 Login to Docker Hub
@@ -58,10 +66,14 @@ jobs:
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Depot CLI
uses: depot/setup-action@v1
- name: 📦 Build frontend and export to Docker - name: 📦 Build frontend and export to Docker
uses: docker/build-push-action@v3 uses: depot/build-push-action@v1
with: with:
load: true load: true
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
project: 64mmf0n610
context: frontend context: frontend
tags: infisical/frontend:test tags: infisical/frontend:test
build-args: | build-args: |
@@ -76,11 +88,45 @@ jobs:
run: | run: |
docker stop infisical-frontend-test docker stop infisical-frontend-test
- name: 🏗️ Build frontend and push - name: 🏗️ Build frontend and push
uses: docker/build-push-action@v3 uses: depot/build-push-action@v1
with: with:
project: 64mmf0n610
push: true push: true
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: frontend context: frontend
tags: infisical/frontend:latest tags: infisical/frontend:${{ steps.commit.outputs.short }},
infisical/frontend:latest
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
build-args: | build-args: |
POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }}
gamma-deployment:
name: Deploy to gamma
runs-on: ubuntu-latest
needs: [frontend-image, backend-image]
steps:
- name: ☁️ Checkout source
uses: actions/checkout@v3
- name: Install Helm
uses: azure/setup-helm@v3
with:
version: v3.10.0
- name: Install infisical helm chart
run: |
helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/'
helm repo update
- name: Install kubectl
uses: azure/setup-kubectl@v3
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
- name: Save DigitalOcean kubeconfig with short-lived credentials
run: doctl kubernetes cluster kubeconfig save --expiry-seconds 600 k8s-1-25-4-do-0-nyc1-1670645170179
- name: switch to gamma namespace
run: kubectl config set-context --current --namespace=gamma
- name: test kubectl
run: kubectl get ingress
- name: Download helm values to file and upgrade gamma deploy
run: |
wget https://raw.githubusercontent.com/Infisical/infisical/main/.github/values.yaml
helm upgrade infisical infisical-helm-charts/infisical --values values.yaml --recreate-pods

View File

@@ -1,4 +1,4 @@
name: Go releaser name: Build and release CLI
on: on:
push: push:

View File

@@ -7,6 +7,9 @@ push:
up-dev: up-dev:
docker-compose -f docker-compose.dev.yml up --build docker-compose -f docker-compose.dev.yml up --build
i-dev:
infisical export && infisical export > .env && docker-compose -f docker-compose.dev.yml up --build
up-prod: up-prod:
docker-compose -f docker-compose.yml up --build docker-compose -f docker-compose.yml up --build

View File

@@ -333,10 +333,10 @@ Infisical officially launched as v.1.0 on November 21st, 2022. There are a lot o
<!-- prettier-ignore-start --> <!-- prettier-ignore-start -->
<!-- markdownlint-disable --> <!-- markdownlint-disable -->
<a href="https://github.com/dangtony98"><img src="https://avatars.githubusercontent.com/u/25857006?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/mv-turtle"><img src="https://avatars.githubusercontent.com/u/78047717?s=96&v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/maidul98"><img src="https://avatars.githubusercontent.com/u/9300960?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/gangjun06"><img src="https://avatars.githubusercontent.com/u/50910815?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/reginaldbondoc"><img src="https://avatars.githubusercontent.com/u/7693108?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/SH5H"><img src="https://avatars.githubusercontent.com/u/25437192?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/gmgale"><img src="https://avatars.githubusercontent.com/u/62303146?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/asharonbaltazar"><img src="https://avatars.githubusercontent.com/u/58940073?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/jon4hz"><img src="https://avatars.githubusercontent.com/u/26183582?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/edgarrmondragon"><img src="https://avatars.githubusercontent.com/u/16805946?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/arjunyel"><img src="https://avatars.githubusercontent.com/u/11153289?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/LemmyMwaura"><img src="https://avatars.githubusercontent.com/u/20738858?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/Zamion101"><img src="https://avatars.githubusercontent.com/u/8071263?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/akhilmhdh"><img src="https://avatars.githubusercontent.com/u/31166322?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/naorpeled"><img src="https://avatars.githubusercontent.com/u/6171622?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/jonerrr"><img src="https://avatars.githubusercontent.com/u/73760377?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/adrianmarinwork"><img src="https://avatars.githubusercontent.com/u/118568289?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/arthurzenika"><img src="https://avatars.githubusercontent.com/u/445200?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/hanywang2"><img src="https://avatars.githubusercontent.com/u/44352119?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/tobias-mintlify"><img src="https://avatars.githubusercontent.com/u/110702161?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/wjhurley"><img src="https://avatars.githubusercontent.com/u/15939055?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/0xflotus"><img src="https://avatars.githubusercontent.com/u/26602940?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/wanjohiryan"><img src="https://avatars.githubusercontent.com/u/71614375?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/dangtony98"><img src="https://avatars.githubusercontent.com/u/25857006?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/mv-turtle"><img src="https://avatars.githubusercontent.com/u/78047717?s=96&v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/maidul98"><img src="https://avatars.githubusercontent.com/u/9300960?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/gangjun06"><img src="https://avatars.githubusercontent.com/u/50910815?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/reginaldbondoc"><img src="https://avatars.githubusercontent.com/u/7693108?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/SH5H"><img src="https://avatars.githubusercontent.com/u/25437192?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/gmgale"><img src="https://avatars.githubusercontent.com/u/62303146?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/asharonbaltazar"><img src="https://avatars.githubusercontent.com/u/58940073?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/JoaoVictor6"><img src="https://avatars.githubusercontent.com/u/68869379?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/mocherfaoui"><img src="https://avatars.githubusercontent.com/u/37941426?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/jon4hz"><img src="https://avatars.githubusercontent.com/u/26183582?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/edgarrmondragon"><img src="https://avatars.githubusercontent.com/u/16805946?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/arjunyel"><img src="https://avatars.githubusercontent.com/u/11153289?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/LemmyMwaura"><img src="https://avatars.githubusercontent.com/u/20738858?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/Zamion101"><img src="https://avatars.githubusercontent.com/u/8071263?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/akhilmhdh"><img src="https://avatars.githubusercontent.com/u/31166322?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/naorpeled"><img src="https://avatars.githubusercontent.com/u/6171622?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/jonerrr"><img src="https://avatars.githubusercontent.com/u/73760377?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/adrianmarinwork"><img src="https://avatars.githubusercontent.com/u/118568289?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/arthurzenika"><img src="https://avatars.githubusercontent.com/u/445200?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/hanywang2"><img src="https://avatars.githubusercontent.com/u/44352119?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/tobias-mintlify"><img src="https://avatars.githubusercontent.com/u/110702161?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/wjhurley"><img src="https://avatars.githubusercontent.com/u/15939055?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/0xflotus"><img src="https://avatars.githubusercontent.com/u/26602940?v=4" width="50" height="50" alt=""/></a> <a href="https://github.com/wanjohiryan"><img src="https://avatars.githubusercontent.com/u/71614375?v=4" width="50" height="50" alt=""/></a>
## 🌎 Translations ## 🌎 Translations
Infisical is currently aviable in English and Korean. Help us translate Infisical to your language! Infisical is currently available in English and Korean. Help us translate Infisical to your language!
You can find all the info in [this issue](https://github.com/Infisical/infisical/issues/181). You can find all the info in [this issue](https://github.com/Infisical/infisical/issues/181).

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,8 @@
"query-string": "^7.1.3", "query-string": "^7.1.3",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"stripe": "^10.7.0", "stripe": "^10.7.0",
"swagger-autogen": "^2.22.0",
"swagger-ui-express": "^4.6.0",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1", "tweetnacl-util": "^0.15.1",
"typescript": "^4.9.3", "typescript": "^4.9.3",
@@ -4549,7 +4551,6 @@
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
"dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -6690,7 +6691,6 @@
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"bin": { "bin": {
"json5": "lib/cli.js" "json5": "lib/cli.js"
}, },
@@ -11174,6 +11174,47 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/swagger-autogen": {
"version": "2.22.0",
"resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.22.0.tgz",
"integrity": "sha512-MPdtwgx/RL3og0RjFVV9hPoQv3x+c3ZRhS0Vjp9k94DLV7iUgIuCg8H+uAT8oD5w48ATTRT1VjcOHlCGH62pdA==",
"dependencies": {
"acorn": "^7.4.1",
"deepmerge": "^4.2.2",
"glob": "^7.1.7",
"json5": "^2.2.1"
}
},
"node_modules/swagger-autogen/node_modules/acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/swagger-ui-dist": {
"version": "4.15.5",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.15.5.tgz",
"integrity": "sha512-V3eIa28lwB6gg7/wfNvAbjwJYmDXy1Jo1POjyTzlB6wPcHiGlRxq39TSjYGVjQrUSAzpv+a7nzp7mDxgNy57xA=="
},
"node_modules/swagger-ui-express": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.6.0.tgz",
"integrity": "sha512-ZxpQFp1JR2RF8Ar++CyJzEDdvufa08ujNUJgMVTMWPi86CuQeVdBtvaeO/ysrz6dJAYXf9kbVNhWD7JWocwqsA==",
"dependencies": {
"swagger-ui-dist": ">=4.11.0"
},
"engines": {
"node": ">= v0.10.32"
},
"peerDependencies": {
"express": ">=4.0.0"
}
},
"node_modules/tar": { "node_modules/tar": {
"version": "6.1.13", "version": "6.1.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz",
@@ -15582,8 +15623,7 @@
"deepmerge": { "deepmerge": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg=="
"dev": true
}, },
"delayed-stream": { "delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
@@ -17200,8 +17240,7 @@
"json5": { "json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="
"dev": true
}, },
"jsonwebtoken": { "jsonwebtoken": {
"version": "9.0.0", "version": "9.0.0",
@@ -20424,6 +20463,37 @@
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true "dev": true
}, },
"swagger-autogen": {
"version": "2.22.0",
"resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.22.0.tgz",
"integrity": "sha512-MPdtwgx/RL3og0RjFVV9hPoQv3x+c3ZRhS0Vjp9k94DLV7iUgIuCg8H+uAT8oD5w48ATTRT1VjcOHlCGH62pdA==",
"requires": {
"acorn": "^7.4.1",
"deepmerge": "^4.2.2",
"glob": "^7.1.7",
"json5": "^2.2.1"
},
"dependencies": {
"acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="
}
}
},
"swagger-ui-dist": {
"version": "4.15.5",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.15.5.tgz",
"integrity": "sha512-V3eIa28lwB6gg7/wfNvAbjwJYmDXy1Jo1POjyTzlB6wPcHiGlRxq39TSjYGVjQrUSAzpv+a7nzp7mDxgNy57xA=="
},
"swagger-ui-express": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.6.0.tgz",
"integrity": "sha512-ZxpQFp1JR2RF8Ar++CyJzEDdvufa08ujNUJgMVTMWPi86CuQeVdBtvaeO/ysrz6dJAYXf9kbVNhWD7JWocwqsA==",
"requires": {
"swagger-ui-dist": ">=4.11.0"
}
},
"tar": { "tar": {
"version": "6.1.13", "version": "6.1.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz",

View File

@@ -1,46 +1,11 @@
{ {
"dependencies": {
"@godaddy/terminus": "^4.11.2",
"@octokit/rest": "^19.0.5",
"@sentry/node": "^7.14.0",
"@sentry/tracing": "^7.19.0",
"@types/crypto-js": "^4.1.1",
"@types/libsodium-wrappers": "^0.7.10",
"await-to-js": "^3.0.0",
"axios": "^1.1.3",
"bcrypt": "^5.1.0",
"bigint-conversion": "^2.2.2",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"crypto-js": "^4.1.1",
"dotenv": "^16.0.1",
"express": "^4.18.1",
"express-rate-limit": "^6.7.0",
"express-validator": "^6.14.2",
"handlebars": "^4.7.7",
"helmet": "^5.1.1",
"jsonwebtoken": "^9.0.0",
"jsrp": "^0.2.4",
"libsodium-wrappers": "^0.7.10",
"mongoose": "^6.7.2",
"nodemailer": "^6.8.0",
"posthog-node": "^2.2.2",
"query-string": "^7.1.3",
"rimraf": "^3.0.2",
"stripe": "^10.7.0",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"typescript": "^4.9.3",
"utility-types": "^3.10.0",
"winston": "^3.8.2",
"winston-loki": "^6.0.6"
},
"name": "infisical-api", "name": "infisical-api",
"version": "1.0.0", "version": "1.0.0",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "npm run build && node build/index.js", "start": "npm run build && node build/index.js",
"dev": "nodemon", "dev": "nodemon",
"swagger-autogen": "node ./swagger.ts",
"build": "rimraf ./build && tsc && cp -R ./src/templates ./build", "build": "rimraf ./build && tsc && cp -R ./src/templates ./build",
"lint": "eslint . --ext .ts", "lint": "eslint . --ext .ts",
"lint-and-fix": "eslint . --ext .ts --fix", "lint-and-fix": "eslint . --ext .ts --fix",
@@ -108,5 +73,43 @@
"suiteNameTemplate": "{filepath}", "suiteNameTemplate": "{filepath}",
"classNameTemplate": "{classname}", "classNameTemplate": "{classname}",
"titleTemplate": "{title}" "titleTemplate": "{title}"
},
"dependencies": {
"@godaddy/terminus": "^4.11.2",
"@octokit/rest": "^19.0.5",
"@sentry/node": "^7.14.0",
"@sentry/tracing": "^7.19.0",
"@types/crypto-js": "^4.1.1",
"@types/libsodium-wrappers": "^0.7.10",
"await-to-js": "^3.0.0",
"axios": "^1.1.3",
"bcrypt": "^5.1.0",
"bigint-conversion": "^2.2.2",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"crypto-js": "^4.1.1",
"dotenv": "^16.0.1",
"express": "^4.18.1",
"express-rate-limit": "^6.7.0",
"express-validator": "^6.14.2",
"handlebars": "^4.7.7",
"helmet": "^5.1.1",
"jsonwebtoken": "^9.0.0",
"jsrp": "^0.2.4",
"libsodium-wrappers": "^0.7.10",
"mongoose": "^6.7.2",
"nodemailer": "^6.8.0",
"posthog-node": "^2.2.2",
"query-string": "^7.1.3",
"rimraf": "^3.0.2",
"stripe": "^10.7.0",
"swagger-autogen": "^2.22.0",
"swagger-ui-express": "^4.6.0",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"typescript": "^4.9.3",
"utility-types": "^3.10.0",
"winston": "^3.8.2",
"winston-loki": "^6.0.6"
} }
} }

View File

@@ -1,11 +1,15 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const { patchRouterParam } = require('./utils/patchAsyncRoutes'); const { patchRouterParam } = require('./utils/patchAsyncRoutes');
import express from 'express'; import express, { Request, Response } from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
import cors from 'cors'; import cors from 'cors';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import swaggerUi = require('swagger-ui-express');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const swaggerFile = require('../api-documentation.json')
dotenv.config(); dotenv.config();
import { PORT, NODE_ENV, SITE_URL } from './config'; import { PORT, NODE_ENV, SITE_URL } from './config';
@@ -38,11 +42,15 @@ import {
} from './routes/v1'; } from './routes/v1';
import { import {
secret as v2SecretRouter, secret as v2SecretRouter,
secrets as v2SecretsRouter,
workspace as v2WorkspaceRouter, workspace as v2WorkspaceRouter,
serviceTokenData as v2ServiceTokenDataRouter, serviceTokenData as v2ServiceTokenDataRouter,
apiKeyData as v2APIKeyDataRouter, apiKeyData as v2APIKeyDataRouter,
environment as v2EnvironmentRouter,
} from './routes/v2'; } from './routes/v2';
import { healthCheck } from './routes/status';
import { getLogger } from './utils/logger'; import { getLogger } from './utils/logger';
import { RouteNotFoundError } from './utils/errors'; import { RouteNotFoundError } from './utils/errors';
import { requestErrorHandler } from './middleware/requestErrorHandler'; import { requestErrorHandler } from './middleware/requestErrorHandler';
@@ -89,18 +97,26 @@ app.use('/api/v1/membership', v1MembershipRouter);
app.use('/api/v1/key', v1KeyRouter); app.use('/api/v1/key', v1KeyRouter);
app.use('/api/v1/invite-org', v1InviteOrgRouter); app.use('/api/v1/invite-org', v1InviteOrgRouter);
app.use('/api/v1/secret', v1SecretRouter); app.use('/api/v1/secret', v1SecretRouter);
app.use('/api/v1/service-token', v1ServiceTokenRouter); // deprecate app.use('/api/v1/service-token', v1ServiceTokenRouter); // stop supporting
app.use('/api/v1/password', v1PasswordRouter); app.use('/api/v1/password', v1PasswordRouter);
app.use('/api/v1/stripe', v1StripeRouter); app.use('/api/v1/stripe', v1StripeRouter);
app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration', v1IntegrationRouter);
app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter);
// v2 routes // v2 routes
app.use('/api/v2/workspace', v2WorkspaceRouter); app.use('/api/v2/workspace', v2EnvironmentRouter);
app.use('/api/v2/secret', v2SecretRouter); app.use('/api/v2/workspace', v2WorkspaceRouter); // TODO: turn into plural route
app.use('/api/v2/service-token', v2ServiceTokenDataRouter); app.use('/api/v2/secret', v2SecretRouter); // stop supporting, TODO: revise
app.use('/api/v2/secrets', v2SecretsRouter);
app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route
app.use('/api/v2/api-key-data', v2APIKeyDataRouter); app.use('/api/v2/api-key-data', v2APIKeyDataRouter);
// api docs
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile))
// Server status
app.use('/api', healthCheck)
//* Handle unrouted requests and respond with proper error message as well as status code //* Handle unrouted requests and respond with proper error message as well as status code
app.use((req, res, next) => { app.use((req, res, next) => {
if (res.headersSent) return next(); if (res.headersSent) return next();

View File

@@ -3,7 +3,7 @@ import * as Sentry from '@sentry/node';
import axios from 'axios'; import axios from 'axios';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { IntegrationAuth, Integration } from '../../models'; import { IntegrationAuth, Integration } from '../../models';
import { INTEGRATION_SET, INTEGRATION_OPTIONS, ENV_DEV } from '../../variables'; import { INTEGRATION_SET, INTEGRATION_OPTIONS } from '../../variables';
import { IntegrationService } from '../../services'; import { IntegrationService } from '../../services';
import { getApps, revokeAccess } from '../../integrations'; import { getApps, revokeAccess } from '../../integrations';
@@ -31,11 +31,17 @@ export const oAuthExchange = async (
if (!INTEGRATION_SET.has(integration)) if (!INTEGRATION_SET.has(integration))
throw new Error('Failed to validate integration'); throw new Error('Failed to validate integration');
const environments = req.membership.workspace?.environments || [];
if(environments.length === 0){
throw new Error("Failed to get environments")
}
await IntegrationService.handleOAuthExchange({ await IntegrationService.handleOAuthExchange({
workspaceId, workspaceId,
integration, integration,
code code,
environment: environments[0].slug,
}); });
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);

View File

@@ -115,13 +115,14 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => {
if (!membershipOrg) { if (!membershipOrg) {
throw new Error('Failed to validate organization membership'); throw new Error('Failed to validate organization membership');
} }
invitee = await User.findOne({ invitee = await User.findOne({
email: inviteeEmail email: inviteeEmail
}); }).select('+publicKey');
if (invitee) { if (invitee) {
// case: invitee is an existing user // case: invitee is an existing user
inviteeMembershipOrg = await MembershipOrg.findOne({ inviteeMembershipOrg = await MembershipOrg.findOne({
user: invitee._id, user: invitee._id,
organization: organizationId organization: organizationId

View File

@@ -9,7 +9,6 @@ import {
import { pushKeys } from '../../helpers/key'; import { pushKeys } from '../../helpers/key';
import { eventPushSecrets } from '../../events'; import { eventPushSecrets } from '../../events';
import { EventService } from '../../services'; import { EventService } from '../../services';
import { ENV_SET } from '../../variables';
import { postHogClient } from '../../services'; import { postHogClient } from '../../services';
interface PushSecret { interface PushSecret {
@@ -44,7 +43,8 @@ export const pushSecrets = async (req: Request, res: Response) => {
const { workspaceId } = req.params; const { workspaceId } = req.params;
// validate environment // validate environment
if (!ENV_SET.has(environment)) { const workspaceEnvs = req.membership.workspace.environments;
if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) {
throw new Error('Failed to validate environment'); throw new Error('Failed to validate environment');
} }
@@ -116,7 +116,8 @@ export const pullSecrets = async (req: Request, res: Response) => {
const { workspaceId } = req.params; const { workspaceId } = req.params;
// validate environment // validate environment
if (!ENV_SET.has(environment)) { const workspaceEnvs = req.membership.workspace.environments;
if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) {
throw new Error('Failed to validate environment'); throw new Error('Failed to validate environment');
} }
@@ -183,7 +184,8 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => {
const { workspaceId } = req.params; const { workspaceId } = req.params;
// validate environment // validate environment
if (!ENV_SET.has(environment)) { const workspaceEnvs = req.membership.workspace.environments;
if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) {
throw new Error('Failed to validate environment'); throw new Error('Failed to validate environment');
} }

View File

@@ -1,7 +1,6 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { ServiceToken } from '../../models'; import { ServiceToken } from '../../models';
import { createToken } from '../../helpers/auth'; import { createToken } from '../../helpers/auth';
import { ENV_SET } from '../../variables';
import { JWT_SERVICE_SECRET } from '../../config'; import { JWT_SERVICE_SECRET } from '../../config';
/** /**
@@ -36,7 +35,8 @@ export const createServiceToken = async (req: Request, res: Response) => {
} = req.body; } = req.body;
// validate environment // validate environment
if (!ENV_SET.has(environment)) { const workspaceEnvs = req.membership.workspace.environments;
if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) {
throw new Error('Failed to validate environment'); throw new Error('Failed to validate environment');
} }

View File

@@ -65,7 +65,6 @@ export const createAPIKeyData = async (req: Request, res: Response) => {
apiKey = `ak.${apiKeyData._id.toString()}.${secret}`; apiKey = `ak.${apiKeyData._id.toString()}.${secret}`;
} catch (err) { } catch (err) {
console.error(err);
Sentry.setUser({ email: req.user.email }); Sentry.setUser({ email: req.user.email });
Sentry.captureException(err); Sentry.captureException(err);
return res.status(400).send({ return res.status(400).send({

View File

@@ -0,0 +1,204 @@
import { Request, Response } from 'express';
import * as Sentry from '@sentry/node';
import {
Secret,
ServiceToken,
Workspace,
Integration,
ServiceTokenData,
} from '../../models';
import { SecretVersion } from '../../ee/models';
/**
* Create new workspace environment named [environmentName] under workspace with id
* @param req
* @param res
* @returns
*/
export const createWorkspaceEnvironment = async (
req: Request,
res: Response
) => {
const { workspaceId } = req.params;
const { environmentName, environmentSlug } = req.body;
try {
const workspace = await Workspace.findById(workspaceId).exec();
if (
!workspace ||
workspace?.environments.find(
({ name, slug }) => slug === environmentSlug || environmentName === name
)
) {
throw new Error('Failed to create workspace environment');
}
workspace?.environments.push({
name: environmentName.toLowerCase(),
slug: environmentSlug.toLowerCase(),
});
await workspace.save();
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to create new workspace environment',
});
}
return res.status(200).send({
message: 'Successfully created new environment',
workspace: workspaceId,
environment: {
name: environmentName,
slug: environmentSlug,
},
});
};
/**
* Rename workspace environment with new name and slug of a workspace with [workspaceId]
* Old slug [oldEnvironmentSlug] must be provided
* @param req
* @param res
* @returns
*/
export const renameWorkspaceEnvironment = async (
req: Request,
res: Response
) => {
const { workspaceId } = req.params;
const { environmentName, environmentSlug, oldEnvironmentSlug } = req.body;
try {
// user should pass both new slug and env name
if (!environmentSlug || !environmentName) {
throw new Error('Invalid environment given.');
}
// atomic update the env to avoid conflict
const workspace = await Workspace.findById(workspaceId).exec();
if (!workspace) {
throw new Error('Failed to create workspace environment');
}
const isEnvExist = workspace.environments.some(
({ name, slug }) =>
slug !== oldEnvironmentSlug &&
(name === environmentName || slug === environmentSlug)
);
if (isEnvExist) {
throw new Error('Invalid environment given');
}
const envIndex = workspace?.environments.findIndex(
({ slug }) => slug === oldEnvironmentSlug
);
if (envIndex === -1) {
throw new Error('Invalid environment given');
}
workspace.environments[envIndex].name = environmentName.toLowerCase();
workspace.environments[envIndex].slug = environmentSlug.toLowerCase();
await workspace.save();
await Secret.updateMany(
{ workspace: workspaceId, environment: oldEnvironmentSlug },
{ environment: environmentSlug }
);
await SecretVersion.updateMany(
{ workspace: workspaceId, environment: oldEnvironmentSlug },
{ environment: environmentSlug }
);
await ServiceToken.updateMany(
{ workspace: workspaceId, environment: oldEnvironmentSlug },
{ environment: environmentSlug }
);
await ServiceTokenData.updateMany(
{ workspace: workspaceId, environment: oldEnvironmentSlug },
{ environment: environmentSlug }
);
await Integration.updateMany(
{ workspace: workspaceId, environment: oldEnvironmentSlug },
{ environment: environmentSlug }
);
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to update workspace environment',
});
}
return res.status(200).send({
message: 'Successfully update environment',
workspace: workspaceId,
environment: {
name: environmentName,
slug: environmentSlug,
},
});
};
/**
* Delete workspace environment by [environmentSlug] of workspace [workspaceId] and do the clean up
* @param req
* @param res
* @returns
*/
export const deleteWorkspaceEnvironment = async (
req: Request,
res: Response
) => {
const { workspaceId } = req.params;
const { environmentSlug } = req.body;
try {
// atomic update the env to avoid conflict
const workspace = await Workspace.findById(workspaceId).exec();
if (!workspace) {
throw new Error('Failed to create workspace environment');
}
const envIndex = workspace?.environments.findIndex(
({ slug }) => slug === environmentSlug
);
if (envIndex === -1) {
throw new Error('Invalid environment given');
}
workspace.environments.splice(envIndex, 1);
await workspace.save();
// clean up
await Secret.deleteMany({
workspace: workspaceId,
environment: environmentSlug,
});
await SecretVersion.deleteMany({
workspace: workspaceId,
environment: environmentSlug,
});
await ServiceToken.deleteMany({
workspace: workspaceId,
environment: environmentSlug,
});
await ServiceTokenData.deleteMany({
workspace: workspaceId,
environment: environmentSlug,
});
await Integration.deleteMany({
workspace: workspaceId,
environment: environmentSlug,
});
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to delete workspace environment',
});
}
return res.status(200).send({
message: 'Successfully deleted environment',
workspace: workspaceId,
environment: environmentSlug,
});
};

View File

@@ -2,10 +2,14 @@ import * as workspaceController from './workspaceController';
import * as serviceTokenDataController from './serviceTokenDataController'; import * as serviceTokenDataController from './serviceTokenDataController';
import * as apiKeyDataController from './apiKeyDataController'; import * as apiKeyDataController from './apiKeyDataController';
import * as secretController from './secretController'; import * as secretController from './secretController';
import * as secretsController from './secretsController';
import * as environmentController from './environmentController';
export { export {
workspaceController, workspaceController,
serviceTokenDataController, serviceTokenDataController,
apiKeyDataController, apiKeyDataController,
secretController secretController,
secretsController,
environmentController
} }

View File

@@ -7,10 +7,68 @@ const { ValidationError } = mongoose.Error;
import { BadRequestError, InternalServerError, UnauthorizedRequestError, ValidationError as RouteValidationError } from '../../utils/errors'; import { BadRequestError, InternalServerError, UnauthorizedRequestError, ValidationError as RouteValidationError } from '../../utils/errors';
import { AnyBulkWriteOperation } from 'mongodb'; import { AnyBulkWriteOperation } from 'mongodb';
import { SECRET_PERSONAL, SECRET_SHARED } from "../../variables"; import { SECRET_PERSONAL, SECRET_SHARED } from "../../variables";
import { postHogClient } from '../../services';
export const batchCreateSecrets = async (req: Request, res: Response) => { /**
* Create secret for workspace with id [workspaceId] and environment [environment]
* @param req
* @param res
*/
export const createSecret = async (req: Request, res: Response) => {
const secretToCreate: CreateSecretRequestBody = req.body.secret;
const { workspaceId, environment } = req.params
const sanitizedSecret: SanitizedSecretForCreate = {
secretKeyCiphertext: secretToCreate.secretKeyCiphertext,
secretKeyIV: secretToCreate.secretKeyIV,
secretKeyTag: secretToCreate.secretKeyTag,
secretKeyHash: secretToCreate.secretKeyHash,
secretValueCiphertext: secretToCreate.secretValueCiphertext,
secretValueIV: secretToCreate.secretValueIV,
secretValueTag: secretToCreate.secretValueTag,
secretValueHash: secretToCreate.secretValueHash,
secretCommentCiphertext: secretToCreate.secretCommentCiphertext,
secretCommentIV: secretToCreate.secretCommentIV,
secretCommentTag: secretToCreate.secretCommentTag,
secretCommentHash: secretToCreate.secretCommentHash,
workspace: new Types.ObjectId(workspaceId),
environment,
type: secretToCreate.type,
user: new Types.ObjectId(req.user._id)
}
const [error, secret] = await to(Secret.create(sanitizedSecret).then())
if (error instanceof ValidationError) {
throw RouteValidationError({ message: error.message, stack: error.stack })
}
if (postHogClient) {
postHogClient.capture({
event: 'secrets added',
distinctId: req.user.email,
properties: {
numberOfSecrets: 1,
workspaceId,
environment,
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
res.status(200).send({
secret
})
}
/**
* Create many secrets for workspace wiht id [workspaceId] and environment [environment]
* @param req
* @param res
*/
export const createSecrets = async (req: Request, res: Response) => {
const secretsToCreate: CreateSecretRequestBody[] = req.body.secrets; const secretsToCreate: CreateSecretRequestBody[] = req.body.secrets;
const { workspaceId, environmentName } = req.params const { workspaceId, environment } = req.params
const sanitizedSecretesToCreate: SanitizedSecretForCreate[] = [] const sanitizedSecretesToCreate: SanitizedSecretForCreate[] = []
secretsToCreate.forEach(rawSecret => { secretsToCreate.forEach(rawSecret => {
@@ -28,7 +86,7 @@ export const batchCreateSecrets = async (req: Request, res: Response) => {
secretCommentTag: rawSecret.secretCommentTag, secretCommentTag: rawSecret.secretCommentTag,
secretCommentHash: rawSecret.secretCommentHash, secretCommentHash: rawSecret.secretCommentHash,
workspace: new Types.ObjectId(workspaceId), workspace: new Types.ObjectId(workspaceId),
environment: environmentName, environment,
type: rawSecret.type, type: rawSecret.type,
user: new Types.ObjectId(req.user._id) user: new Types.ObjectId(req.user._id)
} }
@@ -36,7 +94,7 @@ export const batchCreateSecrets = async (req: Request, res: Response) => {
sanitizedSecretesToCreate.push(safeUpdateFields) sanitizedSecretesToCreate.push(safeUpdateFields)
}) })
const [bulkCreateError, newlyCreatedSecrets] = await to(Secret.insertMany(sanitizedSecretesToCreate).then()) const [bulkCreateError, secrets] = await to(Secret.insertMany(sanitizedSecretesToCreate).then())
if (bulkCreateError) { if (bulkCreateError) {
if (bulkCreateError instanceof ValidationError) { if (bulkCreateError instanceof ValidationError) {
throw RouteValidationError({ message: bulkCreateError.message, stack: bulkCreateError.stack }) throw RouteValidationError({ message: bulkCreateError.message, stack: bulkCreateError.stack })
@@ -45,20 +103,31 @@ export const batchCreateSecrets = async (req: Request, res: Response) => {
throw InternalServerError({ message: "Unable to process your batch create request. Please try again", stack: bulkCreateError.stack }) throw InternalServerError({ message: "Unable to process your batch create request. Please try again", stack: bulkCreateError.stack })
} }
res.status(200).send() if (postHogClient) {
} postHogClient.capture({
event: 'secrets added',
distinctId: req.user.email,
export const createSingleSecret = async (req: Request, res: Response) => { properties: {
try { numberOfSecrets: (secretsToCreate ?? []).length,
const secretFromDB = await Secret.findById(req.params.secretId) workspaceId,
return res.status(200).send(secretFromDB); environment,
} catch (e) { channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
throw BadRequestError({ message: "Unable to find the requested secret" }) userAgent: req.headers?.['user-agent']
}
});
} }
res.status(200).send({
secrets
})
} }
export const batchDeleteSecrets = async (req: Request, res: Response) => { /**
* Delete secrets in workspace with id [workspaceId] and environment [environment]
* @param req
* @param res
*/
export const deleteSecrets = async (req: Request, res: Response) => {
const { workspaceId, environmentName } = req.params const { workspaceId, environmentName } = req.params
const secretIdsToDelete: string[] = req.body.secretIds const secretIdsToDelete: string[] = req.body.secretIds
@@ -70,10 +139,12 @@ export const batchDeleteSecrets = async (req: Request, res: Response) => {
const secretsUserCanDeleteSet: Set<string> = new Set(secretIdsUserCanDelete.map(objectId => objectId._id.toString())); const secretsUserCanDeleteSet: Set<string> = new Set(secretIdsUserCanDelete.map(objectId => objectId._id.toString()));
const deleteOperationsToPerform: AnyBulkWriteOperation<ISecret>[] = [] const deleteOperationsToPerform: AnyBulkWriteOperation<ISecret>[] = []
let numSecretsDeleted = 0;
secretIdsToDelete.forEach(secretIdToDelete => { secretIdsToDelete.forEach(secretIdToDelete => {
if (secretsUserCanDeleteSet.has(secretIdToDelete)) { if (secretsUserCanDeleteSet.has(secretIdToDelete)) {
const deleteOperation = { deleteOne: { filter: { _id: new Types.ObjectId(secretIdToDelete) } } } const deleteOperation = { deleteOne: { filter: { _id: new Types.ObjectId(secretIdToDelete) } } }
deleteOperationsToPerform.push(deleteOperation) deleteOperationsToPerform.push(deleteOperation)
numSecretsDeleted++;
} else { } else {
throw RouteValidationError({ message: "You cannot delete secrets that you do not have access to" }) throw RouteValidationError({ message: "You cannot delete secrets that you do not have access to" })
} }
@@ -87,10 +158,57 @@ export const batchDeleteSecrets = async (req: Request, res: Response) => {
throw InternalServerError() throw InternalServerError()
} }
if (postHogClient) {
postHogClient.capture({
event: 'secrets deleted',
distinctId: req.user.email,
properties: {
numberOfSecrets: numSecretsDeleted,
environment: environmentName,
workspaceId,
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
res.status(200).send() res.status(200).send()
} }
export const batchModifySecrets = async (req: Request, res: Response) => { /**
* Delete secret with id [secretId]
* @param req
* @param res
*/
export const deleteSecret = async (req: Request, res: Response) => {
await Secret.findByIdAndDelete(req._secret._id)
if (postHogClient) {
postHogClient.capture({
event: 'secrets deleted',
distinctId: req.user.email,
properties: {
numberOfSecrets: 1,
workspaceId: req._secret.workspace.toString(),
environment: req._secret.environment,
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
res.status(200).send({
secret: req._secret
})
}
/**
* Update secrets for workspace with id [workspaceId] and environment [environment]
* @param req
* @param res
* @returns
*/
export const updateSecrets = async (req: Request, res: Response) => {
const { workspaceId, environmentName } = req.params const { workspaceId, environmentName } = req.params
const secretsModificationsRequested: ModifySecretRequestBody[] = req.body.secrets; const secretsModificationsRequested: ModifySecretRequestBody[] = req.body.secrets;
const [secretIdsUserCanModifyError, secretIdsUserCanModify] = await to(Secret.find({ workspace: workspaceId, environment: environmentName }, { _id: 1 }).then()) const [secretIdsUserCanModifyError, secretIdsUserCanModify] = await to(Secret.find({ workspace: workspaceId, environment: environmentName }, { _id: 1 }).then())
@@ -101,7 +219,6 @@ export const batchModifySecrets = async (req: Request, res: Response) => {
const secretsUserCanModifySet: Set<string> = new Set(secretIdsUserCanModify.map(objectId => objectId._id.toString())); const secretsUserCanModifySet: Set<string> = new Set(secretIdsUserCanModify.map(objectId => objectId._id.toString()));
const updateOperationsToPerform: any = [] const updateOperationsToPerform: any = []
secretsModificationsRequested.forEach(userModifiedSecret => { secretsModificationsRequested.forEach(userModifiedSecret => {
if (secretsUserCanModifySet.has(userModifiedSecret._id.toString())) { if (secretsUserCanModifySet.has(userModifiedSecret._id.toString())) {
const sanitizedSecret: SanitizedSecretModify = { const sanitizedSecret: SanitizedSecretModify = {
@@ -135,23 +252,99 @@ export const batchModifySecrets = async (req: Request, res: Response) => {
throw InternalServerError() throw InternalServerError()
} }
if (postHogClient) {
postHogClient.capture({
event: 'secrets modified',
distinctId: req.user.email,
properties: {
numberOfSecrets: (secretsModificationsRequested ?? []).length,
environment: environmentName,
workspaceId,
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
return res.status(200).send() return res.status(200).send()
} }
export const fetchAllSecrets = async (req: Request, res: Response) => { /**
* Update a secret within workspace with id [workspaceId] and environment [environment]
* @param req
* @param res
* @returns
*/
export const updateSecret = async (req: Request, res: Response) => {
const { workspaceId, environmentName } = req.params
const secretModificationsRequested: ModifySecretRequestBody = req.body.secret;
const [secretIdUserCanModifyError, secretIdUserCanModify] = await to(Secret.findOne({ workspace: workspaceId, environment: environmentName }, { _id: 1 }).then())
if (secretIdUserCanModifyError && !secretIdUserCanModify) {
throw BadRequestError()
}
const sanitizedSecret: SanitizedSecretModify = {
secretKeyCiphertext: secretModificationsRequested.secretKeyCiphertext,
secretKeyIV: secretModificationsRequested.secretKeyIV,
secretKeyTag: secretModificationsRequested.secretKeyTag,
secretKeyHash: secretModificationsRequested.secretKeyHash,
secretValueCiphertext: secretModificationsRequested.secretValueCiphertext,
secretValueIV: secretModificationsRequested.secretValueIV,
secretValueTag: secretModificationsRequested.secretValueTag,
secretValueHash: secretModificationsRequested.secretValueHash,
secretCommentCiphertext: secretModificationsRequested.secretCommentCiphertext,
secretCommentIV: secretModificationsRequested.secretCommentIV,
secretCommentTag: secretModificationsRequested.secretCommentTag,
secretCommentHash: secretModificationsRequested.secretCommentHash,
}
const [error, singleModificationUpdate] = await to(Secret.updateOne({ _id: secretModificationsRequested._id, workspace: workspaceId }, { $inc: { version: 1 }, $set: sanitizedSecret }).then())
if (error instanceof ValidationError) {
throw RouteValidationError({ message: "Unable to apply modifications, please try again", stack: error.stack })
}
if (postHogClient) {
postHogClient.capture({
event: 'secrets modified',
distinctId: req.user.email,
properties: {
numberOfSecrets: 1,
environment: environmentName,
workspaceId,
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
return res.status(200).send(singleModificationUpdate)
}
/**
* Return secrets for workspace with id [workspaceId], environment [environment] and user
* with id [req.user._id]
* @param req
* @param res
* @returns
*/
export const getSecrets = async (req: Request, res: Response) => {
const { environment } = req.query; const { environment } = req.query;
const { workspaceId } = req.params; const { workspaceId } = req.params;
let userId: string | undefined = undefined // Used for choosing the personal secrets to fetch in let userId: Types.ObjectId | undefined = undefined // used for getting personal secrets for user
let userEmail: Types.ObjectId | undefined = undefined // used for posthog
if (req.user) { if (req.user) {
userId = req.user._id.toString(); userId = req.user._id;
userEmail = req.user.email;
} }
if (req.serviceTokenData) { if (req.serviceTokenData) {
userId = req.serviceTokenData.user._id userId = req.serviceTokenData.user._id
userEmail = req.serviceTokenData.user.email;
} }
const [retriveAllSecretsError, allSecrets] = await to(Secret.find( const [err, secrets] = await to(Secret.find(
{ {
workspace: workspaceId, workspace: workspaceId,
environment, environment,
@@ -160,9 +353,49 @@ export const fetchAllSecrets = async (req: Request, res: Response) => {
} }
).then()) ).then())
if (retriveAllSecretsError instanceof ValidationError) { if (err) {
throw RouteValidationError({ message: "Unable to get secrets, please try again", stack: retriveAllSecretsError.stack }) throw RouteValidationError({ message: "Failed to get secrets, please try again", stack: err.stack })
} }
return res.json(allSecrets) if (postHogClient) {
postHogClient.capture({
event: 'secrets pulled',
distinctId: userEmail,
properties: {
numberOfSecrets: (secrets ?? []).length,
environment,
workspaceId,
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
return res.json(secrets)
}
/**
* Return secret with id [secretId]
* @param req
* @param res
* @returns
*/
export const getSecret = async (req: Request, res: Response) => {
// if (postHogClient) {
// postHogClient.capture({
// event: 'secrets pulled',
// distinctId: req.user.email,
// properties: {
// numberOfSecrets: 1,
// workspaceId: req._secret.workspace.toString(),
// environment: req._secret.environment,
// channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
// userAgent: req.headers?.['user-agent']
// }
// });
// }
return res.status(200).send({
secret: req._secret
});
} }

View File

@@ -0,0 +1,476 @@
import to from 'await-to-js';
import { Types } from 'mongoose';
import { Request, Response } from 'express';
import { ISecret, Secret } from '../../models';
import {
SECRET_PERSONAL,
SECRET_SHARED,
ACTION_ADD_SECRETS,
ACTION_READ_SECRETS,
ACTION_UPDATE_SECRETS,
ACTION_DELETE_SECRETS
} from '../../variables';
import { ValidationError } from '../../utils/errors';
import { EventService } from '../../services';
import { eventPushSecrets } from '../../events';
import { EESecretService, EELogService } from '../../ee/services';
import { postHogClient } from '../../services';
import { BadRequestError } from '../../utils/errors';
/**
* Create secret(s) for workspace with id [workspaceId] and environment [environment]
* @param req
* @param res
*/
export const createSecrets = async (req: Request, res: Response) => {
const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli';
const { workspaceId, environment } = req.body;
let toAdd;
if (Array.isArray(req.body.secrets)) {
// case: create multiple secrets
toAdd = req.body.secrets;
} else if (typeof req.body.secrets === 'object') {
// case: create 1 secret
toAdd = [req.body.secrets];
}
const newSecrets = await Secret.insertMany(
toAdd.map(({
type,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretValueCiphertext,
secretValueIV,
secretValueTag,
}: {
type: string;
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
}) => ({
version: 1,
workspace: new Types.ObjectId(workspaceId),
type,
user: type === SECRET_PERSONAL ? req.user : undefined,
environment,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretValueCiphertext,
secretValueIV,
secretValueTag
}))
);
setTimeout(async () => {
// trigger event - push secrets
await EventService.handleEvent({
event: eventPushSecrets({
workspaceId
})
});
}, 5000);
// (EE) add secret versions for new secrets
await EESecretService.addSecretVersions({
secretVersions: newSecrets.map(({
_id,
version,
workspace,
type,
user,
environment,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
}) => ({
_id: new Types.ObjectId(),
secret: _id,
version,
workspace,
type,
user,
environment,
isDeleted: false,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
}))
});
const addAction = await EELogService.createActionSecret({
name: ACTION_ADD_SECRETS,
userId: req.user._id.toString(),
workspaceId,
secretIds: newSecrets.map((n) => n._id)
});
// (EE) create (audit) log
addAction && await EELogService.createLog({
userId: req.user._id.toString(),
workspaceId,
actions: [addAction],
channel,
ipAddress: req.ip
});
// (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId
});
if (postHogClient) {
postHogClient.capture({
event: 'secrets added',
distinctId: req.user.email,
properties: {
numberOfSecrets: toAdd.length,
environment,
workspaceId,
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
return res.status(200).send({
secrets: newSecrets
});
}
/**
* Return secret(s) for workspace with id [workspaceId], environment [environment] and user
* with id [req.user._id]
* @param req
* @param res
* @returns
*/
export const getSecrets = async (req: Request, res: Response) => {
const { workspaceId, environment } = req.query;
let userId: Types.ObjectId | undefined = undefined // used for getting personal secrets for user
let userEmail: Types.ObjectId | undefined = undefined // used for posthog
if (req.user) {
userId = req.user._id;
userEmail = req.user.email;
}
if (req.serviceTokenData) {
userId = req.serviceTokenData.user._id
userEmail = req.serviceTokenData.user.email;
}
const [err, secrets] = await to(Secret.find(
{
workspace: workspaceId,
environment,
$or: [
{ user: userId },
{ user: { $exists: false } }
],
type: { $in: [SECRET_SHARED, SECRET_PERSONAL] }
}
).then())
if (err) throw ValidationError({ message: 'Failed to get secrets', stack: err.stack });
const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli';
const readAction = await EELogService.createActionSecret({
name: ACTION_READ_SECRETS,
userId: req.user._id.toString(),
workspaceId: workspaceId as string,
secretIds: secrets.map((n: any) => n._id)
});
readAction && await EELogService.createLog({
userId: req.user._id.toString(),
workspaceId: workspaceId as string,
actions: [readAction],
channel,
ipAddress: req.ip
});
if (postHogClient) {
postHogClient.capture({
event: 'secrets pulled',
distinctId: userEmail,
properties: {
numberOfSecrets: secrets.length,
environment,
workspaceId,
channel,
userAgent: req.headers?.['user-agent']
}
});
}
return res.status(200).send({
secrets
});
}
/**
* Update secret(s)
* @param req
* @param res
*/
export const updateSecrets = async (req: Request, res: Response) => {
const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli';
// TODO: move type
interface PatchSecret {
id: string;
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
secretCommentCiphertext: string;
secretCommentIV: string;
secretCommentTag: string;
}
const updateOperationsToPerform = req.body.secrets.map((secret: PatchSecret) => {
const {
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag
} = secret;
return ({
updateOne: {
filter: { _id: new Types.ObjectId(secret.id) },
update: {
$inc: {
version: 1
},
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretValueCiphertext,
secretValueIV,
secretValueTag,
...((
secretCommentCiphertext &&
secretCommentIV &&
secretCommentTag
) ? {
secretCommentCiphertext,
secretCommentIV,
secretCommentTag
} : {}),
}
}
});
});
await Secret.bulkWrite(updateOperationsToPerform);
const secretModificationsBySecretId: { [key: string]: PatchSecret } = {};
req.body.secrets.forEach((secret: PatchSecret) => {
secretModificationsBySecretId[secret.id] = secret;
});
const ListOfSecretsBeforeModifications = req.secrets
const secretVersions = {
secretVersions: ListOfSecretsBeforeModifications.map((secret: ISecret) => {
const {
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag,
} = secretModificationsBySecretId[secret._id.toString()]
return ({
secret: secret._id,
version: secret.version + 1,
workspace: secret.workspace,
type: secret.type,
environment: secret.environment,
secretKeyCiphertext: secretKeyCiphertext ? secretKeyCiphertext : secret.secretKeyCiphertext,
secretKeyIV: secretKeyIV ? secretKeyIV : secret.secretKeyIV,
secretKeyTag: secretKeyTag ? secretKeyTag : secret.secretKeyTag,
secretValueCiphertext: secretValueCiphertext ? secretValueCiphertext : secret.secretValueCiphertext,
secretValueIV: secretValueIV ? secretValueIV : secret.secretValueIV,
secretValueTag: secretValueTag ? secretValueTag : secret.secretValueTag,
secretCommentCiphertext: secretCommentCiphertext ? secretCommentCiphertext : secret.secretCommentCiphertext,
secretCommentIV: secretCommentIV ? secretCommentIV : secret.secretCommentIV,
secretCommentTag: secretCommentTag ? secretCommentTag : secret.secretCommentTag,
});
})
}
await EESecretService.addSecretVersions(secretVersions);
// group secrets into workspaces so updated secrets can
// be logged and snapshotted separately for each workspace
const workspaceSecretObj: any = {};
req.secrets.forEach((s: any) => {
if (s.workspace.toString() in workspaceSecretObj) {
workspaceSecretObj[s.workspace.toString()].push(s);
} else {
workspaceSecretObj[s.workspace.toString()] = [s]
}
});
Object.keys(workspaceSecretObj).forEach(async (key) => {
// trigger event - push secrets
setTimeout(async () => {
await EventService.handleEvent({
event: eventPushSecrets({
workspaceId: key
})
});
}, 10000);
const updateAction = await EELogService.createActionSecret({
name: ACTION_UPDATE_SECRETS,
userId: req.user._id.toString(),
workspaceId: key,
secretIds: workspaceSecretObj[key].map((secret: ISecret) => secret._id)
});
// (EE) create (audit) log
updateAction && await EELogService.createLog({
userId: req.user._id.toString(),
workspaceId: key,
actions: [updateAction],
channel,
ipAddress: req.ip
});
// (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId: key
})
if (postHogClient) {
postHogClient.capture({
event: 'secrets modified',
distinctId: req.user.email,
properties: {
numberOfSecrets: workspaceSecretObj[key].length,
environment: workspaceSecretObj[key][0].environment,
workspaceId: key,
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
});
return res.status(200).send({
secrets: await Secret.find({
_id: {
$in: req.secrets.map((secret: ISecret) => secret._id)
}
})
});
}
/**
* Delete secret(s) with id [workspaceId] and environment [environment]
* @param req
* @param res
*/
export const deleteSecrets = async (req: Request, res: Response) => {
const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli';
const toDelete = req.secrets.map((s: any) => s._id);
await Secret.deleteMany({
_id: {
$in: toDelete
}
});
await EESecretService.markDeletedSecretVersions({
secretIds: toDelete
});
// group secrets into workspaces so deleted secrets can
// be logged and snapshotted separately for each workspace
const workspaceSecretObj: any = {};
req.secrets.forEach((s: any) => {
if (s.workspace.toString() in workspaceSecretObj) {
workspaceSecretObj[s.workspace.toString()].push(s);
} else {
workspaceSecretObj[s.workspace.toString()] = [s]
}
});
Object.keys(workspaceSecretObj).forEach(async (key) => {
// trigger event - push secrets
await EventService.handleEvent({
event: eventPushSecrets({
workspaceId: key
})
});
const deleteAction = await EELogService.createActionSecret({
name: ACTION_DELETE_SECRETS,
userId: req.user._id.toString(),
workspaceId: key,
secretIds: workspaceSecretObj[key].map((secret: ISecret) => secret._id)
});
// (EE) create (audit) log
deleteAction && await EELogService.createLog({
userId: req.user._id.toString(),
workspaceId: key,
actions: [deleteAction],
channel,
ipAddress: req.ip
});
// (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId: key
})
if (postHogClient) {
postHogClient.capture({
event: 'secrets deleted',
distinctId: req.user.email,
properties: {
numberOfSecrets: workspaceSecretObj[key].length,
environment: workspaceSecretObj[key][0].environment,
workspaceId: key,
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
});
return res.status(200).send({
secrets: req.secrets
});
}

View File

@@ -19,7 +19,6 @@ import {
import { pushKeys } from '../../helpers/key'; import { pushKeys } from '../../helpers/key';
import { postHogClient, EventService } from '../../services'; import { postHogClient, EventService } from '../../services';
import { eventPushSecrets } from '../../events'; import { eventPushSecrets } from '../../events';
import { ENV_SET } from '../../variables';
interface V2PushSecret { interface V2PushSecret {
type: string; // personal or shared type: string; // personal or shared
@@ -52,7 +51,8 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => {
const { workspaceId } = req.params; const { workspaceId } = req.params;
// validate environment // validate environment
if (!ENV_SET.has(environment)) { const workspaceEnvs = req.membership.workspace.environments;
if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) {
throw new Error('Failed to validate environment'); throw new Error('Failed to validate environment');
} }
@@ -129,6 +129,11 @@ export const pullSecrets = async (req: Request, res: Response) => {
} else if (req.serviceTokenData) { } else if (req.serviceTokenData) {
userId = req.serviceTokenData.user._id userId = req.serviceTokenData.user._id
} }
// validate environment
const workspaceEnvs = req.membership.workspace.environments;
if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) {
throw new Error('Failed to validate environment');
}
secrets = await pull({ secrets = await pull({
userId, userId,

View File

@@ -1,6 +1,8 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Secret } from '../../../models';
import { SecretVersion } from '../../models'; import { SecretVersion } from '../../models';
import { EESecretService } from '../../services';
/** /**
* Return secret versions for secret with id [secretId] * Return secret versions for secret with id [secretId]
@@ -33,4 +35,103 @@ import { SecretVersion } from '../../models';
return res.status(200).send({ return res.status(200).send({
secretVersions secretVersions
}); });
}
/**
* Roll back secret with id [secretId] to version [version]
* @param req
* @param res
* @returns
*/
export const rollbackSecretVersion = async (req: Request, res: Response) => {
let secret;
try {
const { secretId } = req.params;
const { version } = req.body;
// validate secret version
const oldSecretVersion = await SecretVersion.findOne({
secret: secretId,
version
});
if (!oldSecretVersion) throw new Error('Failed to find secret version');
const {
workspace,
type,
user,
environment,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
} = oldSecretVersion;
// update secret
secret = await Secret.findByIdAndUpdate(
secretId,
{
$inc: {
version: 1
},
workspace,
type,
user,
environment,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
},
{
new: true
}
);
if (!secret) throw new Error('Failed to find and update secret');
// add new secret version
await new SecretVersion({
secret: secretId,
version: secret.version,
workspace,
type,
user,
environment,
isDeleted: false,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
}).save();
// take secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId: secret.workspace.toString()
});
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to roll back secret version'
});
}
return res.status(200).send({
secret
});
} }

View File

@@ -2,6 +2,12 @@ import { Request, Response } from 'express';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { SecretSnapshot } from '../../models'; import { SecretSnapshot } from '../../models';
/**
* Return secret snapshot with id [secretSnapshotId]
* @param req
* @param res
* @returns
*/
export const getSecretSnapshot = async (req: Request, res: Response) => { export const getSecretSnapshot = async (req: Request, res: Response) => {
let secretSnapshot; let secretSnapshot;
try { try {

View File

@@ -1,9 +1,17 @@
import e, { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Types } from 'mongoose';
import {
Secret
} from '../../../models';
import { import {
SecretSnapshot, SecretSnapshot,
Log Log,
SecretVersion,
ISecretVersion
} from '../../models'; } from '../../models';
import { EESecretService } from '../../services';
import { getLatestSecretVersionIds } from '../../helpers/secretVersion';
/** /**
* Return secret snapshots for workspace with id [workspaceId] * Return secret snapshots for workspace with id [workspaceId]
@@ -63,6 +71,159 @@ export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Respon
}); });
} }
/**
* Rollback secret snapshot with id [secretSnapshotId] to version [version]
* @param req
* @param res
* @returns
*/
export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Response) => {
let secrets;
try {
const { workspaceId } = req.params;
const { version } = req.body;
// validate secret snapshot
const secretSnapshot = await SecretSnapshot.findOne({
workspace: workspaceId,
version
}).populate<{ secretVersions: ISecretVersion[]}>('secretVersions');
if (!secretSnapshot) throw new Error('Failed to find secret snapshot');
// TODO: fix any
const oldSecretVersionsObj: any = secretSnapshot.secretVersions
.reduce((accumulator, s) => ({
...accumulator,
[`${s.secret.toString()}`]: s
}), {});
const latestSecretVersionIds = await getLatestSecretVersionIds({
secretIds: secretSnapshot.secretVersions.map((sv) => sv.secret)
});
// TODO: fix any
const latestSecretVersions: any = (await SecretVersion.find({
_id: {
$in: latestSecretVersionIds.map((s) => s.versionId)
}
}, 'secret version'))
.reduce((accumulator, s) => ({
...accumulator,
[`${s.secret.toString()}`]: s
}), {});
// delete existing secrets
await Secret.deleteMany({
workspace: workspaceId
});
// add secrets
secrets = await Secret.insertMany(
secretSnapshot.secretVersions.map((sv) => {
const secretId = sv.secret;
const {
workspace,
type,
user,
environment,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash,
createdAt
} = oldSecretVersionsObj[secretId.toString()];
return ({
_id: secretId,
version: latestSecretVersions[secretId.toString()].version + 1,
workspace,
type,
user,
environment,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash,
secretCommentCiphertext: '',
secretCommentIV: '',
secretCommentTag: '',
createdAt
});
})
);
// add secret versions
await SecretVersion.insertMany(
secrets.map(({
_id,
version,
workspace,
type,
user,
environment,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
}) => ({
_id: new Types.ObjectId(),
secret: _id,
version,
workspace,
type,
user,
environment,
isDeleted: false,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
}))
);
// update secret versions of restored secrets as not deleted
await SecretVersion.updateMany({
secret: {
$in: secretSnapshot.secretVersions.map((sv) => sv.secret)
}
}, {
isDeleted: false
});
// take secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId
});
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to roll back secret snapshot'
});
}
return res.status(200).send({
secrets
});
}
/** /**
* Return (audit) logs for workspace with id [workspaceId] * Return (audit) logs for workspace with id [workspaceId]
* @param req * @param req

View File

@@ -1,7 +1,10 @@
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import { Secret } from '../../models';
import { SecretVersion, Action } from '../models'; import { SecretVersion, Action } from '../models';
import {
getLatestSecretVersionIds,
getLatestNSecretSecretVersionIds
} from '../helpers/secretVersion';
import { ACTION_UPDATE_SECRETS } from '../../variables'; import { ACTION_UPDATE_SECRETS } from '../../variables';
/** /**
@@ -30,65 +33,23 @@ const createActionSecretHelper = async ({
if (name === ACTION_UPDATE_SECRETS) { if (name === ACTION_UPDATE_SECRETS) {
// case: action is updating secrets // case: action is updating secrets
// -> add old and new secret versions // -> add old and new secret versions
latestSecretVersions = (await getLatestNSecretSecretVersionIds({
// TODO: make query more efficient secretIds,
latestSecretVersions = (await SecretVersion.aggregate([ n: 2
{ }))
$match: { .map((s) => ({
secret: { oldSecretVersion: s.versions[0]._id,
$in: secretIds, newSecretVersion: s.versions[1]._id
}, }));
},
},
{
$sort: { version: -1 },
},
{
$group: {
_id: "$secret",
versions: { $push: "$$ROOT" },
},
},
{
$project: {
_id: 0,
secret: "$_id",
versions: { $slice: ["$versions", 2] },
},
}
]))
.map((s) => ({
oldSecretVersion: s.versions[0]._id,
newSecretVersion: s.versions[1]._id
}));
} else { } else {
// case: action is adding, deleting, or reading secrets // case: action is adding, deleting, or reading secrets
// -> add new secret versions // -> add new secret versions
latestSecretVersions = (await SecretVersion.aggregate([ latestSecretVersions = (await getLatestSecretVersionIds({
{ secretIds
$match: { }))
secret: { .map((s) => ({
$in: secretIds newSecretVersion: s.versionId
} }));
}
},
{
$group: {
_id: '$secret',
version: { $max: '$version' },
versionId: { $max: '$_id' } // secret version id
}
},
{
$sort: { version: -1 }
}
])
.exec())
.map((s) => ({
newSecretVersion: s.versionId
}));
} }
action = await new Action({ action = await new Action({

View File

@@ -1,11 +1,11 @@
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { import {
Secret, Secret,
ISecret ISecret
} from '../../models'; } from '../../models';
import { import {
SecretSnapshot, SecretSnapshot,
SecretVersion, SecretVersion,
ISecretVersion ISecretVersion
} from '../models'; } from '../models';
@@ -18,24 +18,24 @@ import {
* @param {String} obj.workspaceId * @param {String} obj.workspaceId
* @returns {SecretSnapshot} secretSnapshot - new secret snapshot * @returns {SecretSnapshot} secretSnapshot - new secret snapshot
*/ */
const takeSecretSnapshotHelper = async ({ const takeSecretSnapshotHelper = async ({
workspaceId workspaceId
}: { }: {
workspaceId: string; workspaceId: string;
}) => { }) => {
let secretSnapshot; let secretSnapshot;
try { try {
const secretIds = (await Secret.find({ const secretIds = (await Secret.find({
workspace: workspaceId workspace: workspaceId
}, '_id')).map((s) => s._id); }, '_id')).map((s) => s._id);
const latestSecretVersions = (await SecretVersion.aggregate([ const latestSecretVersions = (await SecretVersion.aggregate([
{ {
$match: { $match: {
secret: { secret: {
$in: secretIds $in: secretIds
} }
} }
}, },
{ {
@@ -48,14 +48,14 @@ import {
{ {
$sort: { version: -1 } $sort: { version: -1 }
} }
]) ])
.exec()) .exec())
.map((s) => s.versionId); .map((s) => s.versionId);
const latestSecretSnapshot = await SecretSnapshot.findOne({ const latestSecretSnapshot = await SecretSnapshot.findOne({
workspace: workspaceId workspace: workspaceId
}).sort({ version: -1 }); }).sort({ version: -1 });
secretSnapshot = await new SecretSnapshot({ secretSnapshot = await new SecretSnapshot({
workspace: workspaceId, workspace: workspaceId,
version: latestSecretSnapshot ? latestSecretSnapshot.version + 1 : 1, version: latestSecretSnapshot ? latestSecretSnapshot.version + 1 : 1,
@@ -66,7 +66,7 @@ import {
Sentry.captureException(err); Sentry.captureException(err);
throw new Error('Failed to take a secret snapshot'); throw new Error('Failed to take a secret snapshot');
} }
return secretSnapshot; return secretSnapshot;
} }
@@ -87,9 +87,9 @@ const addSecretVersionsHelper = async ({
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);
Sentry.captureException(err); Sentry.captureException(err);
throw new Error('Failed to add secret versions'); throw new Error(`Failed to add secret versions [err=${err}]`);
} }
return newSecretVersions; return newSecretVersions;
} }
@@ -120,39 +120,39 @@ const markDeletedSecretVersionsHelper = async ({
const initSecretVersioningHelper = async () => { const initSecretVersioningHelper = async () => {
try { try {
await Secret.updateMany( await Secret.updateMany(
{ version: { $exists: false } }, { version: { $exists: false } },
{ $set: { version: 1 } } { $set: { version: 1 } }
); );
const unversionedSecrets: ISecret[] = await Secret.aggregate([ const unversionedSecrets: ISecret[] = await Secret.aggregate([
{ {
$lookup: { $lookup: {
from: 'secretversions', from: 'secretversions',
localField: '_id', localField: '_id',
foreignField: 'secret', foreignField: 'secret',
as: 'versions', as: 'versions',
}, },
}, },
{ {
$match: { $match: {
versions: { $size: 0 }, versions: { $size: 0 },
}, },
}, },
]); ]);
if (unversionedSecrets.length > 0) { if (unversionedSecrets.length > 0) {
await addSecretVersionsHelper({ await addSecretVersionsHelper({
secretVersions: unversionedSecrets.map((s, idx) => ({ secretVersions: unversionedSecrets.map((s, idx) => ({
...s, ...s,
secret: s._id, secret: s._id,
version: s.version ? s.version : 1, version: s.version ? s.version : 1,
isDeleted: false, isDeleted: false,
workspace: s.workspace, workspace: s.workspace,
environment: s.environment environment: s.environment
})) }))
}); });
} }
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);
@@ -162,7 +162,7 @@ const initSecretVersioningHelper = async () => {
} }
export { export {
takeSecretSnapshotHelper, takeSecretSnapshotHelper,
addSecretVersionsHelper, addSecretVersionsHelper,
markDeletedSecretVersionsHelper, markDeletedSecretVersionsHelper,
initSecretVersioningHelper initSecretVersioningHelper

View File

@@ -0,0 +1,110 @@
import * as Sentry from '@sentry/node';
import { Types } from 'mongoose';
import { SecretVersion } from '../models';
/**
* Return latest secret versions for secrets with ids [secretIds]
* @param {Object} obj
* @param {Object} obj.secretIds = ids of secrets to get latest versions for
* @returns
*/
const getLatestSecretVersionIds = async ({
secretIds
}: {
secretIds: Types.ObjectId[];
}) => {
interface LatestSecretVersionId {
_id: Types.ObjectId;
version: number;
versionId: Types.ObjectId;
}
let latestSecretVersionIds: LatestSecretVersionId[];
try {
latestSecretVersionIds = (await SecretVersion.aggregate([
{
$match: {
secret: {
$in: secretIds
}
}
},
{
$group: {
_id: '$secret',
version: { $max: '$version' },
versionId: { $max: '$_id' } // id of latest secret version
}
},
{
$sort: { version: -1 }
}
])
.exec());
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to get latest secret versions');
}
return latestSecretVersionIds;
}
/**
* Return latest [n] secret versions for secrets with ids [secretIds]
* @param {Object} obj
* @param {Object} obj.secretIds = ids of secrets to get latest versions for
* @param {Number} obj.n - number of latest secret versions to return for each secret
* @returns
*/
const getLatestNSecretSecretVersionIds = async ({
secretIds,
n
}: {
secretIds: Types.ObjectId[];
n: number;
}) => {
// TODO: optimize query
let latestNSecretVersions;
try {
latestNSecretVersions = (await SecretVersion.aggregate([
{
$match: {
secret: {
$in: secretIds,
},
},
},
{
$sort: { version: -1 },
},
{
$group: {
_id: "$secret",
versions: { $push: "$$ROOT" },
},
},
{
$project: {
_id: 0,
secret: "$_id",
versions: { $slice: ["$versions", n] },
},
}
]));
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to get latest n secret versions');
}
return latestNSecretVersions;
}
export {
getLatestSecretVersionIds,
getLatestNSecretSecretVersionIds
}

View File

@@ -2,31 +2,18 @@ import { Schema, model, Types } from 'mongoose';
import { import {
SECRET_SHARED, SECRET_SHARED,
SECRET_PERSONAL, SECRET_PERSONAL,
ENV_DEV,
ENV_TESTING,
ENV_STAGING,
ENV_PROD
} from '../../variables'; } from '../../variables';
/**
* TODO:
* 1. Modify SecretVersion to also contain XX
* - type
* - user
* - environment
* 2. Modify SecretSnapshot to point to arrays of SecretVersion
*/
export interface ISecretVersion { export interface ISecretVersion {
_id?: Types.ObjectId; _id: Types.ObjectId;
secret: Types.ObjectId; secret: Types.ObjectId;
version: number; version: number;
workspace: Types.ObjectId; // new workspace: Types.ObjectId; // new
type: string; // new type: string; // new
user: Types.ObjectId; // new user: Types.ObjectId; // new
environment: string; // new environment: string; // new
isDeleted: boolean; isDeleted: boolean;
secretKeyCiphertext: string; secretKeyCiphertext: string;
secretKeyIV: string; secretKeyIV: string;
secretKeyTag: string; secretKeyTag: string;
secretKeyHash: string; secretKeyHash: string;
@@ -37,17 +24,17 @@ export interface ISecretVersion {
} }
const secretVersionSchema = new Schema<ISecretVersion>( const secretVersionSchema = new Schema<ISecretVersion>(
{ {
secret: { // could be deleted secret: { // could be deleted
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'Secret', ref: 'Secret',
required: true required: true
}, },
version: { version: {
type: Number, type: Number,
default: 1, default: 1,
required: true required: true
}, },
workspace: { workspace: {
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'Workspace', ref: 'Workspace',
@@ -65,15 +52,14 @@ const secretVersionSchema = new Schema<ISecretVersion>(
}, },
environment: { environment: {
type: String, type: String,
enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD],
required: true required: true
}, },
isDeleted: { isDeleted: { // consider removing field
type: Boolean, type: Boolean,
default: false, default: false,
required: true required: true
}, },
secretKeyCiphertext: { secretKeyCiphertext: {
type: String, type: String,
required: true required: true
}, },
@@ -86,8 +72,7 @@ const secretVersionSchema = new Schema<ISecretVersion>(
required: true required: true
}, },
secretKeyHash: { secretKeyHash: {
type: String, type: String
required: true
}, },
secretValueCiphertext: { secretValueCiphertext: {
type: String, type: String,
@@ -102,13 +87,12 @@ const secretVersionSchema = new Schema<ISecretVersion>(
required: true required: true
}, },
secretValueHash: { secretValueHash: {
type: String, type: String
required: true
} }
}, },
{ {
timestamps: true timestamps: true
} }
); );
const SecretVersion = model<ISecretVersion>('SecretVersion', secretVersionSchema); const SecretVersion = model<ISecretVersion>('SecretVersion', secretVersionSchema);

View File

@@ -5,7 +5,7 @@ import {
requireSecretAuth, requireSecretAuth,
validateRequest validateRequest
} from '../../../middleware'; } from '../../../middleware';
import { query, param } from 'express-validator'; import { query, param, body } from 'express-validator';
import { secretController } from '../../controllers/v1'; import { secretController } from '../../controllers/v1';
import { ADMIN, MEMBER } from '../../../variables'; import { ADMIN, MEMBER } from '../../../variables';
@@ -24,4 +24,17 @@ router.get(
secretController.getSecretVersions secretController.getSecretVersions
); );
router.post(
'/:secretId/secret-versions/rollback',
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireSecretAuth({
acceptedRoles: [ADMIN, MEMBER]
}),
param('secretId').exists().trim(),
body('version').exists().isInt(),
secretController.rollbackSecretVersion
);
export default router; export default router;

View File

@@ -7,7 +7,7 @@ import {
requireAuth, requireAuth,
validateRequest validateRequest
} from '../../../middleware'; } from '../../../middleware';
import { param } from 'express-validator'; import { param, body } from 'express-validator';
import { ADMIN, MEMBER } from '../../../variables'; import { ADMIN, MEMBER } from '../../../variables';
import { secretSnapshotController } from '../../controllers/v1'; import { secretSnapshotController } from '../../controllers/v1';

View File

@@ -5,7 +5,7 @@ import {
requireWorkspaceAuth, requireWorkspaceAuth,
validateRequest validateRequest
} from '../../../middleware'; } from '../../../middleware';
import { param, query } from 'express-validator'; import { param, query, body } from 'express-validator';
import { ADMIN, MEMBER } from '../../../variables'; import { ADMIN, MEMBER } from '../../../variables';
import { workspaceController } from '../../controllers/v1'; import { workspaceController } from '../../controllers/v1';
@@ -37,6 +37,20 @@ router.get(
workspaceController.getWorkspaceSecretSnapshotsCount workspaceController.getWorkspaceSecretSnapshotsCount
); );
router.post(
'/:workspaceId/secret-snapshots/rollback',
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER]
}),
param('workspaceId').exists().trim(),
body('version').exists().isInt(),
validateRequest,
workspaceController.rollbackWorkspaceSecretSnapshot
);
router.get( router.get(
'/:workspaceId/logs', '/:workspaceId/logs',
requireAuth({ requireAuth({

View File

@@ -72,7 +72,7 @@ const getSecretsHelper = async ({
try { try {
const key = await getKey({ workspaceId }); const key = await getKey({ workspaceId });
const secrets = await Secret.find({ const secrets = await Secret.find({
workspaceId, workspace: workspaceId,
environment, environment,
type: SECRET_SHARED type: SECRET_SHARED
}); });
@@ -84,7 +84,7 @@ const getSecretsHelper = async ({
tag: secret.secretKeyTag, tag: secret.secretKeyTag,
key key
}); });
const secretValue = decryptSymmetric({ const secretValue = decryptSymmetric({
ciphertext: secret.secretValueCiphertext, ciphertext: secret.secretValueCiphertext,
iv: secret.secretValueIV, iv: secret.secretValueIV,

View File

@@ -7,8 +7,6 @@ import {
import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations'; import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations';
import { BotService } from '../services'; import { BotService } from '../services';
import { import {
ENV_DEV,
EVENT_PUSH_SECRETS,
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
INTEGRATION_NETLIFY INTEGRATION_NETLIFY
} from '../variables'; } from '../variables';
@@ -36,11 +34,13 @@ interface Update {
const handleOAuthExchangeHelper = async ({ const handleOAuthExchangeHelper = async ({
workspaceId, workspaceId,
integration, integration,
code code,
environment
}: { }: {
workspaceId: string; workspaceId: string;
integration: string; integration: string;
code: string; code: string;
environment: string;
}) => { }) => {
let action; let action;
let integrationAuth; let integrationAuth;
@@ -102,9 +102,9 @@ const handleOAuthExchangeHelper = async ({
// initialize new integration after exchange // initialize new integration after exchange
await new Integration({ await new Integration({
workspace: workspaceId, workspace: workspaceId,
environment: ENV_DEV,
isActive: false, isActive: false,
app: null, app: null,
environment,
integration, integration,
integrationAuth: integrationAuth._id integrationAuth: integrationAuth._id
}).save(); }).save();

View File

@@ -6,7 +6,9 @@ const apiLimiter = rateLimit({
max: 450, max: 450,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
skip: (request) => request.path === '/healthcheck' skip: (request) => {
return request.path === '/healthcheck' || request.path === '/api/status'
}
}); });
// 5 requests per hour // 5 requests per hour

View File

@@ -3,6 +3,7 @@ import { Types } from 'mongoose';
import { import {
Secret, Secret,
ISecret, ISecret,
Membership
} from '../models'; } from '../models';
import { import {
EESecretService, EESecretService,
@@ -20,6 +21,46 @@ import {
ACTION_READ_SECRETS ACTION_READ_SECRETS
} from '../variables'; } from '../variables';
/**
* Validate that user with id [userId] can modify secrets with ids [secretIds]
* @param {Object} obj
* @param {Object} obj.userId - id of user to validate
* @param {Object} obj.secretIds - secret ids
* @returns {Secret[]} secrets
*/
const validateSecrets = async ({
userId,
secretIds
}: {
userId: string;
secretIds: string[];
}) =>{
let secrets;
try {
secrets = await Secret.find({
_id: {
$in: secretIds.map((secretId: string) => new Types.ObjectId(secretId))
}
});
const workspaceIdsSet = new Set((await Membership.find({
user: userId
}, 'workspace'))
.map((m) => m.workspace.toString()));
secrets.forEach((secret: ISecret) => {
if (!workspaceIdsSet.has(secret.workspace.toString())) {
throw new Error('Failed to validate secret');
}
});
} catch (err) {
throw new Error('Failed to validate secrets');
}
return secrets;
}
interface V1PushSecret { interface V1PushSecret {
ciphertextKey: string; ciphertextKey: string;
ivKey: string; ivKey: string;
@@ -187,6 +228,7 @@ const v1PushSecrets = async ({
}) => { }) => {
const newSecret = newSecretsObj[`${type}-${secretKeyHash}`]; const newSecret = newSecretsObj[`${type}-${secretKeyHash}`];
return ({ return ({
_id: new Types.ObjectId(),
secret: _id, secret: _id,
version: version ? version + 1 : 1, version: version ? version + 1 : 1,
workspace: new Types.ObjectId(workspaceId), workspace: new Types.ObjectId(workspaceId),
@@ -258,6 +300,7 @@ const v1PushSecrets = async ({
secretValueTag, secretValueTag,
secretValueHash secretValueHash
}) => ({ }) => ({
_id: new Types.ObjectId(),
secret: _id, secret: _id,
version, version,
workspace, workspace,
@@ -280,7 +323,7 @@ const v1PushSecrets = async ({
// (EE) take a secret snapshot // (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({ await EESecretService.takeSecretSnapshot({
workspaceId workspaceId
}) });
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);
Sentry.captureException(err); Sentry.captureException(err);
@@ -527,6 +570,7 @@ const v1PushSecrets = async ({
environment: string; environment: string;
}): Promise<ISecret[]> => { }): Promise<ISecret[]> => {
let secrets: any; // TODO: FIX any let secrets: any; // TODO: FIX any
try { try {
// get shared workspace secrets // get shared workspace secrets
const sharedSecrets = await Secret.find({ const sharedSecrets = await Secret.find({
@@ -655,6 +699,7 @@ const reformatPullSecrets = ({ secrets }: { secrets: ISecret[] }) => {
}; };
export { export {
validateSecrets,
v1PushSecrets, v1PushSecrets,
v2PushSecrets, v2PushSecrets,
pullSecrets, pullSecrets,

View File

@@ -9,14 +9,9 @@ import {
INTEGRATION_GITHUB, INTEGRATION_GITHUB,
INTEGRATION_HEROKU_API_URL, INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL, INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL, INTEGRATION_NETLIFY_API_URL
INTEGRATION_GITHUB_API_URL
} from '../variables'; } from '../variables';
interface GitHubApp {
name: string;
}
/** /**
* Return list of names of apps for integration named [integration] * Return list of names of apps for integration named [integration]
* @param {Object} obj * @param {Object} obj
@@ -47,6 +42,7 @@ const getApps = async ({
break; break;
case INTEGRATION_VERCEL: case INTEGRATION_VERCEL:
apps = await getAppsVercel({ apps = await getAppsVercel({
integrationAuth,
accessToken accessToken
}); });
break; break;
@@ -110,17 +106,28 @@ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => {
* @returns {Object[]} apps - names of Vercel apps * @returns {Object[]} apps - names of Vercel apps
* @returns {String} apps.name - name of Vercel app * @returns {String} apps.name - name of Vercel app
*/ */
const getAppsVercel = async ({ accessToken }: { accessToken: string }) => { const getAppsVercel = async ({
integrationAuth,
accessToken
}: {
integrationAuth: IIntegrationAuth;
accessToken: string;
}) => {
let apps; let apps;
try { try {
const res = ( const res = (
await axios.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, { await axios.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, {
headers: { headers: {
Authorization: `Bearer ${accessToken}` Authorization: `Bearer ${accessToken}`
},
...( integrationAuth?.teamId ? {
params: {
teamId: integrationAuth.teamId
} }
} : {})
}) })
).data; ).data;
apps = res.projects.map((a: any) => ({ apps = res.projects.map((a: any) => ({
name: a.name name: a.name
})); }));

View File

@@ -8,8 +8,7 @@ import {
INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL,
INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL,
INTEGRATION_NETLIFY_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL,
INTEGRATION_GITHUB_TOKEN_URL, INTEGRATION_GITHUB_TOKEN_URL
INTEGRATION_GITHUB_API_URL
} from '../variables'; } from '../variables';
import { import {
SITE_URL, SITE_URL,

View File

@@ -1,4 +1,4 @@
import axios from 'axios'; import axios, { AxiosError } from 'axios';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
// import * as sodium from 'libsodium-wrappers'; // import * as sodium from 'libsodium-wrappers';
@@ -12,14 +12,10 @@ import {
INTEGRATION_GITHUB, INTEGRATION_GITHUB,
INTEGRATION_HEROKU_API_URL, INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL, INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL, INTEGRATION_NETLIFY_API_URL
INTEGRATION_GITHUB_API_URL
} from '../variables'; } from '../variables';
import { access, appendFile } from 'fs'; import { access, appendFile } from 'fs';
// 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] * Sync/push [secrets] to [app] in integration named [integration]
* @param {Object} obj * @param {Object} obj
@@ -53,6 +49,7 @@ const syncSecrets = async ({
case INTEGRATION_VERCEL: case INTEGRATION_VERCEL:
await syncSecretsVercel({ await syncSecretsVercel({
integration, integration,
integrationAuth,
secrets, secrets,
accessToken accessToken
}); });
@@ -139,14 +136,15 @@ const syncSecretsHeroku = async ({
*/ */
const syncSecretsVercel = async ({ const syncSecretsVercel = async ({
integration, integration,
integrationAuth,
secrets, secrets,
accessToken accessToken
}: { }: {
integration: IIntegration, integration: IIntegration,
integrationAuth: IIntegrationAuth,
secrets: any; secrets: any;
accessToken: string; accessToken: string;
}) => { }) => {
interface VercelSecret { interface VercelSecret {
id?: string; id?: string;
type: string; type: string;
@@ -156,129 +154,135 @@ const syncSecretsVercel = async ({
} }
try { try {
// Get all (decrypted) secrets back from Vercel in // Get all (decrypted) secrets back from Vercel in
// decrypted format // decrypted format
const params = new URLSearchParams({ const params: { [key: string]: string } = {
decrypt: "true" decrypt: 'true',
}); ...( integrationAuth?.teamId ? {
teamId: integrationAuth.teamId
const res = (await Promise.all((await axios.get( } : {})
`${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env`, }
{
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}`,
{
params, params,
headers: { headers: {
Authorization: `Bearer ${accessToken}` Authorization: `Bearer ${accessToken}`
} }
} }
)) )).data)
.data )).reduce((obj: any, secret: any) => ({
.envs ...obj,
.filter((secret: VercelSecret) => secret.target.includes(integration.target)) [secret.key]: secret
.map(async (secret: VercelSecret) => (await axios.get( }), {});
`${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`,
{ 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,
{
params,
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,
{
params,
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}`,
{
params,
headers: { headers: {
Authorization: `Bearer ${accessToken}` 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) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);
Sentry.captureException(err); Sentry.captureException(err);
throw new Error('Failed to sync secrets to Vercel'); throw new Error('Failed to sync secrets to Vercel');
} }
} }
@@ -302,188 +306,188 @@ const syncSecretsNetlify = async ({
}) => { }) => {
try { try {
interface NetlifyValue { interface NetlifyValue {
id?: string; id?: string;
context: string; // 'dev' | 'branch-deploy' | 'deploy-preview' | 'production', context: string; // 'dev' | 'branch-deploy' | 'deploy-preview' | 'production',
value: string; value: string;
} }
interface NetlifySecret { interface NetlifySecret {
key: string; key: string;
values: NetlifyValue[]; values: NetlifyValue[];
} }
interface NetlifySecretsRes { interface NetlifySecretsRes {
[index: string]: NetlifySecret; [index: string]: NetlifySecret;
} }
const getParams = new URLSearchParams({ const getParams = new URLSearchParams({
context_name: 'all', // integration.context or all context_name: 'all', // integration.context or all
site_id: integration.siteId site_id: integration.siteId
}); });
const res = (await axios.get( const res = (await axios.get(
`${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`,
{ {
params: getParams, params: getParams,
headers: { headers: {
Authorization: `Bearer ${accessToken}` Authorization: `Bearer ${accessToken}`
} }
} }
)) ))
.data .data
.reduce((obj: any, secret: any) => ({ .reduce((obj: any, secret: any) => ({
...obj, ...obj,
[secret.key]: secret [secret.key]: secret
}), {}); }), {});
const newSecrets: NetlifySecret[] = []; // createEnvVars const newSecrets: NetlifySecret[] = []; // createEnvVars
const deleteSecrets: string[] = []; // deleteEnvVar const deleteSecrets: string[] = []; // deleteEnvVar
const deleteSecretValues: NetlifySecret[] = []; // deleteEnvVarValue const deleteSecretValues: NetlifySecret[] = []; // deleteEnvVarValue
const updateSecrets: NetlifySecret[] = []; // setEnvVarValue 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
}]
});
}
}
});
}
});
// identify secrets to create and update const syncParams = new URLSearchParams({
Object.keys(secrets).map((key) => { site_id: integration.siteId
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({ if (newSecrets.length > 0) {
site_id: integration.siteId await axios.post(
}); `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`,
newSecrets,
{
params: syncParams,
headers: {
Authorization: `Bearer ${accessToken}`
}
}
);
}
if (newSecrets.length > 0) { if (updateSecrets.length > 0) {
await axios.post( updateSecrets.forEach(async (secret: NetlifySecret) => {
`${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, await axios.patch(
newSecrets, `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`,
{ {
params: syncParams, context: secret.values[0].context,
headers: { value: secret.values[0].value
Authorization: `Bearer ${accessToken}` },
} {
} params: syncParams,
); headers: {
} Authorization: `Bearer ${accessToken}`
}
}
);
});
}
if (updateSecrets.length > 0) { if (deleteSecrets.length > 0) {
updateSecrets.forEach(async (secret: NetlifySecret) => { deleteSecrets.forEach(async (key: string) => {
await axios.patch( await axios.delete(
`${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${key}`,
{ {
context: secret.values[0].context, params: syncParams,
value: secret.values[0].value headers: {
}, Authorization: `Bearer ${accessToken}`
{ }
params: syncParams, }
headers: { );
Authorization: `Bearer ${accessToken}` });
} }
}
);
});
}
if (deleteSecrets.length > 0) { if (deleteSecretValues.length > 0) {
deleteSecrets.forEach(async (key: string) => { deleteSecretValues.forEach(async (secret: NetlifySecret) => {
await axios.delete( await axios.delete(
`${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${key}`, `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}/value/${secret.values[0].id}`,
{ {
params: syncParams, params: syncParams,
headers: { headers: {
Authorization: `Bearer ${accessToken}` 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) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);
Sentry.captureException(err); Sentry.captureException(err);
throw new Error('Failed to sync secrets to Heroku'); throw new Error('Failed to sync secrets to Heroku');
} }
} }

View File

@@ -8,6 +8,7 @@ import requireIntegrationAuthorizationAuth from './requireIntegrationAuthorizati
import requireServiceTokenAuth from './requireServiceTokenAuth'; import requireServiceTokenAuth from './requireServiceTokenAuth';
import requireServiceTokenDataAuth from './requireServiceTokenDataAuth'; import requireServiceTokenDataAuth from './requireServiceTokenDataAuth';
import requireSecretAuth from './requireSecretAuth'; import requireSecretAuth from './requireSecretAuth';
import requireSecretsAuth from './requireSecretsAuth';
import validateRequest from './validateRequest'; import validateRequest from './validateRequest';
export { export {
@@ -21,5 +22,6 @@ export {
requireServiceTokenAuth, requireServiceTokenAuth,
requireServiceTokenDataAuth, requireServiceTokenDataAuth,
requireSecretAuth, requireSecretAuth,
requireSecretsAuth,
validateRequest validateRequest
}; };

View File

@@ -5,6 +5,9 @@ import {
validateMembership validateMembership
} from '../helpers/membership'; } from '../helpers/membership';
// note: used for old /v1/secret and /v2/secret routes.
// newer /v2/secrets routes use [requireSecretsAuth] middleware
/** /**
* Validate if user on request has proper membership to modify secret. * Validate if user on request has proper membership to modify secret.
* @param {Object} obj * @param {Object} obj
@@ -34,7 +37,7 @@ const requireSecretAuth = ({
acceptedRoles acceptedRoles
}); });
req.secret = secret as any; req._secret = secret;
next(); next();
} catch (err) { } catch (err) {

View File

@@ -0,0 +1,49 @@
import { Request, Response, NextFunction } from 'express';
import { UnauthorizedRequestError } from '../utils/errors';
import { Secret, Membership } from '../models';
import { validateSecrets } from '../helpers/secret';
// TODO: make this work for delete route
const requireSecretsAuth = ({
acceptedRoles
}: {
acceptedRoles: string[];
}) => {
return async (req: Request, res: Response, next: NextFunction) => {
let secrets;
try {
if (Array.isArray(req.body.secrets)) {
// case: validate multiple secrets
secrets = await validateSecrets({
userId: req.user._id.toString(),
secretIds: req.body.secrets.map((s: any) => s.id)
});
} else if (typeof req.body.secrets === 'object') { // change this to check for object
// case: validate 1 secret
secrets = await validateSecrets({
userId: req.user._id.toString(),
secretIds: req.body.secrets.id
});
} else if (Array.isArray(req.body.secretIds)) {
secrets = await validateSecrets({
userId: req.user._id.toString(),
secretIds: req.body.secretIds
});
} else if (typeof req.body.secretIds === 'string') {
// case: validate secretIds
secrets = await validateSecrets({
userId: req.user._id.toString(),
secretIds: [req.body.secretIds]
});
}
req.secrets = secrets;
return next();
} catch (err) {
return next(UnauthorizedRequestError({ message: 'Unable to authenticate secret(s)' }));
}
}
}
export default requireSecretsAuth;

View File

@@ -1,9 +1,5 @@
import { Schema, model, Types } from 'mongoose'; import { Schema, model, Types } from 'mongoose';
import { import {
ENV_DEV,
ENV_TESTING,
ENV_STAGING,
ENV_PROD,
INTEGRATION_HEROKU, INTEGRATION_HEROKU,
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
@@ -13,7 +9,7 @@ import {
export interface IIntegration { export interface IIntegration {
_id: Types.ObjectId; _id: Types.ObjectId;
workspace: Types.ObjectId; workspace: Types.ObjectId;
environment: 'dev' | 'test' | 'staging' | 'prod'; environment: string;
isActive: boolean; isActive: boolean;
app: string; app: string;
target: string; target: string;
@@ -32,7 +28,6 @@ const integrationSchema = new Schema<IIntegration>(
}, },
environment: { environment: {
type: String, type: String,
enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD],
required: true required: true
}, },
isActive: { isActive: {

View File

@@ -2,10 +2,6 @@ import { Schema, model, Types } from 'mongoose';
import { import {
SECRET_SHARED, SECRET_SHARED,
SECRET_PERSONAL, SECRET_PERSONAL,
ENV_DEV,
ENV_TESTING,
ENV_STAGING,
ENV_PROD
} from '../variables'; } from '../variables';
export interface ISecret { export interface ISecret {
@@ -53,7 +49,6 @@ const secretSchema = new Schema<ISecret>(
}, },
environment: { environment: {
type: String, type: String,
enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD],
required: true required: true
}, },
secretKeyCiphertext: { secretKeyCiphertext: {
@@ -69,8 +64,7 @@ const secretSchema = new Schema<ISecret>(
required: true required: true
}, },
secretKeyHash: { secretKeyHash: {
type: String, type: String
required: true
}, },
secretValueCiphertext: { secretValueCiphertext: {
type: String, type: String,
@@ -85,8 +79,7 @@ const secretSchema = new Schema<ISecret>(
required: true required: true
}, },
secretValueHash: { secretValueHash: {
type: String, type: String
required: true
}, },
secretCommentCiphertext: { secretCommentCiphertext: {
type: String, type: String,

View File

@@ -1,7 +1,4 @@
import { Schema, model, Types } from 'mongoose'; import { Schema, model, Types } from 'mongoose';
import { ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD } from '../variables';
// TODO: deprecate
export interface IServiceToken { export interface IServiceToken {
_id: Types.ObjectId; _id: Types.ObjectId;
name: string; name: string;
@@ -33,7 +30,6 @@ const serviceTokenSchema = new Schema<IServiceToken>(
}, },
environment: { environment: {
type: String, type: String,
enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD],
required: true required: true
}, },
expiresAt: { expiresAt: {

View File

@@ -4,6 +4,10 @@ export interface IWorkspace {
_id: Types.ObjectId; _id: Types.ObjectId;
name: string; name: string;
organization: Types.ObjectId; organization: Types.ObjectId;
environments: Array<{
name: string;
slug: string;
}>;
} }
const workspaceSchema = new Schema<IWorkspace>({ const workspaceSchema = new Schema<IWorkspace>({
@@ -15,7 +19,33 @@ const workspaceSchema = new Schema<IWorkspace>({
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'Organization', ref: 'Organization',
required: true required: true
} },
environments: {
type: [
{
name: String,
slug: String,
},
],
default: [
{
name: "development",
slug: "dev"
},
{
name: "test",
slug: "test"
},
{
name: "staging",
slug: "staging"
},
{
name: "production",
slug: "prod"
}
],
},
}); });
const Workspace = model<IWorkspace>('Workspace', workspaceSchema); const Workspace = model<IWorkspace>('Workspace', workspaceSchema);

View File

@@ -0,0 +1,5 @@
import healthCheck from './status';
export {
healthCheck
}

View File

@@ -0,0 +1,15 @@
import express, { Request, Response } from 'express';
const router = express.Router();
router.get(
'/status',
(req: Request, res: Response) => {
res.status(200).json({
date: new Date(),
message: 'Ok',
})
}
);
export default router

View File

@@ -0,0 +1,57 @@
import express, { Response, Request } from 'express';
const router = express.Router();
import { body, param } from 'express-validator';
import { environmentController } from '../../controllers/v2';
import {
requireAuth,
requireWorkspaceAuth,
validateRequest,
} from '../../middleware';
import { ADMIN, MEMBER } from '../../variables';
router.post(
'/:workspaceId/environments',
requireAuth({
acceptedAuthModes: ['jwt'],
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
}),
param('workspaceId').exists().trim(),
body('environmentSlug').exists().trim(),
body('environmentName').exists().trim(),
validateRequest,
environmentController.createWorkspaceEnvironment
);
router.put(
'/:workspaceId/environments',
requireAuth({
acceptedAuthModes: ['jwt'],
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
}),
param('workspaceId').exists().trim(),
body('environmentSlug').exists().trim(),
body('environmentName').exists().trim(),
body('oldEnvironmentSlug').exists().trim(),
validateRequest,
environmentController.renameWorkspaceEnvironment
);
router.delete(
'/:workspaceId/environments',
requireAuth({
acceptedAuthModes: ['jwt'],
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN],
}),
param('workspaceId').exists().trim(),
body('environmentSlug').exists().trim(),
validateRequest,
environmentController.deleteWorkspaceEnvironment
);
export default router;

View File

@@ -1,11 +1,15 @@
import secret from './secret'; import secret from './secret'; // stop-supporting
import secrets from './secrets';
import workspace from './workspace'; import workspace from './workspace';
import serviceTokenData from './serviceTokenData'; import serviceTokenData from './serviceTokenData';
import apiKeyData from './apiKeyData'; import apiKeyData from './apiKeyData';
import environment from "./environment"
export { export {
secret, secret,
secrets,
workspace, workspace,
serviceTokenData, serviceTokenData,
apiKeyData apiKeyData,
} environment
}

View File

@@ -1,18 +1,21 @@
import express, { Request, Response } from 'express'; import express from 'express';
import { requireAuth, requireWorkspaceAuth, validateRequest } from '../../middleware'; import {
requireAuth,
requireWorkspaceAuth,
requireSecretAuth,
validateRequest
} from '../../middleware';
import { body, param, query } from 'express-validator'; import { body, param, query } from 'express-validator';
import { ADMIN, MEMBER } from '../../variables'; import { ADMIN, MEMBER } from '../../variables';
import { CreateSecretRequestBody, ModifySecretRequestBody } from '../../types/secret'; import { CreateSecretRequestBody, ModifySecretRequestBody } from '../../types/secret';
import { secretController } from '../../controllers/v2'; import { secretController } from '../../controllers/v2';
import { fetchAllSecrets } from '../../controllers/v2/secretController';
// note to devs: stop supporting
const router = express.Router(); const router = express.Router();
/**
* Create many secrets for a given workspace and environmentName
*/
router.post( router.post(
'/batch-create/workspace/:workspaceId/environment/:environmentName', '/batch-create/workspace/:workspaceId/environment/:environment',
requireAuth({ requireAuth({
acceptedAuthModes: ['jwt'] acceptedAuthModes: ['jwt']
}), }),
@@ -20,15 +23,29 @@ router.post(
acceptedRoles: [ADMIN, MEMBER] acceptedRoles: [ADMIN, MEMBER]
}), }),
param('workspaceId').exists().isMongoId().trim(), param('workspaceId').exists().isMongoId().trim(),
param('environmentName').exists().trim(), param('environment').exists().trim(),
body('secrets').exists().isArray().custom((value) => value.every((item: CreateSecretRequestBody) => typeof item === 'object')), body('secrets').exists().isArray().custom((value) => value.every((item: CreateSecretRequestBody) => typeof item === 'object')),
body('channel'),
validateRequest, validateRequest,
secretController.batchCreateSecrets secretController.createSecrets
);
router.post(
'/workspace/:workspaceId/environment/:environment',
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER]
}),
param('workspaceId').exists().isMongoId().trim(),
param('environment').exists().trim(),
body('secret').exists().isObject(),
body('channel'),
validateRequest,
secretController.createSecret
); );
/**
* Get all secrets for a given environment and workspace id
*/
router.get( router.get(
'/workspace/:workspaceId', '/workspace/:workspaceId',
param('workspaceId').exists().trim(), param('workspaceId').exists().trim(),
@@ -39,13 +56,23 @@ router.get(
requireWorkspaceAuth({ requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER] acceptedRoles: [ADMIN, MEMBER]
}), }),
query('channel'),
validateRequest, validateRequest,
fetchAllSecrets secretController.getSecrets
);
router.get(
'/:secretId',
requireAuth({
acceptedAuthModes: ['jwt', 'serviceToken']
}),
requireSecretAuth({
acceptedRoles: [ADMIN, MEMBER]
}),
validateRequest,
secretController.getSecret
); );
/**
* Batch delete secrets in a given workspace and environment name
*/
router.delete( router.delete(
'/batch/workspace/:workspaceId/environment/:environmentName', '/batch/workspace/:workspaceId/environment/:environmentName',
requireAuth({ requireAuth({
@@ -58,13 +85,22 @@ router.delete(
acceptedRoles: [ADMIN, MEMBER] acceptedRoles: [ADMIN, MEMBER]
}), }),
validateRequest, validateRequest,
secretController.batchDeleteSecrets secretController.deleteSecrets
);
router.delete(
'/:secretId',
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireSecretAuth({
acceptedRoles: [ADMIN, MEMBER]
}),
param('secretId').isMongoId(),
validateRequest,
secretController.deleteSecret
); );
/**
* Apply modifications to many existing secrets in a given workspace and environment
*/
router.patch( router.patch(
'/batch-modify/workspace/:workspaceId/environment/:environmentName', '/batch-modify/workspace/:workspaceId/environment/:environmentName',
requireAuth({ requireAuth({
@@ -77,7 +113,23 @@ router.patch(
acceptedRoles: [ADMIN, MEMBER] acceptedRoles: [ADMIN, MEMBER]
}), }),
validateRequest, validateRequest,
secretController.batchModifySecrets secretController.updateSecrets
);
router.patch(
'/workspace/:workspaceId/environment/:environmentName',
requireAuth({
acceptedAuthModes: ['jwt']
}),
body('secret').isObject(),
param('workspaceId').exists().isMongoId().trim(),
param('environmentName').exists().trim(),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER]
}),
validateRequest,
secretController.updateSecret
); );
export default router; export default router;

View File

@@ -0,0 +1,157 @@
import express from 'express';
const router = express.Router();
import {
requireAuth,
requireWorkspaceAuth,
requireSecretsAuth,
validateRequest
} from '../../middleware';
import { query, check, body } from 'express-validator';
import { secretsController } from '../../controllers/v2';
import {
ADMIN,
MEMBER,
SECRET_PERSONAL,
SECRET_SHARED
} from '../../variables';
router.post(
'/',
body('workspaceId').exists().isString().trim(),
body('environment').exists().isString().trim(),
body('secrets')
.exists()
.custom((value) => {
if (Array.isArray(value)) {
// case: create multiple secrets
if (value.length === 0) throw new Error('secrets cannot be an empty array')
for (const secret of value) {
if (
!secret.type ||
!(secret.type === SECRET_PERSONAL || secret.type === SECRET_SHARED) ||
!secret.secretKeyCiphertext ||
!secret.secretKeyIV ||
!secret.secretKeyTag ||
!secret.secretValueCiphertext ||
!secret.secretValueIV ||
!secret.secretValueTag
) {
throw new Error('secrets array must contain objects that have required secret properties');
}
}
} else if (typeof value === 'object') {
// case: update 1 secret
if (
!value.type ||
!(value.type === SECRET_PERSONAL || value.type === SECRET_SHARED) ||
!value.secretKeyCiphertext ||
!value.secretKeyIV ||
!value.secretKeyTag ||
!value.secretValueCiphertext ||
!value.secretValueIV ||
!value.secretValueTag
) {
throw new Error('secrets object is missing required secret properties');
}
} else {
throw new Error('secrets must be an object or an array of objects')
}
return true;
}),
validateRequest,
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
location: 'body'
}),
secretsController.createSecrets
);
router.get(
'/',
query('workspaceId').exists().trim(),
query('environment').exists().trim(),
validateRequest,
requireAuth({
acceptedAuthModes: ['jwt', 'serviceToken']
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
location: 'query'
}),
secretsController.getSecrets
);
router.patch(
'/',
body('secrets')
.exists()
.custom((value) => {
if (Array.isArray(value)) {
// case: update multiple secrets
if (value.length === 0) throw new Error('secrets cannot be an empty array')
for (const secret of value) {
if (
!secret.id
) {
throw new Error('Each secret must contain a ID property');
}
}
} else if (typeof value === 'object') {
// case: update 1 secret
if (
!value.id
) {
throw new Error('secret must contain a ID property');
}
} else {
throw new Error('secrets must be an object or an array of objects')
}
return true;
}),
validateRequest,
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireSecretsAuth({
acceptedRoles: [ADMIN, MEMBER]
}),
secretsController.updateSecrets
);
router.delete(
'/',
body('secretIds')
.exists()
.custom((value) => {
// case: delete 1 secret
if (typeof value === 'string') return true;
if (Array.isArray(value)) {
// case: delete multiple secrets
if (value.length === 0) throw new Error('secrets cannot be an empty array');
return value.every((id: string) => typeof id === 'string')
}
throw new Error('secretIds must be a string or an array of strings');
})
.not()
.isEmpty(),
validateRequest,
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireSecretsAuth({
acceptedRoles: [ADMIN, MEMBER]
}),
secretsController.deleteSecrets
);
export default router;

View File

@@ -11,10 +11,6 @@ import {
setIntegrationAuthAccessHelper, setIntegrationAuthAccessHelper,
} from '../helpers/integration'; } from '../helpers/integration';
import { exchangeCode } from '../integrations'; import { exchangeCode } from '../integrations';
import {
ENV_DEV,
EVENT_PUSH_SECRETS
} from '../variables';
// should sync stuff be here too? Probably. // should sync stuff be here too? Probably.
// TODO: move bot functions to IntegrationService. // TODO: move bot functions to IntegrationService.
@@ -32,22 +28,26 @@ class IntegrationService {
* - Create bot sequence for integration * - Create bot sequence for integration
* @param {Object} obj * @param {Object} obj
* @param {String} obj.workspaceId - id of workspace * @param {String} obj.workspaceId - id of workspace
* @param {String} obj.environment - workspace environment
* @param {String} obj.integration - name of integration * @param {String} obj.integration - name of integration
* @param {String} obj.code - code * @param {String} obj.code - code
*/ */
static async handleOAuthExchange({ static async handleOAuthExchange({
workspaceId, workspaceId,
integration, integration,
code code,
environment
}: { }: {
workspaceId: string; workspaceId: string;
integration: string; integration: string;
code: string; code: string;
environment: string;
}) { }) {
await handleOAuthExchangeHelper({ await handleOAuthExchangeHelper({
workspaceId, workspaceId,
integration, integration,
code code,
environment
}); });
} }

View File

@@ -7,12 +7,12 @@ import {
} from '../config'; } from '../config';
import { getLogger } from '../utils/logger'; import { getLogger } from '../utils/logger';
if(TELEMETRY_ENABLED){ if(!TELEMETRY_ENABLED){
getLogger("backend-main").info([ getLogger("backend-main").info([
"", "",
"Infisical collects telemetry data about general usage.", "To improve, Infisical collects telemetry data about general usage.",
"The data helps us understand how the product is doing and guide our product development to create the best possible platform; it also helps us demonstrate growth for investors as we support Infisical as open-source software.", "This helps us understand how the product is doing and guide our product development to create the best possible platform; it also helps us demonstrate growth as we support Infisical as open-source software.",
"To opt out of telemetry, you can set `TELEMETRY_ENABLED=false` within the environment variables", "To opt into telemetry, you can set `TELEMETRY_ENABLED=true` within the environment variables.",
].join('\n')) ].join('\n'))
} }

View File

@@ -28,7 +28,13 @@ if (SMTP_SECURE) {
} }
break; break;
default: default:
mailOpts.secure = true; if (SMTP_HOST.includes('amazonaws.com')) {
mailOpts.tls = {
ciphers: 'TLSv1.2'
}
} else {
mailOpts.secure = true;
}
break; break;
} }
} }

View File

@@ -1,4 +1,5 @@
import * as express from 'express'; import * as express from 'express';
import { ISecret } from '../../models';
// TODO: fix (any) types // TODO: fix (any) types
declare global { declare global {
@@ -12,7 +13,8 @@ declare global {
integration: any; integration: any;
integrationAuth: any; integrationAuth: any;
bot: any; bot: any;
secret: any; _secret: any;
secrets: any;
secretSnapshot: any; secretSnapshot: any;
serviceToken: any; serviceToken: any;
accessToken: any; accessToken: any;

View File

@@ -19,7 +19,6 @@ import {
INTEGRATION_HEROKU_API_URL, INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL, INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL, INTEGRATION_NETLIFY_API_URL,
INTEGRATION_GITHUB_API_URL,
INTEGRATION_OPTIONS INTEGRATION_OPTIONS
} from './integration'; } from './integration';
import { import {
@@ -66,7 +65,6 @@ export {
INTEGRATION_HEROKU_API_URL, INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL, INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL, INTEGRATION_NETLIFY_API_URL,
INTEGRATION_GITHUB_API_URL,
EVENT_PUSH_SECRETS, EVENT_PUSH_SECRETS,
EVENT_PULL_SECRETS, EVENT_PULL_SECRETS,
ACTION_ADD_SECRETS, ACTION_ADD_SECRETS,

View File

@@ -11,10 +11,10 @@ const INTEGRATION_VERCEL = 'vercel';
const INTEGRATION_NETLIFY = 'netlify'; const INTEGRATION_NETLIFY = 'netlify';
const INTEGRATION_GITHUB = 'github'; const INTEGRATION_GITHUB = 'github';
const INTEGRATION_SET = new Set([ const INTEGRATION_SET = new Set([
INTEGRATION_HEROKU, INTEGRATION_HEROKU,
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_GITHUB INTEGRATION_GITHUB
]); ]);
// integration types // integration types
@@ -23,22 +23,21 @@ const INTEGRATION_OAUTH2 = 'oauth2';
// integration oauth endpoints // integration oauth endpoints
const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token';
const INTEGRATION_VERCEL_TOKEN_URL = const INTEGRATION_VERCEL_TOKEN_URL =
'https://api.vercel.com/v2/oauth/access_token'; 'https://api.vercel.com/v2/oauth/access_token';
const INTEGRATION_NETLIFY_TOKEN_URL = 'https://api.netlify.com/oauth/token'; const INTEGRATION_NETLIFY_TOKEN_URL = 'https://api.netlify.com/oauth/token';
const INTEGRATION_GITHUB_TOKEN_URL = const INTEGRATION_GITHUB_TOKEN_URL =
'https://github.com/login/oauth/access_token'; 'https://github.com/login/oauth/access_token';
// integration apps endpoints // integration apps endpoints
const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com'; const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com';
const INTEGRATION_VERCEL_API_URL = 'https://api.vercel.com'; const INTEGRATION_VERCEL_API_URL = 'https://api.vercel.com';
const INTEGRATION_NETLIFY_API_URL = 'https://api.netlify.com'; const INTEGRATION_NETLIFY_API_URL = 'https://api.netlify.com';
const INTEGRATION_GITHUB_API_URL = 'https://api.github.com';
const INTEGRATION_OPTIONS = [ const INTEGRATION_OPTIONS = [
{ {
name: 'Heroku', name: 'Heroku',
slug: 'heroku', slug: 'heroku',
image: 'Heroku', image: 'Heroku',
isAvailable: true, isAvailable: true,
type: 'oauth2', type: 'oauth2',
clientId: CLIENT_ID_HEROKU, clientId: CLIENT_ID_HEROKU,
@@ -47,8 +46,8 @@ const INTEGRATION_OPTIONS = [
{ {
name: 'Vercel', name: 'Vercel',
slug: 'vercel', slug: 'vercel',
image: 'Vercel', image: 'Vercel',
isAvailable: false, isAvailable: true,
type: 'vercel', type: 'vercel',
clientId: '', clientId: '',
clientSlug: CLIENT_SLUG_VERCEL, clientSlug: CLIENT_SLUG_VERCEL,
@@ -57,7 +56,7 @@ const INTEGRATION_OPTIONS = [
{ {
name: 'Netlify', name: 'Netlify',
slug: 'netlify', slug: 'netlify',
image: 'Netlify', image: 'Netlify',
isAvailable: false, isAvailable: false,
type: 'oauth2', type: 'oauth2',
clientId: CLIENT_ID_NETLIFY, clientId: CLIENT_ID_NETLIFY,
@@ -66,17 +65,17 @@ const INTEGRATION_OPTIONS = [
{ {
name: 'GitHub', name: 'GitHub',
slug: 'github', slug: 'github',
image: 'GitHub', image: 'GitHub',
isAvailable: false, isAvailable: false,
type: 'oauth2', type: 'oauth2',
clientId: CLIENT_ID_GITHUB, clientId: CLIENT_ID_GITHUB,
docsLink: '' docsLink: ''
}, },
{ {
name: 'Google Cloud Platform', name: 'Google Cloud Platform',
slug: 'gcp', slug: 'gcp',
image: 'Google Cloud Platform', image: 'Google Cloud Platform',
isAvailable: false, isAvailable: false,
type: '', type: '',
clientId: '', clientId: '',
@@ -85,7 +84,7 @@ const INTEGRATION_OPTIONS = [
{ {
name: 'Amazon Web Services', name: 'Amazon Web Services',
slug: 'aws', slug: 'aws',
image: 'Amazon Web Services', image: 'Amazon Web Services',
isAvailable: false, isAvailable: false,
type: '', type: '',
clientId: '', clientId: '',
@@ -94,7 +93,7 @@ const INTEGRATION_OPTIONS = [
{ {
name: 'Microsoft Azure', name: 'Microsoft Azure',
slug: 'azure', slug: 'azure',
image: 'Microsoft Azure', image: 'Microsoft Azure',
isAvailable: false, isAvailable: false,
type: '', type: '',
clientId: '', clientId: '',
@@ -103,7 +102,7 @@ const INTEGRATION_OPTIONS = [
{ {
name: 'Travis CI', name: 'Travis CI',
slug: 'travisci', slug: 'travisci',
image: 'Travis CI', image: 'Travis CI',
isAvailable: false, isAvailable: false,
type: '', type: '',
clientId: '', clientId: '',
@@ -112,7 +111,7 @@ const INTEGRATION_OPTIONS = [
{ {
name: 'Circle CI', name: 'Circle CI',
slug: 'circleci', slug: 'circleci',
image: 'Circle CI', image: 'Circle CI',
isAvailable: false, isAvailable: false,
type: '', type: '',
clientId: '', clientId: '',
@@ -121,19 +120,18 @@ const INTEGRATION_OPTIONS = [
] ]
export { export {
INTEGRATION_HEROKU, INTEGRATION_HEROKU,
INTEGRATION_VERCEL, INTEGRATION_VERCEL,
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_GITHUB, INTEGRATION_GITHUB,
INTEGRATION_SET, INTEGRATION_SET,
INTEGRATION_OAUTH2, INTEGRATION_OAUTH2,
INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL,
INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL,
INTEGRATION_NETLIFY_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL,
INTEGRATION_GITHUB_TOKEN_URL, INTEGRATION_GITHUB_TOKEN_URL,
INTEGRATION_HEROKU_API_URL, INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL, INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL, INTEGRATION_NETLIFY_API_URL,
INTEGRATION_GITHUB_API_URL, INTEGRATION_OPTIONS
INTEGRATION_OPTIONS
}; };

22
backend/swagger.ts Normal file
View File

@@ -0,0 +1,22 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' });
const doc = {
info: {
title: 'Infisical API',
description: 'List of all available APIs that can be consumed',
},
host: ['https://infisical.com'],
securityDefinitions: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT'
}
}
};
const outputFile = './api-documentation.json';
const endpointsFiles = ['./src/app.ts'];
swaggerAutogen(outputFile, endpointsFiles, doc);

View File

@@ -31,8 +31,8 @@ var exportCmd = &cobra.Command{
Args: cobra.NoArgs, Args: cobra.NoArgs,
PreRun: func(cmd *cobra.Command, args []string) { PreRun: func(cmd *cobra.Command, args []string) {
toggleDebug(cmd, args) toggleDebug(cmd, args)
util.RequireLogin() // util.RequireLogin()
util.RequireLocalWorkspaceFile() // util.RequireLocalWorkspaceFile()
}, },
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
envName, err := cmd.Flags().GetString("env") envName, err := cmd.Flags().GetString("env")

View File

@@ -6,6 +6,7 @@ package cmd
import ( import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"strings"
"errors" "errors"
"fmt" "fmt"
@@ -17,6 +18,7 @@ import (
"github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/models"
"github.com/Infisical/infisical-merge/packages/srp" "github.com/Infisical/infisical-merge/packages/srp"
"github.com/Infisical/infisical-merge/packages/util" "github.com/Infisical/infisical-merge/packages/util"
"github.com/fatih/color"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
"github.com/manifoldco/promptui" "github.com/manifoldco/promptui"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -31,7 +33,9 @@ var loginCmd = &cobra.Command{
PreRun: toggleDebug, PreRun: toggleDebug,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
currentLoggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() currentLoggedInUserDetails, err := util.GetCurrentLoggedInUserDetails()
if err != nil { if err != nil && strings.Contains(err.Error(), "The specified item could not be found in the keyring") { // if the key can't be found allow them to override
log.Debug(err)
} else if err != nil {
util.HandleError(err) util.HandleError(err)
} }
@@ -97,7 +101,7 @@ var loginCmd = &cobra.Command{
util.HandleError(err, "Unable to write write to Infisical Config file. Please try again") util.HandleError(err, "Unable to write write to Infisical Config file. Please try again")
} }
log.Infoln("Nice! You are loggin as:", email) color.Green("Nice! You are logged in as: %v", email)
}, },
} }

View File

@@ -15,7 +15,7 @@ var rootCmd = &cobra.Command{
Short: "Infisical CLI is used to inject environment variables into any process", Short: "Infisical CLI is used to inject environment variables into any process",
Long: `Infisical is a simple, end-to-end encrypted service that enables teams to sync and manage their environment variables across their development life cycle.`, Long: `Infisical is a simple, end-to-end encrypted service that enables teams to sync and manage their environment variables across their development life cycle.`,
CompletionOptions: cobra.CompletionOptions{HiddenDefaultCmd: true}, CompletionOptions: cobra.CompletionOptions{HiddenDefaultCmd: true},
Version: "0.2.0", Version: "0.2.1",
} }
// Execute adds all child commands to the root command and sets flags appropriately. // Execute adds all child commands to the root command and sets flags appropriately.

View File

@@ -12,8 +12,8 @@ import (
"strings" "strings"
"syscall" "syscall"
"github.com/Infisical/infisical-merge/packages/models"
"github.com/Infisical/infisical-merge/packages/util" "github.com/Infisical/infisical-merge/packages/util"
"github.com/fatih/color"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -85,16 +85,50 @@ var runCmd = &cobra.Command{
secrets = util.OverrideWithPersonalSecrets(secrets) secrets = util.OverrideWithPersonalSecrets(secrets)
} }
secretsByKey := getSecretsByKeys(secrets)
environmentVariables := make(map[string]string)
// add all existing environment vars
for _, s := range os.Environ() {
kv := strings.SplitN(s, "=", 2)
key := kv[0]
value := kv[1]
environmentVariables[key] = value
}
// check to see if there are any reserved key words in secrets to inject
reservedEnvironmentVariables := []string{"HOME", "PATH", "PS1", "PS2"}
for _, reservedEnvName := range reservedEnvironmentVariables {
if _, ok := secretsByKey[reservedEnvName]; ok {
delete(secretsByKey, reservedEnvName)
util.PrintWarning(fmt.Sprintf("Infisical secret named [%v] has been removed because it is a reserved secret name", reservedEnvName))
}
}
// now add infisical secrets
for k, v := range secretsByKey {
environmentVariables[k] = v.Value
}
// turn it back into a list of envs
var env []string
for key, value := range environmentVariables {
s := key + "=" + value
env = append(env, s)
}
log.Debugf("injecting the following environment variables into shell: %v", env)
if cmd.Flags().Changed("command") { if cmd.Flags().Changed("command") {
command := cmd.Flag("command").Value.String() command := cmd.Flag("command").Value.String()
err = executeMultipleCommandWithEnvs(command, secrets) err = executeMultipleCommandWithEnvs(command, len(secretsByKey), env)
if err != nil { if err != nil {
util.HandleError(err, "Unable to execute your chained command") util.HandleError(err, "Unable to execute your chained command")
} }
} else { } else {
err = executeSingleCommandWithEnvs(args, secrets) err = executeSingleCommandWithEnvs(args, len(secretsByKey), env)
if err != nil { if err != nil {
util.HandleError(err, "Unable to execute your single command") util.HandleError(err, "Unable to execute your single command")
} }
@@ -111,24 +145,21 @@ func init() {
} }
// Will execute a single command and pass in the given secrets into the process // Will execute a single command and pass in the given secrets into the process
func executeSingleCommandWithEnvs(args []string, secrets []models.SingleEnvironmentVariable) error { func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string) error {
command := args[0] command := args[0]
argsForCommand := args[1:] argsForCommand := args[1:]
numberOfSecretsInjected := fmt.Sprintf("\u2713 Injected %v Infisical secrets into your application process successfully", len(secrets)) color.Green("Injecting %v Infisical secrets into your application process", secretsCount)
log.Infof("\x1b[%dm%s\x1b[0m", 32, numberOfSecretsInjected)
log.Debugf("executing command: %s %s \n", command, strings.Join(argsForCommand, " "))
log.Debugf("Secrets injected: %v", secrets)
cmd := exec.Command(command, argsForCommand...) cmd := exec.Command(command, argsForCommand...)
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
cmd.Env = getAllEnvs(secrets) cmd.Env = env
return execCmd(cmd) return execCmd(cmd)
} }
func executeMultipleCommandWithEnvs(fullCommand string, secrets []models.SingleEnvironmentVariable) error { func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env []string) error {
shell := [2]string{"sh", "-c"} shell := [2]string{"sh", "-c"}
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
shell = [2]string{"cmd", "/C"} shell = [2]string{"cmd", "/C"}
@@ -140,12 +171,10 @@ func executeMultipleCommandWithEnvs(fullCommand string, secrets []models.SingleE
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
cmd.Env = getAllEnvs(secrets) cmd.Env = env
numberOfSecretsInjected := fmt.Sprintf("\u2713 Injected %v Infisical secrets into your application process successfully", len(secrets)) color.Green("Injecting %v Infisical secrets into your application process", secretsCount)
log.Infof("\x1b[%dm%s\x1b[0m", 32, numberOfSecretsInjected)
log.Debugf("executing command: %s %s %s \n", shell[0], shell[1], fullCommand) log.Debugf("executing command: %s %s %s \n", shell[0], shell[1], fullCommand)
log.Debugf("Secrets injected: %v", secrets)
return execCmd(cmd) return execCmd(cmd)
} }
@@ -175,23 +204,3 @@ func execCmd(cmd *exec.Cmd) error {
os.Exit(waitStatus.ExitStatus()) os.Exit(waitStatus.ExitStatus())
return nil return nil
} }
func getAllEnvs(envsToInject []models.SingleEnvironmentVariable) []string {
env_map := make(map[string]string)
for _, env := range os.Environ() {
splitEnv := strings.Split(env, "=")
env_map[splitEnv[0]] = splitEnv[1]
}
for _, env := range envsToInject {
env_map[env.Key] = env.Value // overrite any envs with ones to inject if they clash
}
var allEnvs []string
for key, value := range env_map {
allEnvs = append(allEnvs, fmt.Sprintf("%s=%s", key, value))
}
return allEnvs
}

View File

@@ -311,14 +311,25 @@ var secretsDeleteCmd = &cobra.Command{
func init() { func init() {
secretsCmd.AddCommand(secretsGetCmd) secretsCmd.AddCommand(secretsGetCmd)
secretsCmd.AddCommand(secretsSetCmd) secretsGetCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
secretsCmd.AddCommand(secretsDeleteCmd)
secretsCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on")
secretsCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets")
secretsCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
util.RequireLogin() util.RequireLogin()
util.RequireLocalWorkspaceFile() util.RequireLocalWorkspaceFile()
} }
secretsCmd.AddCommand(secretsSetCmd)
secretsSetCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
util.RequireLogin()
util.RequireLocalWorkspaceFile()
}
secretsCmd.AddCommand(secretsDeleteCmd)
secretsDeleteCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
util.RequireLogin()
util.RequireLocalWorkspaceFile()
}
secretsCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on")
secretsCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets")
rootCmd.AddCommand(secretsCmd) rootCmd.AddCommand(secretsCmd)
} }

View File

@@ -23,6 +23,10 @@ func PrintErrorAndExit(exitCode int, err error, messages ...string) {
os.Exit(exitCode) os.Exit(exitCode)
} }
func PrintWarning(message string) {
color.Yellow("Warning: %v", message)
}
func PrintMessageAndExit(messages ...string) { func PrintMessageAndExit(messages ...string) {
if len(messages) > 0 { if len(messages) > 0 {
for _, message := range messages { for _, message := range messages {

View File

@@ -33,6 +33,7 @@ func GetKeyRing() (keyring.Keyring, error) {
LibSecretCollectionName: KEYRING_SERVICE_NAME, LibSecretCollectionName: KEYRING_SERVICE_NAME,
KWalletAppID: KEYRING_SERVICE_NAME, KWalletAppID: KEYRING_SERVICE_NAME,
KWalletFolder: KEYRING_SERVICE_NAME, KWalletFolder: KEYRING_SERVICE_NAME,
KeychainName: "login", // default so user will not be prompted
KeychainTrustApplication: true, KeychainTrustApplication: true,
WinCredPrefix: KEYRING_SERVICE_NAME, WinCredPrefix: KEYRING_SERVICE_NAME,
FileDir: fmt.Sprintf("~/%s-file-vault", KEYRING_SERVICE_NAME), FileDir: fmt.Sprintf("~/%s-file-vault", KEYRING_SERVICE_NAME),
@@ -53,9 +54,10 @@ func GetKeyRing() (keyring.Keyring, error) {
} }
func fileKeyringPassphrasePrompt(prompt string) (string, error) { func fileKeyringPassphrasePrompt(prompt string) (string, error) {
fmt.Println("You may set the `INFISICAL_VAULT_FILE_PASSPHRASE` environment variable to avoid typing password")
if password, ok := os.LookupEnv("INFISICAL_VAULT_FILE_PASSPHRASE"); ok { if password, ok := os.LookupEnv("INFISICAL_VAULT_FILE_PASSPHRASE"); ok {
return password, nil return password, nil
} else {
fmt.Println("You may set the `INFISICAL_VAULT_FILE_PASSPHRASE` environment variable to avoid typing password")
} }
fmt.Fprintf(os.Stderr, "%s:", prompt) fmt.Fprintf(os.Stderr, "%s:", prompt)

View File

@@ -28,6 +28,8 @@ services:
- ./backend/src:/app/src - ./backend/src:/app/src
- ./backend/nodemon.json:/app/nodemon.json - ./backend/nodemon.json:/app/nodemon.json
- /app/node_modules - /app/node_modules
- ./backend/api-documentation.json:/app/api-documentation.json
- ./backend/swagger.ts:/app/swagger.ts
command: npm run dev command: npm run dev
env_file: .env env_file: .env
environment: environment:
@@ -48,6 +50,7 @@ services:
- ./frontend/public:/app/public - ./frontend/public:/app/public
- ./frontend/styles:/app/styles - ./frontend/styles:/app/styles
- ./frontend/components:/app/components - ./frontend/components:/app/components
- ./frontend/ee:/app/ee
- ./frontend/locales:/app/locales - ./frontend/locales:/app/locales
- ./frontend/next-i18next.config.js:/app/next-i18next.config.js - ./frontend/next-i18next.config.js:/app/next-i18next.config.js
env_file: .env env_file: .env

View File

@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v2/secrets/"
---

View File

@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v2/secrets/"
---

View File

@@ -0,0 +1,4 @@
---
title: "Read"
openapi: "GET /api/v2/secrets/"
---

View File

@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v2/secrets/"
---

View File

@@ -0,0 +1,3 @@
---
title: "Authentication"
---

View File

@@ -0,0 +1,3 @@
---
title: "Introduction"
---

View File

@@ -2,8 +2,10 @@
title: "Activity Logs" title: "Activity Logs"
--- ---
Activity logs record all actions going through Infisical including CRUD operations applied to environment variables. They help answer questions like: Activity logs record all actions going through Infisical including who performed which CRUD operations on environment variables and from what IP address. They help answer questions like:
- Who added or updated environment variables recently? - Who added or updated environment variables recently?
- Did Bob read environment variables last week (if at all)? - Did Bob read environment variables last week (if at all)?
- What IP address was used for that action? - What IP address was used for that action?
![Activity logs](../../images/activity-logs.png)

View File

@@ -2,4 +2,22 @@
title: "Point-in-Time Recovery" title: "Point-in-Time Recovery"
--- ---
Point-in-time (PIT) recovery allows environment variables to be rolled back to any point in time. It's powered by snapshots that get captured after mutations to environment variables. Point-in-time recovery allows environment variables to be rolled back to any point in time. It's powered by snapshots that get captured after mutations to environment variables.
## Commits
Similar to Git, a commit in Infisical is a snapshot of your project's secrets at a specific point in time. You can browse and view your project's snapshots via the "Point-in-Time Recovery" sidebar.
![PIT commits](../../images/pit-commits.png)
![PIT snapshots](../../images/pit-snapshots.png)
## Rolling back
Environment variables can be rolled back to any point in time via the "Rollback to this snapshot" button.
![PIT snapshot](../../images/pit-snapshot.png)
<Note>
Rolling back environment variables to a past snapshot creates a new commit and
snapshot at the top of the stack and updates secret versions.
</Note>

View File

@@ -0,0 +1,14 @@
---
title: "Secret Versioning"
---
Secret versioning records changes made to every secret.
![secret versioning](../../images/secret-versioning.png)
<Note>
You can copy and paste a secret version value to the "Value" input field "roll
back" to that secret version. This creates a new secret version at the top of
the stack. We're releasing the ability to press and automatically roll back to
a secret version soon.
</Note>

View File

@@ -1,5 +0,0 @@
---
title: "Secret Versioning"
---
Secret versioning allows an individual environment variable to be rolled back without touching other project environment variables.

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

BIN
docs/images/pit-commits.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

View File

@@ -81,7 +81,7 @@
"getting-started/dashboard/project", "getting-started/dashboard/project",
"getting-started/dashboard/integrations", "getting-started/dashboard/integrations",
"getting-started/dashboard/pit-recovery", "getting-started/dashboard/pit-recovery",
"getting-started/dashboard/versioning", "getting-started/dashboard/secret-versioning",
"getting-started/dashboard/audit-logs", "getting-started/dashboard/audit-logs",
"getting-started/dashboard/token" "getting-started/dashboard/token"
] ]

View File

@@ -48,7 +48,7 @@ SMTP_FROM_NAME=Infisical
``` ```
<Info> <Info>
Remember that you will need to restart Infisical for this to work properly. Remember that you will need to restart Infisical for this to work properly.
</Info> </Info>
## Mailgun ## Mailgun
@@ -70,6 +70,28 @@ SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out em
SMTP_FROM_NAME=Infisical SMTP_FROM_NAME=Infisical
``` ```
## AWS SES
1. Create an account and [configure AWS SES](https://aws.amazon.com/premiumsupport/knowledge-center/ses-set-up-connect-smtp/) to send emails in the Amazon SES console.
2. Create an IAM user for SMTP authentication and obtain SMTP credentials in SMTP settings > Create SMTP credentials
![opening AWS SES console](../../images/email-aws-ses-console.png)
![creating AWS IAM SES user](../../images/email-aws-ses-user.png)
3. With your AWS SES SMTP credentials, you can now set up your SMTP environment variables:
```
SMTP_HOST=smtp.mailgun.org # obtained from credentials page
SMTP_HOST=email-smtp.ap-northeast-1.amazonaws.com # SMTP endpoint obtained from SMTP settings
SMTP_USERNAME=xxx # your SMTP username
SMTP_PASSWORD=xxx # your SMTP password
SMTP_PORT=587
SMTP_SECURE=true
SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails
SMTP_FROM_NAME=Infisical
```
<Info> <Info>
Remember that you will need to restart Infisical for this to work properly. Remember that you will need to restart Infisical for this to work properly.
</Info> </Info>

View File

@@ -9,7 +9,6 @@ export const initPostHog = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// @ts-ignore // @ts-ignore
if (ENV == 'production' && TELEMETRY_CAPTURING_ENABLED) { if (ENV == 'production' && TELEMETRY_CAPTURING_ENABLED) {
console.log("Outside of posthog", "POSTHOG_API_KEY", POSTHOG_API_KEY, "POSTHOG_HOST", POSTHOG_HOST)
posthog.init(POSTHOG_API_KEY, { posthog.init(POSTHOG_API_KEY, {
api_host: POSTHOG_HOST api_host: POSTHOG_HOST
}); });

View File

@@ -4,13 +4,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export default function Error({ text }: { text: string }): JSX.Element { export default function Error({ text }: { text: string }): JSX.Element {
return ( return (
<div className="relative bg-red-500 opacity-100 border flex flex-row justify-center m-auto items-center w-fit rounded-full mb-4"> <div className="relative flex flex-row justify-center m-auto items-center w-fit rounded-full">
<FontAwesomeIcon <FontAwesomeIcon
icon={faExclamationTriangle} icon={faExclamationTriangle}
className="text-white mt-1.5 mb-2 mx-2" className="text-red mt-1.5 mb-2 mx-2"
/> />
{text && ( {text && (
<p className="relative top-0 text-white mr-2 text-sm py-1">{text}</p> <p className="relative top-0 text-red mr-2 text-sm py-1">{text}</p>
)} )}
</div> </div>
); );

View File

@@ -55,7 +55,7 @@ export default function EventFilter({
{selected != '' ? ( {selected != '' ? (
<p className="select-none text-bunker-100">{t("activity:event." + selected)}</p> <p className="select-none text-bunker-100">{t("activity:event." + selected)}</p>
) : ( ) : (
<p className="select-none">Select an event</p> <p className="select-none">{String(t("common:select-event"))}</p>
)} )}
{selected != '' ? ( {selected != '' ? (
<FontAwesomeIcon <FontAwesomeIcon

View File

@@ -11,7 +11,6 @@ import {
faKey, faKey,
faMobile, faMobile,
faPlug, faPlug,
faTimeline,
faUser, faUser,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
@@ -20,7 +19,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import getOrganizations from "~/pages/api/organization/getOrgs"; import getOrganizations from "~/pages/api/organization/getOrgs";
import getOrganizationUserProjects from "~/pages/api/organization/GetOrgUserProjects"; import getOrganizationUserProjects from "~/pages/api/organization/GetOrgUserProjects";
import getOrganizationUsers from "~/pages/api/organization/GetOrgUsers"; import getOrganizationUsers from "~/pages/api/organization/GetOrgUsers";
import checkUserAction from "~/pages/api/userActions/checkUserAction"; import getUser from "~/pages/api/user/getUser";
import addUserToWorkspace from "~/pages/api/workspace/addUserToWorkspace"; import addUserToWorkspace from "~/pages/api/workspace/addUserToWorkspace";
import createWorkspace from "~/pages/api/workspace/createWorkspace"; import createWorkspace from "~/pages/api/workspace/createWorkspace";
import getWorkspaces from "~/pages/api/workspace/getWorkspaces"; import getWorkspaces from "~/pages/api/workspace/getWorkspaces";
@@ -40,6 +39,7 @@ import Listbox from "./Listbox";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
} }
const crypto = require("crypto");
export default function Layout({ children }: LayoutProps) { export default function Layout({ children }: LayoutProps) {
const router = useRouter(); const router = useRouter();
@@ -84,12 +84,31 @@ export default function Layout({ children }: LayoutProps) {
}); });
const newWorkspaceId = newWorkspace._id; const newWorkspaceId = newWorkspace._id;
const randomBytes = crypto.randomBytes(16).toString("hex");
const PRIVATE_KEY = String(localStorage.getItem("PRIVATE_KEY"));
const myUser = await getUser();
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: randomBytes,
publicKey: myUser.publicKey,
privateKey: PRIVATE_KEY,
}) as { ciphertext: string; nonce: string };
await uploadKeys(
newWorkspaceId,
myUser._id,
ciphertext,
nonce
);
if (addAllUsers) { if (addAllUsers) {
console.log('adding other users')
const orgUsers = await getOrganizationUsers({ const orgUsers = await getOrganizationUsers({
orgId: tempLocalStorage("orgData.id"), orgId: tempLocalStorage("orgData.id"),
}); });
orgUsers.map(async (user: any) => { orgUsers.map(async (user: any) => {
if (user.status == "accepted") { if (user.status == "accepted" && user.email != myUser.email) {
const result = await addUserToWorkspace( const result = await addUserToWorkspace(
user.user.email, user.user.email,
newWorkspaceId newWorkspaceId
@@ -184,6 +203,7 @@ export default function Layout({ children }: LayoutProps) {
if ( if (
userWorkspaces.length == 0 && userWorkspaces.length == 0 &&
router.asPath != "/noprojects" && router.asPath != "/noprojects" &&
!router.asPath.includes("home")&&
!router.asPath.includes("settings") !router.asPath.includes("settings")
) { ) {
router.push("/noprojects"); router.push("/noprojects");
@@ -191,9 +211,16 @@ export default function Layout({ children }: LayoutProps) {
const intendedWorkspaceId = router.asPath const intendedWorkspaceId = router.asPath
.split("/") .split("/")
[router.asPath.split("/").length - 1].split("?")[0]; [router.asPath.split("/").length - 1].split("?")[0];
if (
!["heroku", "vercel", "github", "netlify"].includes(intendedWorkspaceId)
) {
localStorage.setItem("projectData.id", intendedWorkspaceId);
}
// If a user is not a member of a workspace they are trying to access, just push them to one of theirs // If a user is not a member of a workspace they are trying to access, just push them to one of theirs
if ( if (
intendedWorkspaceId != "heroku" && !["heroku", "vercel", "github", "netlify"].includes(intendedWorkspaceId) &&
!userWorkspaces !userWorkspaces
.map((workspace: { _id: string }) => workspace._id) .map((workspace: { _id: string }) => workspace._id)
.includes(intendedWorkspaceId) .includes(intendedWorkspaceId)
@@ -239,14 +266,14 @@ export default function Layout({ children }: LayoutProps) {
.split("/") .split("/")
[router.asPath.split("/").length - 1].split("?")[0] [router.asPath.split("/").length - 1].split("?")[0]
) { ) {
router.push(
"/dashboard/" +
workspaceMapping[workspaceSelected as any]
);
localStorage.setItem( localStorage.setItem(
"projectData.id", "projectData.id",
`${workspaceMapping[workspaceSelected as any]}` `${workspaceMapping[workspaceSelected as any]}`
); );
router.push(
"/dashboard/" +
workspaceMapping[workspaceSelected as any]
);
} }
} catch (error) { } catch (error) {
console.log(error); console.log(error);

View File

@@ -46,7 +46,7 @@ export default function ListBox({
> >
<div className="flex flex-row"> <div className="flex flex-row">
{text} {text}
<span className="ml-1 cursor-pointer block truncate font-semibold text-gray-300"> <span className="ml-1 cursor-pointer block truncate font-semibold text-gray-300 capitalize">
{' '} {' '}
{selected} {selected}
</span> </span>
@@ -69,7 +69,7 @@ export default function ListBox({
<Listbox.Option <Listbox.Option
key={personIdx} key={personIdx}
className={({ active, selected }) => className={({ active, selected }) =>
`my-0.5 relative cursor-default select-none py-2 pl-10 pr-4 rounded-md ${ `my-0.5 relative cursor-default select-none py-2 pl-10 pr-4 rounded-md capitalize ${
selected ? 'bg-white/10 text-gray-400 font-bold' : '' selected ? 'bg-white/10 text-gray-400 font-bold' : ''
} ${ } ${
active && !selected active && !selected

View File

@@ -1,27 +1,11 @@
import React from "react"; import React from "react";
import { Switch } from "@headlessui/react"; import { Switch } from "@headlessui/react";
interface OverrideProps {
id: string;
keyName: string;
value: string;
pos: number;
comment: string;
}
interface ToggleProps { interface ToggleProps {
enabled: boolean; enabled: boolean;
setEnabled: (value: boolean) => void; setEnabled: (value: boolean) => void;
addOverride: (value: OverrideProps) => void; addOverride: (value: string | undefined, pos: number) => void;
keyName: string;
value: string;
pos: number; pos: number;
id: string;
comment: string;
deleteOverride: (id: string) => void;
sharedToHide: string[];
setSharedToHide: (values: string[]) => void;
} }
/** /**
@@ -30,41 +14,23 @@ interface ToggleProps {
* @param {boolean} obj.enabled - whether the toggle is turned on or off * @param {boolean} obj.enabled - whether the toggle is turned on or off
* @param {function} obj.setEnabled - change the state of the toggle * @param {function} obj.setEnabled - change the state of the toggle
* @param {function} obj.addOverride - a function that adds an override to a certain secret * @param {function} obj.addOverride - a function that adds an override to a certain secret
* @param {string} obj.keyName - key of a certain secret
* @param {string} obj.value - value of a certain secret
* @param {number} obj.pos - position of a certain secret * @param {number} obj.pos - position of a certain secret
#TODO: make the secret id persistent?
* @param {string} obj.id - id of a certain secret
* @param {function} obj.deleteOverride - a function that deleted an override for a certain secret
* @param {string[]} obj.sharedToHide - an array of shared secrets that we want to hide visually because they are overriden.
* @param {function} obj.setSharedToHide - a function that updates the array of secrets that we want to hide visually
* @returns * @returns
*/ */
export default function Toggle ({ export default function Toggle ({
enabled, enabled,
setEnabled, setEnabled,
addOverride, addOverride,
keyName, pos
value,
pos,
id,
comment,
deleteOverride,
sharedToHide,
setSharedToHide
}: ToggleProps): JSX.Element { }: ToggleProps): JSX.Element {
return ( return (
<Switch <Switch
checked={enabled} checked={enabled}
onChange={() => { onChange={() => {
if (enabled == false) { if (enabled == false) {
addOverride({ id, keyName, value, pos, comment }); addOverride('', pos);
setSharedToHide([
...sharedToHide!,
id
])
} else { } else {
deleteOverride(id); addOverride(undefined, pos);
} }
setEnabled(!enabled); setEnabled(!enabled);
}} }}

View File

@@ -3,7 +3,6 @@ import Image from "next/image";
import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome"; } from "@fortawesome/react-fontawesome";
const classNames = require("classnames"); const classNames = require("classnames");
@@ -101,7 +100,7 @@ export default function Button(props: ButtonProps): JSX.Element {
<div <div
className={`${ className={`${
props.loading == true ? "opacity-100" : "opacity-0" props.loading == true ? "opacity-100" : "opacity-0"
} absolute flex items-center px-2 duration-200`} } absolute flex items-center px-3 bg-primary duration-200 w-full`}
> >
<Image <Image
src="/images/loading/loadingblack.gif" src="/images/loading/loadingblack.gif"

View File

@@ -109,7 +109,7 @@ const AddProjectMemberDialog = ({
selected={email ? email : data[0]} selected={email ? email : data[0]}
onChange={setEmail} onChange={setEmail}
data={data} data={data}
width="full" isFull={true}
/> />
)} )}
</div> </div>

View File

@@ -8,7 +8,6 @@ import nacl from "tweetnacl";
import addServiceToken from "~/pages/api/serviceToken/addServiceToken"; import addServiceToken from "~/pages/api/serviceToken/addServiceToken";
import getLatestFileKey from "~/pages/api/workspace/getLatestFileKey"; import getLatestFileKey from "~/pages/api/workspace/getLatestFileKey";
import { envMapping } from "../../../public/data/frequentConstants";
import { import {
decryptAssymmetric, decryptAssymmetric,
encryptAssymmetric, encryptAssymmetric,
@@ -34,11 +33,12 @@ const AddServiceTokenDialog = ({
workspaceId, workspaceId,
workspaceName, workspaceName,
serviceTokens, serviceTokens,
environments,
setServiceTokens setServiceTokens
}) => { }) => {
const [serviceToken, setServiceToken] = useState(""); const [serviceToken, setServiceToken] = useState("");
const [serviceTokenName, setServiceTokenName] = useState(""); const [serviceTokenName, setServiceTokenName] = useState("");
const [serviceTokenEnv, setServiceTokenEnv] = useState("Development"); const [selectedServiceTokenEnv, setSelectedServiceTokenEnv] = useState(environments?.[0]);
const [serviceTokenExpiresIn, setServiceTokenExpiresIn] = useState("1 day"); const [serviceTokenExpiresIn, setServiceTokenExpiresIn] = useState("1 day");
const [serviceTokenCopied, setServiceTokenCopied] = useState(false); const [serviceTokenCopied, setServiceTokenCopied] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -66,15 +66,13 @@ const AddServiceTokenDialog = ({
let newServiceToken = await addServiceToken({ let newServiceToken = await addServiceToken({
name: serviceTokenName, name: serviceTokenName,
workspaceId, workspaceId,
environment: envMapping[serviceTokenEnv], environment: selectedServiceTokenEnv?.slug ? selectedServiceTokenEnv.slug : environments[0]?.name,
expiresIn: expiryMapping[serviceTokenExpiresIn], expiresIn: expiryMapping[serviceTokenExpiresIn],
encryptedKey: ciphertext, encryptedKey: ciphertext,
iv, iv,
tag tag
}); });
console.log('newServiceToken', newServiceToken);
setServiceTokens(serviceTokens.concat([newServiceToken.serviceTokenData])); setServiceTokens(serviceTokens.concat([newServiceToken.serviceTokenData]));
setServiceToken(newServiceToken.serviceToken + "." + randomBytes); setServiceToken(newServiceToken.serviceToken + "." + randomBytes);
}; };
@@ -103,155 +101,159 @@ const AddServiceTokenDialog = ({
}; };
return ( return (
<div className="z-50"> <div className='z-50'>
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative" onClose={closeModal}> <Dialog as='div' className='relative' onClose={closeModal}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter='ease-out duration-300'
enterFrom="opacity-0" enterFrom='opacity-0'
enterTo="opacity-100" enterTo='opacity-100'
leave="ease-in duration-200" leave='ease-in duration-200'
leaveFrom="opacity-100" leaveFrom='opacity-100'
leaveTo="opacity-0" leaveTo='opacity-0'
> >
<div className="fixed inset-0 bg-bunker-700 bg-opacity-80" /> <div className='fixed inset-0 bg-bunker-700 bg-opacity-80' />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 overflow-y-auto"> <div className='fixed inset-0 overflow-y-auto'>
<div className="flex min-h-full items-center justify-center p-4 text-center"> <div className='flex min-h-full items-center justify-center p-4 text-center'>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter='ease-out duration-300'
enterFrom="opacity-0 scale-95" enterFrom='opacity-0 scale-95'
enterTo="opacity-100 scale-100" enterTo='opacity-100 scale-100'
leave="ease-in duration-200" leave='ease-in duration-200'
leaveFrom="opacity-100 scale-100" leaveFrom='opacity-100 scale-100'
leaveTo="opacity-0 scale-95" leaveTo='opacity-0 scale-95'
> >
{serviceToken == "" ? ( {serviceToken == '' ? (
<Dialog.Panel className="w-full max-w-md transform rounded-md bg-bunker-800 border border-gray-700 p-6 text-left align-middle shadow-xl transition-all"> <Dialog.Panel className='w-full max-w-md transform rounded-md bg-bunker-800 border border-gray-700 p-6 text-left align-middle shadow-xl transition-all'>
<Dialog.Title <Dialog.Title
as="h3" as='h3'
className="text-lg font-medium leading-6 text-gray-400 z-50" className='text-lg font-medium leading-6 text-gray-400 z-50'
> >
{t("section-token:add-dialog.title", { {t('section-token:add-dialog.title', {
target: workspaceName, target: workspaceName,
})} })}
</Dialog.Title> </Dialog.Title>
<div className="mt-2 mb-4"> <div className='mt-2 mb-4'>
<div className="flex flex-col"> <div className='flex flex-col'>
<p className="text-sm text-gray-500"> <p className='text-sm text-gray-500'>
{t("section-token:add-dialog.description")} {t('section-token:add-dialog.description')}
</p> </p>
</div> </div>
</div> </div>
<div className="max-h-28 mb-2"> <div className='max-h-28 mb-2'>
<InputField <InputField
label={t("section-token:add-dialog.name")} label={t('section-token:add-dialog.name')}
onChangeHandler={setServiceTokenName} onChangeHandler={setServiceTokenName}
type="varName" type='varName'
value={serviceTokenName} value={serviceTokenName}
placeholder="" placeholder=''
isRequired isRequired
/> />
</div> </div>
<div className="max-h-28 mb-2"> <div className='max-h-28 mb-2'>
<ListBox <ListBox
selected={serviceTokenEnv} selected={selectedServiceTokenEnv?.name ? selectedServiceTokenEnv?.name : environments[0]?.name}
onChange={setServiceTokenEnv} data={environments.map(({ name }) => name)}
data={[ onChange={(envName) =>
"Development", setSelectedServiceTokenEnv(
"Staging", environments.find(
"Production", ({ name }) => envName === name
"Testing", ) || {
]} name: 'unknown',
slug: 'unknown',
}
)
}
isFull={true} isFull={true}
text={`${t("common:environment")}: `} text={`${t('common:environment')}: `}
/> />
</div> </div>
<div className="max-h-28"> <div className='max-h-28'>
<ListBox <ListBox
selected={serviceTokenExpiresIn} selected={serviceTokenExpiresIn}
onChange={setServiceTokenExpiresIn} onChange={setServiceTokenExpiresIn}
data={[ data={[
"1 day", '1 day',
"7 days", '7 days',
"1 month", '1 month',
"6 months", '6 months',
"12 months", '12 months',
]} ]}
isFull={true} isFull={true}
text={`${t("common:expired-in")}: `} text={`${t('common:expired-in')}: `}
/> />
</div> </div>
<div className="max-w-max"> <div className='max-w-max'>
<div className="mt-6 flex flex-col justify-start w-max"> <div className='mt-6 flex flex-col justify-start w-max'>
<Button <Button
onButtonPressed={() => generateServiceToken()} onButtonPressed={() => generateServiceToken()}
color="mineshaft" color='mineshaft'
text={t("section-token:add-dialog.add")} text={t('section-token:add-dialog.add')}
textDisabled={t("section-token:add-dialog.add")} textDisabled={t('section-token:add-dialog.add')}
size="md" size='md'
active={serviceTokenName == "" ? false : true} active={serviceTokenName == '' ? false : true}
/> />
</div> </div>
</div> </div>
</Dialog.Panel> </Dialog.Panel>
) : ( ) : (
<Dialog.Panel className="w-full max-w-md transform rounded-md bg-bunker-800 border border-gray-700 p-6 text-left align-middle shadow-xl transition-all"> <Dialog.Panel className='w-full max-w-md transform rounded-md bg-bunker-800 border border-gray-700 p-6 text-left align-middle shadow-xl transition-all'>
<Dialog.Title <Dialog.Title
as="h3" as='h3'
className="text-lg font-medium leading-6 text-gray-400 z-50" className='text-lg font-medium leading-6 text-gray-400 z-50'
> >
{t("section-token:add-dialog.copy-service-token")} {t('section-token:add-dialog.copy-service-token')}
</Dialog.Title> </Dialog.Title>
<div className="mt-2 mb-4"> <div className='mt-2 mb-4'>
<div className="flex flex-col"> <div className='flex flex-col'>
<p className="text-sm text-gray-500"> <p className='text-sm text-gray-500'>
{t( {t(
"section-token:add-dialog.copy-service-token-description" 'section-token:add-dialog.copy-service-token-description'
)} )}
</p> </p>
</div> </div>
</div> </div>
<div className="w-full"> <div className='w-full'>
<div className="flex justify-end items-center bg-white/[0.07] text-base mt-2 mr-2 rounded-md text-gray-400 w-full h-20"> <div className='flex justify-end items-center bg-white/[0.07] text-base mt-2 mr-2 rounded-md text-gray-400 w-full h-20'>
<input <input
type="text" type='text'
value={serviceToken} value={serviceToken}
id="serviceToken" id='serviceToken'
className="invisible bg-white/0 text-gray-400 py-2 w-full px-2 min-w-full outline-none" className='invisible bg-white/0 text-gray-400 py-2 w-full px-2 min-w-full outline-none'
></input> ></input>
<div className="bg-white/0 max-w-md text-sm text-gray-400 py-2 w-full pl-14 pr-2 break-words outline-none"> <div className='bg-white/0 max-w-md text-sm text-gray-400 py-2 w-full pl-14 pr-2 break-words outline-none'>
{serviceToken} {serviceToken}
</div> </div>
<div className="group font-normal h-full relative inline-block text-gray-400 underline hover:text-primary duration-200"> <div className='group font-normal h-full relative inline-block text-gray-400 underline hover:text-primary duration-200'>
<button <button
onClick={copyToClipboard} onClick={copyToClipboard}
className="h-full pl-3.5 pr-4 border-l border-white/20 py-2 hover:bg-white/[0.12] duration-200" className='h-full pl-3.5 pr-4 border-l border-white/20 py-2 hover:bg-white/[0.12] duration-200'
> >
{serviceTokenCopied ? ( {serviceTokenCopied ? (
<FontAwesomeIcon <FontAwesomeIcon
icon={faCheck} icon={faCheck}
className="pr-0.5" className='pr-0.5'
/> />
) : ( ) : (
<FontAwesomeIcon icon={faCopy} /> <FontAwesomeIcon icon={faCopy} />
)} )}
</button> </button>
<span className="absolute hidden group-hover:flex group-hover:animate-popup duration-300 w-28 -left-8 -top-20 translate-y-full px-3 py-2 bg-chicago-900 rounded-md text-center text-gray-400 text-sm"> <span className='absolute hidden group-hover:flex group-hover:animate-popup duration-300 w-28 -left-8 -top-20 translate-y-full px-3 py-2 bg-chicago-900 rounded-md text-center text-gray-400 text-sm'>
{t("common:click-to-copy")} {t('common:click-to-copy')}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-6 flex flex-col justify-start w-max"> <div className='mt-6 flex flex-col justify-start w-max'>
<Button <Button
onButtonPressed={() => closeAddServiceTokenModal()} onButtonPressed={() => closeAddServiceTokenModal()}
color="mineshaft" color='mineshaft'
text="Close" text='Close'
size="md" size='md'
/> />
</div> </div>
</Dialog.Panel> </Dialog.Panel>

View File

@@ -0,0 +1,145 @@
import { FormEventHandler, Fragment, useEffect, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import Button from '../buttons/Button';
import InputField from '../InputField';
type FormFields = { name: string; slug: string };
type Props = {
isOpen?: boolean;
isEditMode?: boolean;
// on edit mode load up initial values
initialValues?: FormFields;
onClose: () => void;
onCreateSubmit: (data: FormFields) => void;
onEditSubmit: (data: FormFields) => void;
};
// TODO: Migrate to better form management and validation. Preferable react-hook-form + yup
/**
* The dialog modal for when the user wants to create a new workspace
* @param {*} param0
* @returns
*/
export const AddUpdateEnvironmentDialog = ({
isOpen,
onClose,
onCreateSubmit,
onEditSubmit,
initialValues,
isEditMode,
}: Props) => {
const [formInput, setFormInput] = useState<FormFields>({
name: '',
slug: '',
});
// This use effect can be removed when the unmount is happening from outside the component
// When unmount happens outside state gets unmounted also
useEffect(() => {
setFormInput(initialValues || { name: '', slug: '' });
}, [isOpen]);
// REFACTOR: Move to react-hook-form with yup for better form management
const onInputChange = (fieldName: string, fieldValue: string) => {
setFormInput((state) => ({ ...state, [fieldName]: fieldValue }));
};
const onFormSubmit: FormEventHandler = (e) => {
e.preventDefault();
const data = {
name: formInput.name.toLowerCase(),
slug: formInput.slug.toLowerCase(),
};
if (isEditMode) {
onEditSubmit(data);
return;
}
onCreateSubmit(data);
};
return (
<div>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as='div' className='relative z-20' onClose={onClose}>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0'
enterTo='opacity-100'
leave='ease-out duration-150'
leaveFrom='opacity-100'
leaveTo='opacity-0'
>
<div className='fixed inset-0 bg-black bg-opacity-70' />
</Transition.Child>
<div className='fixed inset-0 overflow-y-auto z-50'>
<div className='flex min-h-full items-center justify-center p-4 text-center'>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0 scale-95'
enterTo='opacity-100 scale-100'
leave='ease-in duration-200'
leaveFrom='opacity-100 scale-100'
leaveTo='opacity-0 scale-95'
>
<Dialog.Panel className='w-full max-w-md transform overflow-hidden rounded-2xl bg-bunker-800 border border-gray-700 p-6 text-left align-middle shadow-xl transition-all'>
<Dialog.Title
as='h3'
className='text-lg font-medium leading-6 text-gray-400'
>
{isEditMode
? 'Update environment'
: 'Create a new environment'}
</Dialog.Title>
<form onSubmit={onFormSubmit}>
<div className='max-h-28 mt-4'>
<InputField
label='Environment Name'
onChangeHandler={(val) => onInputChange('name', val)}
type='varName'
value={formInput.name}
placeholder=''
isRequired
// error={error.length > 0}
// errorText={error}
/>
</div>
<div className='max-h-28 mt-4'>
<InputField
label='Environment Slug'
onChangeHandler={(val) => onInputChange('slug', val)}
type='varName'
value={formInput.slug}
placeholder=''
isRequired
// error={error.length > 0}
// errorText={error}
/>
</div>
<p className='text-xs text-gray-500 mt-2'>
Slugs are shorthands used in cli to access environment
</p>
<div className='mt-4 max-w-min'>
<Button
onButtonPressed={() => null}
type='submit'
color='mineshaft'
text={isEditMode ? 'Update' : 'Create'}
active={formInput.name !== '' && formInput.slug !== ''}
size='md'
/>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</div>
);
};

View File

@@ -0,0 +1,104 @@
import { Fragment, useEffect, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import InputField from '../InputField';
// REFACTOR: Move all these modals into one reusable one
type Props = {
isOpen?: boolean;
onClose: ()=>void;
title: string;
onSubmit:()=>void;
deleteKey?:string;
}
const DeleteActionModal = ({
isOpen,
onClose,
title,
onSubmit,
deleteKey
}:Props) => {
const [deleteInputField, setDeleteInputField] = useState("")
useEffect(() => {
setDeleteInputField("");
}, [isOpen]);
return (
<div>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as='div' className='relative z-10' onClose={onClose}>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0'
enterTo='opacity-100'
leave='ease-in duration-150'
leaveFrom='opacity-100'
leaveTo='opacity-0'
>
<div className='fixed inset-0 bg-black bg-opacity-25' />
</Transition.Child>
<div className='fixed inset-0 overflow-y-auto'>
<div className='flex min-h-full items-center justify-center p-4 text-center'>
<Transition.Child
as={Fragment}
enter='ease-out duration-300'
enterFrom='opacity-0 scale-95'
enterTo='opacity-100 scale-100'
leave='ease-in duration-200'
leaveFrom='opacity-100 scale-100'
leaveTo='opacity-0 scale-95'
>
<Dialog.Panel className='w-full max-w-md transform overflow-hidden rounded-2xl bg-grey border border-gray-700 p-6 text-left align-middle shadow-xl transition-all'>
<Dialog.Title
as='h3'
className='text-lg font-medium leading-6 text-gray-400'
>
{title}
</Dialog.Title>
<div className='mt-2'>
<p className='text-sm text-gray-500'>
This action is irrevertible.
</p>
</div>
<div className='mt-2'>
<InputField
isRequired
label={`Type ${deleteKey} to delete the resource`}
onChangeHandler={(val) => setDeleteInputField(val)}
value={deleteInputField}
type='text'
/>
</div>
<div className='mt-6'>
<button
type='button'
className='inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-sm font-medium text-gray-400 hover:bg-alizarin hover:text-white hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
onClick={onSubmit}
disabled={
Boolean(deleteKey) && deleteInputField !== deleteKey
}
>
Delete
</button>
<button
type='button'
className='ml-2 inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-sm font-medium text-gray-400 hover:border-white hover:text-white hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
onClick={onClose}
>
Cancel
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</div>
);
};
export default DeleteActionModal;

View File

@@ -0,0 +1,77 @@
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { Dialog, Transition } from "@headlessui/react";
// #TODO: USE THIS. Currently it's not. Kinda complicated to set up because of state.
type Props = {
isOpen: boolean
onClose: () => void
onSubmit: () => void
}
export const DeleteEnvVar = ({ isOpen, onClose, onSubmit }: Props) => {
const { t } = useTranslation()
return (
<div>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={() => {}}>
<div className="fixed inset-0 overflow-y-auto">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-70" onClick={onClose} />
</Transition.Child>
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-md bg-grey border border-gray-700 p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-400"
>
{t('dashboard:sidebar.delete-key-dialog.title')}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
{t('dashboard:sidebar.delete-key-dialog.confirm-delete-message')}
</p>
</div>
<div className="mt-6 flex justify-start">
<button
type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-red-700 hover:bg-red-600 px-4 py-2 text-sm font-medium text-bunker-200 hover:text-white text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={onSubmit}
>
Delete
</button>
<button
type="button"
className="ml-2 inline-flex justify-center rounded-md border border-transparent bg-bunker-500 px-4 py-2 text-sm font-medium text-gray-400 hover:bg-mineshaft-500 hover:text-white hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={onClose}
>
Cancel
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</div>
);
};

View File

@@ -36,7 +36,7 @@ export default function BottonRightPopup({
}: PopupProps): JSX.Element { }: PopupProps): JSX.Element {
return ( return (
<div <div
className="z-50 drop-shadow-xl border-gray-600/50 border flex flex-col items-start bg-bunker max-w-xl text-gray-200 pt-3 pb-4 rounded-xl absolute bottom-0 right-0 mr-6 mb-6" className="z-50 drop-shadow-xl border-gray-600/50 border flex flex-col items-start bg-bunker max-w-xl text-gray-200 pt-3 pb-4 rounded-md absolute bottom-0 right-0 mr-6 mb-6"
role="alert" role="alert"
> >
<div className="flex flex-row items-center justify-between w-full border-b border-gray-600/70 pb-3 px-6"> <div className="flex flex-row items-center justify-between w-full border-b border-gray-600/70 pb-3 px-6">

View File

@@ -0,0 +1,167 @@
import { faPencil, faPlus, faX } from '@fortawesome/free-solid-svg-icons';
import { usePopUp } from '../../../hooks/usePopUp';
import Button from '../buttons/Button';
import { AddUpdateEnvironmentDialog } from '../dialog/AddUpdateEnvironmentDialog';
import DeleteActionModal from '../dialog/DeleteActionModal';
type Env = { name: string; slug: string };
type Props = {
data: Env[];
onCreateEnv: (arg0: Env) => Promise<void>;
onUpdateEnv: (oldSlug: string, arg0: Env) => Promise<void>;
onDeleteEnv: (slug: string) => Promise<void>;
};
const EnvironmentTable = ({
data = [],
onCreateEnv,
onDeleteEnv,
onUpdateEnv,
}: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
'createUpdateEnv',
'deleteEnv',
] as const);
const onEnvCreateCB = async (env: Env) => {
try {
await onCreateEnv(env);
handlePopUpClose('createUpdateEnv');
} catch (error) {
console.error(error);
}
};
const onEnvUpdateCB = async (env: Env) => {
try {
await onUpdateEnv(
(popUp.createUpdateEnv?.data as Pick<Env, 'slug'>)?.slug,
env
);
handlePopUpClose('createUpdateEnv');
} catch (error) {
console.error(error);
}
};
const onEnvDeleteCB = async () => {
try {
await onDeleteEnv(
(popUp.deleteEnv?.data as Pick<Env, 'slug'>)?.slug
);
handlePopUpClose('deleteEnv');
} catch (error) {
console.error(error);
}
};
return (
<>
<div className='flex flex-row justify-between w-full'>
<div className='flex flex-col w-full'>
<p className='text-xl font-semibold mb-3'>Project Environments</p>
<p className='text-base text-gray-400 mb-4'>
Choose which environments will show up in your dashboard like
development, staging, production
</p>
<p className='text-sm mr-1 text-gray-500 self-start'>
Note: the text in slugs shows how these environmant should be
accessed in CLI.
</p>
</div>
<div className='w-48'>
<Button
text='Add New Env'
onButtonPressed={() => handlePopUpOpen('createUpdateEnv')}
color='mineshaft'
icon={faPlus}
size='md'
/>
</div>
</div>
<div className='table-container w-full bg-bunker rounded-md mb-6 border border-mineshaft-700 relative mt-1'>
<div className='absolute rounded-t-md w-full h-12 bg-white/5'></div>
<table className='w-full my-1'>
<thead className='text-bunker-300'>
<tr>
<th className='text-left pl-6 pt-2.5 pb-2'>Name</th>
<th className='text-left pl-6 pt-2.5 pb-2'>Slug</th>
<th></th>
</tr>
</thead>
<tbody>
{data?.length > 0 ? (
data.map(({ name, slug }) => {
return (
<tr
key={name}
className='bg-bunker-800 hover:bg-bunker-800/5 duration-100'
>
<td className='pl-6 py-2 border-mineshaft-700 border-t text-gray-300 capitalize'>
{name}
</td>
<td className='pl-6 py-2 border-mineshaft-700 border-t text-gray-300'>
{slug}
</td>
<td className='py-2 border-mineshaft-700 border-t flex'>
<div className='opacity-50 hover:opacity-100 duration-200 flex items-center mr-8'>
<Button
onButtonPressed={() =>
handlePopUpOpen('createUpdateEnv', { name, slug })
}
color='red'
size='icon-sm'
icon={faPencil}
/>
</div>
<div className='opacity-50 hover:opacity-100 duration-200 flex items-center'>
<Button
onButtonPressed={() =>
handlePopUpOpen('deleteEnv', { name, slug })
}
color='red'
size='icon-sm'
icon={faX}
/>
</div>
</td>
</tr>
);
})
) : (
<tr>
<td
colSpan={4}
className='text-center pt-7 pb-4 text-bunker-400'
>
No environmants found
</td>
</tr>
)}
</tbody>
</table>
<DeleteActionModal
isOpen={popUp['deleteEnv'].isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteEnv?.data as { name: string })?.name || ' '
}?`}
deleteKey={(popUp?.deleteEnv?.data as { slug: string })?.slug || ''}
onClose={() => handlePopUpClose('deleteEnv')}
onSubmit={onEnvDeleteCB}
/>
<AddUpdateEnvironmentDialog
isOpen={popUp.createUpdateEnv.isOpen}
isEditMode={Boolean(popUp.createUpdateEnv?.data)}
initialValues={popUp?.createUpdateEnv?.data as any}
onClose={() => handlePopUpClose('createUpdateEnv')}
onCreateSubmit={onEnvCreateCB}
onEditSubmit={onEnvUpdateCB}
/>
</div>
</>
);
};
export default EnvironmentTable;

View File

@@ -3,7 +3,6 @@ import { faX } from '@fortawesome/free-solid-svg-icons';
import { useNotificationContext } from '~/components/context/Notifications/NotificationProvider'; import { useNotificationContext } from '~/components/context/Notifications/NotificationProvider';
import deleteServiceToken from "../../../pages/api/serviceToken/deleteServiceToken"; import deleteServiceToken from "../../../pages/api/serviceToken/deleteServiceToken";
import { reverseEnvMapping } from '../../../public/data/frequentConstants';
import guidGenerator from '../../utilities/randomId'; import guidGenerator from '../../utilities/randomId';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
@@ -60,7 +59,7 @@ const ServiceTokenTable = ({ data, workspaceName, setServiceTokens }: ServiceTok
{workspaceName} {workspaceName}
</td> </td>
<td className="pl-6 py-2 border-mineshaft-700 border-t text-gray-300"> <td className="pl-6 py-2 border-mineshaft-700 border-t text-gray-300">
{reverseEnvMapping[row.environment]} {row.environment}
</td> </td>
<td className="pl-6 py-2 border-mineshaft-700 border-t text-gray-300"> <td className="pl-6 py-2 border-mineshaft-700 border-t text-gray-300">
{new Date(row.expiresAt).toUTCString()} {new Date(row.expiresAt).toUTCString()}

View File

@@ -117,13 +117,13 @@ const UserTable = ({
return ( return (
<div className="table-container bg-bunker rounded-md mb-6 border border-mineshaft-700 relative mt-1"> <div className="table-container bg-bunker rounded-md mb-6 border border-mineshaft-700 relative mt-1">
<div className="absolute rounded-t-md w-full h-14 bg-white/5"></div> <div className="absolute rounded-t-md w-full h-[3.25rem] bg-white/5"></div>
<table className="w-full my-1"> <table className="w-full my-0.5">
<thead className="text-gray-400"> <thead className="text-gray-400 text-sm font-light">
<tr> <tr>
<th className="text-left pl-6 py-3.5">First Name</th> <th className="text-left pl-6 py-3.5">FIRST NAME</th>
<th className="text-left pl-6 py-3.5">Last Name</th> <th className="text-left pl-6 py-3.5">LAST NAME</th>
<th className="text-left pl-6 py-3.5">Email</th> <th className="text-left pl-6 py-3.5">EMAIL</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>

View File

@@ -6,10 +6,10 @@ import { useTranslation } from "next-i18next";
const CommentField = ({ comment, modifyComment, position }: { comment: string; modifyComment: (value: string, posistion: number) => void; position: number;}) => { const CommentField = ({ comment, modifyComment, position }: { comment: string; modifyComment: (value: string, posistion: number) => void; position: number;}) => {
const { t } = useTranslation(); const { t } = useTranslation();
return <div className={`relative mt-4 px-4 pt-4`}> return <div className={`relative mt-4 px-4 pt-6`}>
<p className='text-sm text-bunker-300'>{t("dashboard:sidebar.comments")}</p> <p className='text-sm text-bunker-300 pl-0.5'>{t("dashboard:sidebar.comments")}</p>
<textarea <textarea
className="bg-bunker-800 h-32 w-full bg-bunker-800 p-2 rounded-md border border-mineshaft-500 text-sm text-bunker-300 outline-none focus:ring-2 ring-primary-800 ring-opacity-70" className="bg-bunker-800 placeholder:text-bunker-400 h-32 w-full bg-bunker-800 px-2 py-1.5 rounded-md border border-mineshaft-500 text-sm text-bunker-300 outline-none focus:ring-2 ring-primary-800 ring-opacity-70"
value={comment} value={comment}
onChange={(e) => modifyComment(e.target.value, position)} onChange={(e) => modifyComment(e.target.value, position)}
placeholder="Leave any comments here..." placeholder="Leave any comments here..."

Some files were not shown because too many files have changed in this diff Show More