diff --git a/.github/values.yaml b/.github/values.yaml new file mode 100644 index 0000000000..f1551d61a0 --- /dev/null +++ b/.github/values.yaml @@ -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: \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 1e836a1782..ffc6e5c409 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,5 +1,4 @@ -name: Push to Docker Hub - +name: Build, Publish and Deploy to Gamma on: [workflow_dispatch] jobs: @@ -10,8 +9,9 @@ jobs: steps: - name: ☁️ Checkout source uses: actions/checkout@v3 - - name: 🔧 Set up QEMU - uses: docker/setup-qemu-action@v2 + - name: Save commit hashes for tag + id: commit + uses: pr-mpt/actions-commit-hash@v2 - name: 🔧 Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: 🐋 Login to Docker Hub @@ -19,9 +19,13 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Depot CLI + uses: depot/setup-action@v1 - name: 📦 Build backend and export to Docker - uses: docker/build-push-action@v3 + uses: depot/build-push-action@v1 with: + project: 64mmf0n610 + token: ${{ secrets.DEPOT_PROJECT_TOKEN }} load: true context: backend tags: infisical/backend:test @@ -35,11 +39,14 @@ jobs: run: | docker compose -f .github/resources/docker-compose.be-test.yml down - name: 🏗️ Build backend and push - uses: docker/build-push-action@v3 + uses: depot/build-push-action@v1 with: + project: 64mmf0n610 + token: ${{ secrets.DEPOT_PROJECT_TOKEN }} push: true context: backend - tags: infisical/backend:latest + tags: infisical/backend:${{ steps.commit.outputs.short }}, + infisical/backend:latest platforms: linux/amd64,linux/arm64 frontend-image: @@ -49,8 +56,9 @@ jobs: steps: - name: ☁️ Checkout source uses: actions/checkout@v3 - - name: 🔧 Set up QEMU - uses: docker/setup-qemu-action@v2 + - name: Save commit hashes for tag + id: commit + uses: pr-mpt/actions-commit-hash@v2 - name: 🔧 Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: 🐋 Login to Docker Hub @@ -58,10 +66,14 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Depot CLI + uses: depot/setup-action@v1 - name: 📦 Build frontend and export to Docker - uses: docker/build-push-action@v3 + uses: depot/build-push-action@v1 with: load: true + token: ${{ secrets.DEPOT_PROJECT_TOKEN }} + project: 64mmf0n610 context: frontend tags: infisical/frontend:test build-args: | @@ -76,11 +88,45 @@ jobs: run: | docker stop infisical-frontend-test - name: 🏗️ Build frontend and push - uses: docker/build-push-action@v3 + uses: depot/build-push-action@v1 with: + project: 64mmf0n610 push: true + token: ${{ secrets.DEPOT_PROJECT_TOKEN }} context: frontend - tags: infisical/frontend:latest + tags: infisical/frontend:${{ steps.commit.outputs.short }}, + infisical/frontend:latest platforms: linux/amd64,linux/arm64 build-args: | POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} + 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 \ No newline at end of file diff --git a/.github/workflows/release_build.yml b/.github/workflows/release_build.yml index da2a294352..df00c5c926 100644 --- a/.github/workflows/release_build.yml +++ b/.github/workflows/release_build.yml @@ -1,4 +1,4 @@ -name: Go releaser +name: Build and release CLI on: push: diff --git a/Makefile b/Makefile index 266eaf9b59..9e1f5c3f61 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,9 @@ push: up-dev: 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: docker-compose -f docker-compose.yml up --build diff --git a/README.md b/README.md index d0f040bb0c..2876c6ee80 100644 --- a/README.md +++ b/README.md @@ -333,10 +333,10 @@ Infisical officially launched as v.1.0 on November 21st, 2022. There are a lot o - + ## 🌎 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). \ No newline at end of file diff --git a/backend/api-documentation.json b/backend/api-documentation.json new file mode 100644 index 0000000000..79cf0eb3b1 --- /dev/null +++ b/backend/api-documentation.json @@ -0,0 +1,2568 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Infisical API", + "description": "List of all available APIs that can be consumed", + "version": "1.0.0" + }, + "paths": { + "/api/v1/secret/{secretId}/secret-versions": { + "get": { + "description": "", + "parameters": [ + { + "name": "secretId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/secret-snapshot/{secretSnapshotId}": { + "get": { + "description": "", + "parameters": [ + { + "name": "secretSnapshotId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/workspace/{workspaceId}/secret-snapshots": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/workspace/{workspaceId}/secret-snapshots/count": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/workspace/{workspaceId}/logs": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "sortBy", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "actionNames", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/action/{actionId}": { + "get": { + "description": "", + "parameters": [ + { + "name": "actionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/signup/email/signup": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/signup/email/verify": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + }, + "code": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/signup/complete-account/signup": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + }, + "firstName": { + "example": "any" + }, + "lastName": { + "example": "any" + }, + "publicKey": { + "example": "any" + }, + "encryptedPrivateKey": { + "example": "any" + }, + "iv": { + "example": "any" + }, + "tag": { + "example": "any" + }, + "salt": { + "example": "any" + }, + "verifier": { + "example": "any" + }, + "organizationName": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/signup/complete-account/invite": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + }, + "firstName": { + "example": "any" + }, + "lastName": { + "example": "any" + }, + "publicKey": { + "example": "any" + }, + "encryptedPrivateKey": { + "example": "any" + }, + "iv": { + "example": "any" + }, + "tag": { + "example": "any" + }, + "salt": { + "example": "any" + }, + "verifier": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/auth/token": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/auth/login1": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + }, + "clientPublicKey": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/auth/login2": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + }, + "clientProof": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/auth/checkAuth": { + "post": { + "description": "", + "parameters": [], + "responses": {} + } + }, + "/api/v1/bot/{workspaceId}": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/bot/{botId}/active": { + "patch": { + "description": "", + "parameters": [ + { + "name": "botId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "isActive": { + "example": "any" + }, + "botKey": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/user/": { + "get": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/user-action/": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "example": "any" + } + } + } + } + } + } + }, + "get": { + "description": "", + "parameters": [ + { + "name": "action", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/organization/": { + "get": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + }, + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "organizationName": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/organization/{organizationId}": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/organization/{organizationId}/users": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/organization/{organizationId}/my-workspaces": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/organization/{organizationId}/name": { + "patch": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/organization/{organizationId}/incidentContactOrg": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + }, + "post": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + } + } + } + } + } + } + }, + "delete": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/organization/{organizationId}/customer-portal-session": { + "post": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/organization/{organizationId}/subscriptions": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/workspace/{workspaceId}/keys": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/workspace/{workspaceId}/users": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/workspace/": { + "get": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + }, + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceName": { + "example": "any" + }, + "organizationId": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/workspace/{workspaceId}": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + }, + "delete": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/workspace/{workspaceId}/name": { + "post": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/workspace/{workspaceId}/invite-signup": { + "post": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/workspace/{workspaceId}/integrations": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/workspace/{workspaceId}/authorizations": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/workspace/{workspaceId}/service-tokens": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/membership-org/membershipOrg/{membershipOrgId}/change-role": { + "post": { + "description": "", + "parameters": [ + { + "name": "membershipOrgId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/membership-org/{membershipOrgId}": { + "delete": { + "description": "", + "parameters": [ + { + "name": "membershipOrgId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/membership/{workspaceId}/connect": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/membership/{membershipId}": { + "delete": { + "description": "", + "parameters": [ + { + "name": "membershipId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/membership/{membershipId}/change-role": { + "post": { + "description": "", + "parameters": [ + { + "name": "membershipId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "role": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/key/{workspaceId}": { + "post": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/key/{workspaceId}/latest": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/invite-org/signup": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "organizationId": { + "example": "any" + }, + "inviteeEmail": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/invite-org/verify": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + }, + "code": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/secret/{workspaceId}": { + "post": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secrets": { + "example": "any" + }, + "keys": { + "example": "any" + }, + "environment": { + "example": "any" + }, + "channel": { + "example": "any" + } + } + } + } + } + } + }, + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "channel", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/secret/{workspaceId}/service-token": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "channel", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/service-token/": { + "get": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + } + } + }, + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "example": "any" + }, + "workspaceId": { + "example": "any" + }, + "environment": { + "example": "any" + }, + "expiresIn": { + "example": "any" + }, + "publicKey": { + "example": "any" + }, + "encryptedKey": { + "example": "any" + }, + "nonce": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/password/srp1": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "clientPublicKey": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/password/change-password": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "clientProof": { + "example": "any" + }, + "encryptedPrivateKey": { + "example": "any" + }, + "iv": { + "example": "any" + }, + "tag": { + "example": "any" + }, + "salt": { + "example": "any" + }, + "verifier": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/password/email/password-reset": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/password/email/password-reset-verify": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "example": "any" + }, + "code": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/password/backup-private-key": { + "get": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + }, + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "clientProof": { + "example": "any" + }, + "encryptedPrivateKey": { + "example": "any" + }, + "iv": { + "example": "any" + }, + "tag": { + "example": "any" + }, + "salt": { + "example": "any" + }, + "verifier": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/password/password-reset": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "encryptedPrivateKey": { + "example": "any" + }, + "iv": { + "example": "any" + }, + "tag": { + "example": "any" + }, + "salt": { + "example": "any" + }, + "verifier": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/stripe/webhook": { + "post": { + "description": "", + "parameters": [ + { + "name": "stripe-signature", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/integration/{integrationId}": { + "patch": { + "description": "", + "parameters": [ + { + "name": "integrationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "app": { + "example": "any" + }, + "environment": { + "example": "any" + }, + "isActive": { + "example": "any" + }, + "target": { + "example": "any" + }, + "context": { + "example": "any" + }, + "siteId": { + "example": "any" + } + } + } + } + } + } + }, + "delete": { + "description": "", + "parameters": [ + { + "name": "integrationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/integration-auth/integration-options": { + "get": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/integration-auth/oauth-token": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "example": "any" + }, + "code": { + "example": "any" + }, + "integration": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/integration-auth/{integrationAuthId}/apps": { + "get": { + "description": "", + "parameters": [ + { + "name": "integrationAuthId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v1/integration-auth/{integrationAuthId}": { + "delete": { + "description": "", + "parameters": [ + { + "name": "integrationAuthId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v2/workspace/{workspaceId}/secrets": { + "post": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secrets": { + "example": "any" + }, + "keys": { + "example": "any" + }, + "environment": { + "example": "any" + }, + "channel": { + "example": "any" + } + } + } + } + } + } + }, + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "channel", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v2/workspace/{workspaceId}/encrypted-key": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v2/workspace/{workspaceId}/service-token-data": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v2/secret/batch-create/workspace/{workspaceId}/environment/{environmentName}": { + "post": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environmentName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secrets": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v2/secret/workspace/{workspaceId}/environment/{environmentName}": { + "post": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environmentName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secret": { + "example": "any" + } + } + } + } + } + } + }, + "patch": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environmentName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secret": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v2/secret/workspace/{workspaceId}": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v2/secret/{secretId}": { + "get": { + "description": "", + "parameters": [ + { + "name": "secretId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "description": "", + "parameters": [ + { + "name": "secretId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v2/secret/batch/workspace/{workspaceId}/environment/{environmentName}": { + "delete": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environmentName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secretIds": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v2/secret/batch-modify/workspace/{workspaceId}/environment/{environmentName}": { + "patch": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environmentName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secrets": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v2/service-token/": { + "get": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + }, + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "example": "any" + }, + "workspaceId": { + "example": "any" + }, + "environment": { + "example": "any" + }, + "encryptedKey": { + "example": "any" + }, + "iv": { + "example": "any" + }, + "tag": { + "example": "any" + }, + "expiresIn": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v2/service-token/{serviceTokenDataId}": { + "delete": { + "description": "", + "parameters": [ + { + "name": "serviceTokenDataId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/v2/api-key-data/": { + "get": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + }, + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "example": "any" + }, + "expiresIn": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v2/api-key-data/{apiKeyDataId}": { + "delete": { + "description": "", + "parameters": [ + { + "name": "apiKeyDataId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/api/status": { + "get": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + } +} \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index a9bb83e6b0..b51aa7cf09 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -37,6 +37,8 @@ "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", @@ -4549,7 +4551,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -6690,7 +6691,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -11174,6 +11174,47 @@ "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": { "version": "6.1.13", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", @@ -15582,8 +15623,7 @@ "deepmerge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" }, "delayed-stream": { "version": "1.0.0", @@ -17200,8 +17240,7 @@ "json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" }, "jsonwebtoken": { "version": "9.0.0", @@ -20424,6 +20463,37 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "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": { "version": "6.1.13", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", diff --git a/backend/package.json b/backend/package.json index 56009e3e91..08f5a815cb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", "version": "1.0.0", "main": "src/index.js", "scripts": { "start": "npm run build && node build/index.js", "dev": "nodemon", + "swagger-autogen": "node ./swagger.ts", "build": "rimraf ./build && tsc && cp -R ./src/templates ./build", "lint": "eslint . --ext .ts", "lint-and-fix": "eslint . --ext .ts --fix", @@ -108,5 +73,43 @@ "suiteNameTemplate": "{filepath}", "classNameTemplate": "{classname}", "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" } } diff --git a/backend/src/app.ts b/backend/src/app.ts index 1e1bbb5939..82275b9c9e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,11 +1,15 @@ // eslint-disable-next-line @typescript-eslint/no-var-requires const { patchRouterParam } = require('./utils/patchAsyncRoutes'); -import express from 'express'; +import express, { Request, Response } from 'express'; import helmet from 'helmet'; import cors from 'cors'; import cookieParser from 'cookie-parser'; 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(); import { PORT, NODE_ENV, SITE_URL } from './config'; @@ -38,11 +42,15 @@ import { } from './routes/v1'; import { secret as v2SecretRouter, + secrets as v2SecretsRouter, workspace as v2WorkspaceRouter, serviceTokenData as v2ServiceTokenDataRouter, apiKeyData as v2APIKeyDataRouter, + environment as v2EnvironmentRouter, } from './routes/v2'; +import { healthCheck } from './routes/status'; + import { getLogger } from './utils/logger'; import { RouteNotFoundError } from './utils/errors'; 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/invite-org', v1InviteOrgRouter); 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/stripe', v1StripeRouter); app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); // v2 routes -app.use('/api/v2/workspace', v2WorkspaceRouter); -app.use('/api/v2/secret', v2SecretRouter); -app.use('/api/v2/service-token', v2ServiceTokenDataRouter); +app.use('/api/v2/workspace', v2EnvironmentRouter); +app.use('/api/v2/workspace', v2WorkspaceRouter); // TODO: turn into plural route +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); +// 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 app.use((req, res, next) => { if (res.headersSent) return next(); diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 95d0066ae7..5df8b4e910 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -3,7 +3,7 @@ import * as Sentry from '@sentry/node'; import axios from 'axios'; import { readFileSync } from 'fs'; 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 { getApps, revokeAccess } from '../../integrations'; @@ -31,11 +31,17 @@ export const oAuthExchange = async ( if (!INTEGRATION_SET.has(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({ workspaceId, integration, - code + code, + environment: environments[0].slug, }); } catch (err) { Sentry.setUser(null); diff --git a/backend/src/controllers/v1/membershipOrgController.ts b/backend/src/controllers/v1/membershipOrgController.ts index 5628cda1a9..f3703b8895 100644 --- a/backend/src/controllers/v1/membershipOrgController.ts +++ b/backend/src/controllers/v1/membershipOrgController.ts @@ -115,13 +115,14 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { if (!membershipOrg) { throw new Error('Failed to validate organization membership'); } - + invitee = await User.findOne({ email: inviteeEmail - }); + }).select('+publicKey'); if (invitee) { // case: invitee is an existing user + inviteeMembershipOrg = await MembershipOrg.findOne({ user: invitee._id, organization: organizationId diff --git a/backend/src/controllers/v1/secretController.ts b/backend/src/controllers/v1/secretController.ts index 1b756ecc70..c76e5e8833 100644 --- a/backend/src/controllers/v1/secretController.ts +++ b/backend/src/controllers/v1/secretController.ts @@ -9,7 +9,6 @@ import { import { pushKeys } from '../../helpers/key'; import { eventPushSecrets } from '../../events'; import { EventService } from '../../services'; -import { ENV_SET } from '../../variables'; import { postHogClient } from '../../services'; interface PushSecret { @@ -44,7 +43,8 @@ export const pushSecrets = async (req: Request, res: Response) => { const { workspaceId } = req.params; // 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'); } @@ -116,7 +116,8 @@ export const pullSecrets = async (req: Request, res: Response) => { const { workspaceId } = req.params; // 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'); } @@ -183,7 +184,8 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => { const { workspaceId } = req.params; // 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'); } diff --git a/backend/src/controllers/v1/serviceTokenController.ts b/backend/src/controllers/v1/serviceTokenController.ts index 244a587837..3fafb90433 100644 --- a/backend/src/controllers/v1/serviceTokenController.ts +++ b/backend/src/controllers/v1/serviceTokenController.ts @@ -1,7 +1,6 @@ import { Request, Response } from 'express'; import { ServiceToken } from '../../models'; import { createToken } from '../../helpers/auth'; -import { ENV_SET } from '../../variables'; import { JWT_SERVICE_SECRET } from '../../config'; /** @@ -36,7 +35,8 @@ export const createServiceToken = async (req: Request, res: Response) => { } = req.body; // 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'); } diff --git a/backend/src/controllers/v2/apiKeyDataController.ts b/backend/src/controllers/v2/apiKeyDataController.ts index 3aacde8aff..cafbacb5b9 100644 --- a/backend/src/controllers/v2/apiKeyDataController.ts +++ b/backend/src/controllers/v2/apiKeyDataController.ts @@ -65,7 +65,6 @@ export const createAPIKeyData = async (req: Request, res: Response) => { apiKey = `ak.${apiKeyData._id.toString()}.${secret}`; } catch (err) { - console.error(err); Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); return res.status(400).send({ diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts new file mode 100644 index 0000000000..1d1ffb6a69 --- /dev/null +++ b/backend/src/controllers/v2/environmentController.ts @@ -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, + }); +}; diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index 2c5cce6600..601314a2ad 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -2,10 +2,14 @@ import * as workspaceController from './workspaceController'; import * as serviceTokenDataController from './serviceTokenDataController'; import * as apiKeyDataController from './apiKeyDataController'; import * as secretController from './secretController'; +import * as secretsController from './secretsController'; +import * as environmentController from './environmentController'; export { workspaceController, serviceTokenDataController, apiKeyDataController, - secretController + secretController, + secretsController, + environmentController } diff --git a/backend/src/controllers/v2/secretController.ts b/backend/src/controllers/v2/secretController.ts index e0cc936c41..89567e616d 100644 --- a/backend/src/controllers/v2/secretController.ts +++ b/backend/src/controllers/v2/secretController.ts @@ -7,10 +7,68 @@ const { ValidationError } = mongoose.Error; import { BadRequestError, InternalServerError, UnauthorizedRequestError, ValidationError as RouteValidationError } from '../../utils/errors'; import { AnyBulkWriteOperation } from 'mongodb'; 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 { workspaceId, environmentName } = req.params + const { workspaceId, environment } = req.params const sanitizedSecretesToCreate: SanitizedSecretForCreate[] = [] secretsToCreate.forEach(rawSecret => { @@ -28,7 +86,7 @@ export const batchCreateSecrets = async (req: Request, res: Response) => { secretCommentTag: rawSecret.secretCommentTag, secretCommentHash: rawSecret.secretCommentHash, workspace: new Types.ObjectId(workspaceId), - environment: environmentName, + environment, type: rawSecret.type, user: new Types.ObjectId(req.user._id) } @@ -36,7 +94,7 @@ export const batchCreateSecrets = async (req: Request, res: Response) => { 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 instanceof ValidationError) { 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 }) } - res.status(200).send() -} - - -export const createSingleSecret = async (req: Request, res: Response) => { - try { - const secretFromDB = await Secret.findById(req.params.secretId) - return res.status(200).send(secretFromDB); - } catch (e) { - throw BadRequestError({ message: "Unable to find the requested secret" }) + if (postHogClient) { + postHogClient.capture({ + event: 'secrets added', + distinctId: req.user.email, + properties: { + numberOfSecrets: (secretsToCreate ?? []).length, + workspaceId, + environment, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + 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 secretIdsToDelete: string[] = req.body.secretIds @@ -70,10 +139,12 @@ export const batchDeleteSecrets = async (req: Request, res: Response) => { const secretsUserCanDeleteSet: Set = new Set(secretIdsUserCanDelete.map(objectId => objectId._id.toString())); const deleteOperationsToPerform: AnyBulkWriteOperation[] = [] + let numSecretsDeleted = 0; secretIdsToDelete.forEach(secretIdToDelete => { if (secretsUserCanDeleteSet.has(secretIdToDelete)) { const deleteOperation = { deleteOne: { filter: { _id: new Types.ObjectId(secretIdToDelete) } } } deleteOperationsToPerform.push(deleteOperation) + numSecretsDeleted++; } else { 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() } + 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() } -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 secretsModificationsRequested: ModifySecretRequestBody[] = req.body.secrets; 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 = new Set(secretIdsUserCanModify.map(objectId => objectId._id.toString())); const updateOperationsToPerform: any = [] - secretsModificationsRequested.forEach(userModifiedSecret => { if (secretsUserCanModifySet.has(userModifiedSecret._id.toString())) { const sanitizedSecret: SanitizedSecretModify = { @@ -135,23 +252,99 @@ export const batchModifySecrets = async (req: Request, res: Response) => { 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() } -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 { 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) { - userId = req.user._id.toString(); + userId = req.user._id; + userEmail = req.user.email; } if (req.serviceTokenData) { 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, environment, @@ -160,9 +353,49 @@ export const fetchAllSecrets = async (req: Request, res: Response) => { } ).then()) - if (retriveAllSecretsError instanceof ValidationError) { - throw RouteValidationError({ message: "Unable to get secrets, please try again", stack: retriveAllSecretsError.stack }) + if (err) { + 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 + }); } \ No newline at end of file diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts new file mode 100644 index 0000000000..098cfed6d0 --- /dev/null +++ b/backend/src/controllers/v2/secretsController.ts @@ -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 + }); +} \ No newline at end of file diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index 0dbdfa0769..efac159a59 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -19,7 +19,6 @@ import { import { pushKeys } from '../../helpers/key'; import { postHogClient, EventService } from '../../services'; import { eventPushSecrets } from '../../events'; -import { ENV_SET } from '../../variables'; interface V2PushSecret { type: string; // personal or shared @@ -52,7 +51,8 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { const { workspaceId } = req.params; // 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'); } @@ -129,6 +129,11 @@ export const pullSecrets = async (req: Request, res: Response) => { } else if (req.serviceTokenData) { 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({ userId, diff --git a/backend/src/ee/controllers/v1/secretController.ts b/backend/src/ee/controllers/v1/secretController.ts index a2d68ca96f..751f216119 100644 --- a/backend/src/ee/controllers/v1/secretController.ts +++ b/backend/src/ee/controllers/v1/secretController.ts @@ -1,6 +1,8 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; +import { Secret } from '../../../models'; import { SecretVersion } from '../../models'; +import { EESecretService } from '../../services'; /** * Return secret versions for secret with id [secretId] @@ -33,4 +35,103 @@ import { SecretVersion } from '../../models'; return res.status(200).send({ 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 + }); } \ No newline at end of file diff --git a/backend/src/ee/controllers/v1/secretSnapshotController.ts b/backend/src/ee/controllers/v1/secretSnapshotController.ts index 40e1a74a68..6e8605c2f9 100644 --- a/backend/src/ee/controllers/v1/secretSnapshotController.ts +++ b/backend/src/ee/controllers/v1/secretSnapshotController.ts @@ -2,6 +2,12 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; import { SecretSnapshot } from '../../models'; +/** + * Return secret snapshot with id [secretSnapshotId] + * @param req + * @param res + * @returns + */ export const getSecretSnapshot = async (req: Request, res: Response) => { let secretSnapshot; try { diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 88c31b8e1f..8fd7c87464 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -1,9 +1,17 @@ -import e, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; +import { + Secret +} from '../../../models'; import { SecretSnapshot, - Log + Log, + SecretVersion, + ISecretVersion } from '../../models'; +import { EESecretService } from '../../services'; +import { getLatestSecretVersionIds } from '../../helpers/secretVersion'; /** * 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] * @param req diff --git a/backend/src/ee/helpers/action.ts b/backend/src/ee/helpers/action.ts index 2971e3f961..389cfedbf1 100644 --- a/backend/src/ee/helpers/action.ts +++ b/backend/src/ee/helpers/action.ts @@ -1,7 +1,10 @@ import * as Sentry from '@sentry/node'; import { Types } from 'mongoose'; -import { Secret } from '../../models'; import { SecretVersion, Action } from '../models'; +import { + getLatestSecretVersionIds, + getLatestNSecretSecretVersionIds +} from '../helpers/secretVersion'; import { ACTION_UPDATE_SECRETS } from '../../variables'; /** @@ -30,65 +33,23 @@ const createActionSecretHelper = async ({ if (name === ACTION_UPDATE_SECRETS) { // case: action is updating secrets // -> add old and new secret versions - - // TODO: make query more efficient - latestSecretVersions = (await SecretVersion.aggregate([ - { - $match: { - secret: { - $in: secretIds, - }, - }, - }, - { - $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 - })); - - + latestSecretVersions = (await getLatestNSecretSecretVersionIds({ + secretIds, + n: 2 + })) + .map((s) => ({ + oldSecretVersion: s.versions[0]._id, + newSecretVersion: s.versions[1]._id + })); } else { // case: action is adding, deleting, or reading secrets // -> add new secret versions - latestSecretVersions = (await SecretVersion.aggregate([ - { - $match: { - secret: { - $in: secretIds - } - } - }, - { - $group: { - _id: '$secret', - version: { $max: '$version' }, - versionId: { $max: '$_id' } // secret version id - } - }, - { - $sort: { version: -1 } - } - ]) - .exec()) - .map((s) => ({ - newSecretVersion: s.versionId - })); + latestSecretVersions = (await getLatestSecretVersionIds({ + secretIds + })) + .map((s) => ({ + newSecretVersion: s.versionId + })); } action = await new Action({ diff --git a/backend/src/ee/helpers/secret.ts b/backend/src/ee/helpers/secret.ts index 529c9a9801..7edee915f0 100644 --- a/backend/src/ee/helpers/secret.ts +++ b/backend/src/ee/helpers/secret.ts @@ -1,11 +1,11 @@ import { Types } from 'mongoose'; import * as Sentry from '@sentry/node'; import { - Secret, + Secret, ISecret } from '../../models'; import { - SecretSnapshot, + SecretSnapshot, SecretVersion, ISecretVersion } from '../models'; @@ -18,24 +18,24 @@ import { * @param {String} obj.workspaceId * @returns {SecretSnapshot} secretSnapshot - new secret snapshot */ - const takeSecretSnapshotHelper = async ({ +const takeSecretSnapshotHelper = async ({ workspaceId }: { workspaceId: string; }) => { - + let secretSnapshot; try { const secretIds = (await Secret.find({ workspace: workspaceId }, '_id')).map((s) => s._id); - + const latestSecretVersions = (await SecretVersion.aggregate([ { - $match: { - secret: { - $in: secretIds - } + $match: { + secret: { + $in: secretIds + } } }, { @@ -48,14 +48,14 @@ import { { $sort: { version: -1 } } - ]) + ]) .exec()) .map((s) => s.versionId); - + const latestSecretSnapshot = await SecretSnapshot.findOne({ workspace: workspaceId }).sort({ version: -1 }); - + secretSnapshot = await new SecretSnapshot({ workspace: workspaceId, version: latestSecretSnapshot ? latestSecretSnapshot.version + 1 : 1, @@ -66,7 +66,7 @@ import { Sentry.captureException(err); throw new Error('Failed to take a secret snapshot'); } - + return secretSnapshot; } @@ -87,9 +87,9 @@ const addSecretVersionsHelper = async ({ } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to add secret versions'); + throw new Error(`Failed to add secret versions [err=${err}]`); } - + return newSecretVersions; } @@ -120,39 +120,39 @@ const markDeletedSecretVersionsHelper = async ({ const initSecretVersioningHelper = async () => { try { - await Secret.updateMany( + await Secret.updateMany( { version: { $exists: false } }, { $set: { version: 1 } } ); - - const unversionedSecrets: ISecret[] = await Secret.aggregate([ - { - $lookup: { - from: 'secretversions', - localField: '_id', - foreignField: 'secret', - as: 'versions', - }, - }, - { - $match: { - versions: { $size: 0 }, - }, - }, - ]); - - if (unversionedSecrets.length > 0) { - await addSecretVersionsHelper({ - secretVersions: unversionedSecrets.map((s, idx) => ({ - ...s, - secret: s._id, - version: s.version ? s.version : 1, - isDeleted: false, - workspace: s.workspace, - environment: s.environment - })) - }); - } + + const unversionedSecrets: ISecret[] = await Secret.aggregate([ + { + $lookup: { + from: 'secretversions', + localField: '_id', + foreignField: 'secret', + as: 'versions', + }, + }, + { + $match: { + versions: { $size: 0 }, + }, + }, + ]); + + if (unversionedSecrets.length > 0) { + await addSecretVersionsHelper({ + secretVersions: unversionedSecrets.map((s, idx) => ({ + ...s, + secret: s._id, + version: s.version ? s.version : 1, + isDeleted: false, + workspace: s.workspace, + environment: s.environment + })) + }); + } } catch (err) { Sentry.setUser(null); @@ -162,7 +162,7 @@ const initSecretVersioningHelper = async () => { } export { - takeSecretSnapshotHelper, + takeSecretSnapshotHelper, addSecretVersionsHelper, markDeletedSecretVersionsHelper, initSecretVersioningHelper diff --git a/backend/src/ee/helpers/secretVersion.ts b/backend/src/ee/helpers/secretVersion.ts new file mode 100644 index 0000000000..d5859e183d --- /dev/null +++ b/backend/src/ee/helpers/secretVersion.ts @@ -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 +} diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index 0197c3a254..1af4aff2c3 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -2,31 +2,18 @@ import { Schema, model, Types } from 'mongoose'; import { SECRET_SHARED, SECRET_PERSONAL, - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD } 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 { - _id?: Types.ObjectId; - secret: Types.ObjectId; - version: number; + _id: Types.ObjectId; + secret: Types.ObjectId; + version: number; workspace: Types.ObjectId; // new type: string; // new user: Types.ObjectId; // new environment: string; // new - isDeleted: boolean; - secretKeyCiphertext: string; + isDeleted: boolean; + secretKeyCiphertext: string; secretKeyIV: string; secretKeyTag: string; secretKeyHash: string; @@ -37,17 +24,17 @@ export interface ISecretVersion { } const secretVersionSchema = new Schema( - { - secret: { // could be deleted - type: Schema.Types.ObjectId, - ref: 'Secret', - required: true - }, - version: { - type: Number, - default: 1, - required: true - }, + { + secret: { // could be deleted + type: Schema.Types.ObjectId, + ref: 'Secret', + required: true + }, + version: { + type: Number, + default: 1, + required: true + }, workspace: { type: Schema.Types.ObjectId, ref: 'Workspace', @@ -65,15 +52,14 @@ const secretVersionSchema = new Schema( }, environment: { type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], required: true }, - isDeleted: { - type: Boolean, - default: false, - required: true - }, - secretKeyCiphertext: { + isDeleted: { // consider removing field + type: Boolean, + default: false, + required: true + }, + secretKeyCiphertext: { type: String, required: true }, @@ -86,8 +72,7 @@ const secretVersionSchema = new Schema( required: true }, secretKeyHash: { - type: String, - required: true + type: String }, secretValueCiphertext: { type: String, @@ -102,13 +87,12 @@ const secretVersionSchema = new Schema( required: true }, secretValueHash: { - type: String, - required: true + type: String } - }, - { - timestamps: true - } + }, + { + timestamps: true + } ); const SecretVersion = model('SecretVersion', secretVersionSchema); diff --git a/backend/src/ee/routes/v1/secret.ts b/backend/src/ee/routes/v1/secret.ts index 43cc8bafc6..ac3089e337 100644 --- a/backend/src/ee/routes/v1/secret.ts +++ b/backend/src/ee/routes/v1/secret.ts @@ -5,7 +5,7 @@ import { requireSecretAuth, validateRequest } from '../../../middleware'; -import { query, param } from 'express-validator'; +import { query, param, body } from 'express-validator'; import { secretController } from '../../controllers/v1'; import { ADMIN, MEMBER } from '../../../variables'; @@ -24,4 +24,17 @@ router.get( 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; \ No newline at end of file diff --git a/backend/src/ee/routes/v1/secretSnapshot.ts b/backend/src/ee/routes/v1/secretSnapshot.ts index 80aa7d1ee3..d10da44566 100644 --- a/backend/src/ee/routes/v1/secretSnapshot.ts +++ b/backend/src/ee/routes/v1/secretSnapshot.ts @@ -7,7 +7,7 @@ import { requireAuth, validateRequest } from '../../../middleware'; -import { param } from 'express-validator'; +import { param, body } from 'express-validator'; import { ADMIN, MEMBER } from '../../../variables'; import { secretSnapshotController } from '../../controllers/v1'; diff --git a/backend/src/ee/routes/v1/workspace.ts b/backend/src/ee/routes/v1/workspace.ts index 4b2e839eb7..c9da582614 100644 --- a/backend/src/ee/routes/v1/workspace.ts +++ b/backend/src/ee/routes/v1/workspace.ts @@ -5,7 +5,7 @@ import { requireWorkspaceAuth, validateRequest } from '../../../middleware'; -import { param, query } from 'express-validator'; +import { param, query, body } from 'express-validator'; import { ADMIN, MEMBER } from '../../../variables'; import { workspaceController } from '../../controllers/v1'; @@ -37,6 +37,20 @@ router.get( 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( '/:workspaceId/logs', requireAuth({ diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts index b3f276b53c..7519ef18bb 100644 --- a/backend/src/helpers/bot.ts +++ b/backend/src/helpers/bot.ts @@ -72,7 +72,7 @@ const getSecretsHelper = async ({ try { const key = await getKey({ workspaceId }); const secrets = await Secret.find({ - workspaceId, + workspace: workspaceId, environment, type: SECRET_SHARED }); @@ -84,7 +84,7 @@ const getSecretsHelper = async ({ tag: secret.secretKeyTag, key }); - + const secretValue = decryptSymmetric({ ciphertext: secret.secretValueCiphertext, iv: secret.secretValueIV, diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index d92156ece0..f602f17291 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -7,8 +7,6 @@ import { import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations'; import { BotService } from '../services'; import { - ENV_DEV, - EVENT_PUSH_SECRETS, INTEGRATION_VERCEL, INTEGRATION_NETLIFY } from '../variables'; @@ -36,11 +34,13 @@ interface Update { const handleOAuthExchangeHelper = async ({ workspaceId, integration, - code + code, + environment }: { workspaceId: string; integration: string; code: string; + environment: string; }) => { let action; let integrationAuth; @@ -102,9 +102,9 @@ const handleOAuthExchangeHelper = async ({ // initialize new integration after exchange await new Integration({ workspace: workspaceId, - environment: ENV_DEV, isActive: false, app: null, + environment, integration, integrationAuth: integrationAuth._id }).save(); diff --git a/backend/src/helpers/rateLimiter.ts b/backend/src/helpers/rateLimiter.ts index 6171559af5..55ac3be0c9 100644 --- a/backend/src/helpers/rateLimiter.ts +++ b/backend/src/helpers/rateLimiter.ts @@ -6,7 +6,9 @@ const apiLimiter = rateLimit({ max: 450, standardHeaders: true, legacyHeaders: false, - skip: (request) => request.path === '/healthcheck' + skip: (request) => { + return request.path === '/healthcheck' || request.path === '/api/status' + } }); // 5 requests per hour diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index 920e8dc1d7..74ba062bda 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -3,6 +3,7 @@ import { Types } from 'mongoose'; import { Secret, ISecret, + Membership } from '../models'; import { EESecretService, @@ -20,6 +21,46 @@ import { ACTION_READ_SECRETS } 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 { ciphertextKey: string; ivKey: string; @@ -187,6 +228,7 @@ const v1PushSecrets = async ({ }) => { const newSecret = newSecretsObj[`${type}-${secretKeyHash}`]; return ({ + _id: new Types.ObjectId(), secret: _id, version: version ? version + 1 : 1, workspace: new Types.ObjectId(workspaceId), @@ -258,6 +300,7 @@ const v1PushSecrets = async ({ secretValueTag, secretValueHash }) => ({ + _id: new Types.ObjectId(), secret: _id, version, workspace, @@ -280,7 +323,7 @@ const v1PushSecrets = async ({ // (EE) take a secret snapshot await EESecretService.takeSecretSnapshot({ workspaceId - }) + }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -527,6 +570,7 @@ const v1PushSecrets = async ({ environment: string; }): Promise => { let secrets: any; // TODO: FIX any + try { // get shared workspace secrets const sharedSecrets = await Secret.find({ @@ -655,6 +699,7 @@ const reformatPullSecrets = ({ secrets }: { secrets: ISecret[] }) => { }; export { + validateSecrets, v1PushSecrets, v2PushSecrets, pullSecrets, diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index e3b78c4811..02a1904c70 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -9,14 +9,9 @@ import { INTEGRATION_GITHUB, INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL, - INTEGRATION_GITHUB_API_URL + INTEGRATION_NETLIFY_API_URL } from '../variables'; -interface GitHubApp { - name: string; -} - /** * Return list of names of apps for integration named [integration] * @param {Object} obj @@ -47,6 +42,7 @@ const getApps = async ({ break; case INTEGRATION_VERCEL: apps = await getAppsVercel({ + integrationAuth, accessToken }); break; @@ -110,17 +106,28 @@ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { * @returns {Object[]} apps - names of Vercel apps * @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; try { const res = ( await axios.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, { headers: { Authorization: `Bearer ${accessToken}` + }, + ...( integrationAuth?.teamId ? { + params: { + teamId: integrationAuth.teamId } + } : {}) }) ).data; - + apps = res.projects.map((a: any) => ({ name: a.name })); diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 3e70761187..26aca5fdb2 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -8,8 +8,7 @@ import { INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL, - INTEGRATION_GITHUB_TOKEN_URL, - INTEGRATION_GITHUB_API_URL + INTEGRATION_GITHUB_TOKEN_URL } from '../variables'; import { SITE_URL, diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 30628fb9a5..0ae57dc594 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import * as Sentry from '@sentry/node'; import { Octokit } from '@octokit/rest'; // import * as sodium from 'libsodium-wrappers'; @@ -12,14 +12,10 @@ import { INTEGRATION_GITHUB, INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL, - INTEGRATION_GITHUB_API_URL + INTEGRATION_NETLIFY_API_URL } from '../variables'; 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] * @param {Object} obj @@ -53,6 +49,7 @@ const syncSecrets = async ({ case INTEGRATION_VERCEL: await syncSecretsVercel({ integration, + integrationAuth, secrets, accessToken }); @@ -139,14 +136,15 @@ const syncSecretsHeroku = async ({ */ const syncSecretsVercel = async ({ integration, + integrationAuth, secrets, accessToken }: { integration: IIntegration, + integrationAuth: IIntegrationAuth, secrets: any; accessToken: string; }) => { - interface VercelSecret { id?: string; type: string; @@ -156,129 +154,135 @@ const syncSecretsVercel = async ({ } try { - // Get all (decrypted) secrets back from Vercel in - // decrypted format - const params = new URLSearchParams({ - decrypt: "true" - }); - - const res = (await Promise.all((await axios.get( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env`, - { + // Get all (decrypted) secrets back from Vercel in + // decrypted format + const params: { [key: string]: string } = { + decrypt: 'true', + ...( integrationAuth?.teamId ? { + teamId: integrationAuth.teamId + } : {}) + } + + 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, headers: { Authorization: `Bearer ${accessToken}` } - } - )) - .data - .envs - .filter((secret: VercelSecret) => secret.target.includes(integration.target)) - .map(async (secret: VercelSecret) => (await axios.get( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, - { - headers: { - Authorization: `Bearer ${accessToken}` - } - - } - )).data) - )).reduce((obj: any, secret: any) => ({ - ...obj, - [secret.key]: secret - }), {}); - - const updateSecrets: VercelSecret[] = []; - const deleteSecrets: VercelSecret[] = []; - const newSecrets: VercelSecret[] = []; + } + )).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], - }); - } - }); + // 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 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, - { - 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}`, - { - 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: { + Authorization: `Bearer ${accessToken}` } - ); - }); - } + } + ); + }); + } } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to sync secrets to Vercel'); + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Vercel'); } } @@ -302,188 +306,188 @@ const syncSecretsNetlify = async ({ }) => { try { - interface NetlifyValue { - id?: string; - context: string; // 'dev' | 'branch-deploy' | 'deploy-preview' | 'production', - value: string; - } - - interface NetlifySecret { - key: string; - values: NetlifyValue[]; - } - - interface NetlifySecretsRes { - [index: string]: NetlifySecret; - } - - const getParams = new URLSearchParams({ - context_name: 'all', // integration.context or all - site_id: integration.siteId - }); - - const res = (await axios.get( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, - { - params: getParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - )) - .data - .reduce((obj: any, secret: any) => ({ - ...obj, - [secret.key]: secret - }), {}); - - const newSecrets: NetlifySecret[] = []; // createEnvVars - const deleteSecrets: string[] = []; // deleteEnvVar - const deleteSecretValues: NetlifySecret[] = []; // deleteEnvVarValue - const updateSecrets: NetlifySecret[] = []; // setEnvVarValue - - // identify secrets to create and update - Object.keys(secrets).map((key) => { - if (!(key in res)) { - // case: Infisical secret does not exist in Netlify -> create secret - newSecrets.push({ - key, - values: [{ - value: secrets[key], - context: integration.context - }] - }); - } else { - // case: Infisical secret exists in Netlify - const contexts = res[key].values - .reduce((obj: any, value: NetlifyValue) => ({ - ...obj, - [value.context]: value - }), {}); - - if (integration.context in contexts) { - // case: Netlify secret value exists in integration context - if (secrets[key] !== contexts[integration.context].value) { - // case: Infisical and Netlify secret values are different - // -> update Netlify secret context and value - updateSecrets.push({ - key, - values: [{ - context: integration.context, - value: secrets[key] - }] - }); - } - } else { - // case: Netlify secret value does not exist in integration context - // -> add the new Netlify secret context and value - updateSecrets.push({ - key, - values: [{ - context: integration.context, - value: secrets[key] - }] - }); - } - } - }) - - // identify secrets to delete - // TODO: revise (patch case where 1 context was deleted but others still there - Object.keys(res).map((key) => { - // loop through each key's context - if (!(key in secrets)) { - // case: Netlify secret does not exist in Infisical - - const numberOfValues = res[key].values.length; - - res[key].values.forEach((value: NetlifyValue) => { - if (value.context === integration.context) { - if (numberOfValues <= 1) { - // case: Netlify secret value has less than 1 context -> delete secret - deleteSecrets.push(key); - } else { - // case: Netlify secret value has more than 1 context -> delete secret value context - deleteSecretValues.push({ - key, - values: [{ - id: value.id, - context: integration.context, - value: value.value - }] - }); - } - } - }); - } - }); + interface NetlifyValue { + id?: string; + context: string; // 'dev' | 'branch-deploy' | 'deploy-preview' | 'production', + value: string; + } + + interface NetlifySecret { + key: string; + values: NetlifyValue[]; + } + + interface NetlifySecretsRes { + [index: string]: NetlifySecret; + } + + const getParams = new URLSearchParams({ + context_name: 'all', // integration.context or all + site_id: integration.siteId + }); + + const res = (await axios.get( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, + { + params: getParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + )) + .data + .reduce((obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret + }), {}); + + const newSecrets: NetlifySecret[] = []; // createEnvVars + const deleteSecrets: string[] = []; // deleteEnvVar + const deleteSecretValues: NetlifySecret[] = []; // deleteEnvVarValue + const updateSecrets: NetlifySecret[] = []; // setEnvVarValue + + // identify secrets to create and update + Object.keys(secrets).map((key) => { + if (!(key in res)) { + // case: Infisical secret does not exist in Netlify -> create secret + newSecrets.push({ + key, + values: [{ + value: secrets[key], + context: integration.context + }] + }); + } else { + // case: Infisical secret exists in Netlify + const contexts = res[key].values + .reduce((obj: any, value: NetlifyValue) => ({ + ...obj, + [value.context]: value + }), {}); + + if (integration.context in contexts) { + // case: Netlify secret value exists in integration context + if (secrets[key] !== contexts[integration.context].value) { + // case: Infisical and Netlify secret values are different + // -> update Netlify secret context and value + updateSecrets.push({ + key, + values: [{ + context: integration.context, + value: secrets[key] + }] + }); + } + } else { + // case: Netlify secret value does not exist in integration context + // -> add the new Netlify secret context and value + updateSecrets.push({ + key, + values: [{ + context: integration.context, + value: secrets[key] + }] + }); + } + } + }) + + // identify secrets to delete + // TODO: revise (patch case where 1 context was deleted but others still there + Object.keys(res).map((key) => { + // loop through each key's context + if (!(key in secrets)) { + // case: Netlify secret does not exist in Infisical + + const numberOfValues = res[key].values.length; + + res[key].values.forEach((value: NetlifyValue) => { + if (value.context === integration.context) { + if (numberOfValues <= 1) { + // case: Netlify secret value has less than 1 context -> delete secret + deleteSecrets.push(key); + } else { + // case: Netlify secret value has more than 1 context -> delete secret value context + deleteSecretValues.push({ + key, + values: [{ + id: value.id, + context: integration.context, + value: value.value + }] + }); + } + } + }); + } + }); - const syncParams = new URLSearchParams({ - site_id: integration.siteId - }); + const syncParams = new URLSearchParams({ + site_id: integration.siteId + }); - if (newSecrets.length > 0) { - await axios.post( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, - newSecrets, - { - params: syncParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - } + if (newSecrets.length > 0) { + await axios.post( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, + newSecrets, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + } - if (updateSecrets.length > 0) { - updateSecrets.forEach(async (secret: NetlifySecret) => { - await axios.patch( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, - { - context: secret.values[0].context, - value: secret.values[0].value - }, - { - params: syncParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - }); - } + if (updateSecrets.length > 0) { + updateSecrets.forEach(async (secret: NetlifySecret) => { + await axios.patch( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, + { + context: secret.values[0].context, + value: secret.values[0].value + }, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } - if (deleteSecrets.length > 0) { - deleteSecrets.forEach(async (key: string) => { - await axios.delete( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${key}`, - { - params: syncParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - }); - } + if (deleteSecrets.length > 0) { + deleteSecrets.forEach(async (key: string) => { + await axios.delete( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${key}`, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } - if (deleteSecretValues.length > 0) { - deleteSecretValues.forEach(async (secret: NetlifySecret) => { - await axios.delete( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}/value/${secret.values[0].id}`, - { - params: syncParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - }); - } + if (deleteSecretValues.length > 0) { + deleteSecretValues.forEach(async (secret: NetlifySecret) => { + await axios.delete( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}/value/${secret.values[0].id}`, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to sync secrets to Heroku'); + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Heroku'); } } diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 119a86abe5..6a3537076c 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -8,6 +8,7 @@ import requireIntegrationAuthorizationAuth from './requireIntegrationAuthorizati import requireServiceTokenAuth from './requireServiceTokenAuth'; import requireServiceTokenDataAuth from './requireServiceTokenDataAuth'; import requireSecretAuth from './requireSecretAuth'; +import requireSecretsAuth from './requireSecretsAuth'; import validateRequest from './validateRequest'; export { @@ -21,5 +22,6 @@ export { requireServiceTokenAuth, requireServiceTokenDataAuth, requireSecretAuth, + requireSecretsAuth, validateRequest }; diff --git a/backend/src/middleware/requireSecretAuth.ts b/backend/src/middleware/requireSecretAuth.ts index c6a291200a..36e47247ea 100644 --- a/backend/src/middleware/requireSecretAuth.ts +++ b/backend/src/middleware/requireSecretAuth.ts @@ -5,6 +5,9 @@ import { validateMembership } 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. * @param {Object} obj @@ -34,7 +37,7 @@ const requireSecretAuth = ({ acceptedRoles }); - req.secret = secret as any; + req._secret = secret; next(); } catch (err) { diff --git a/backend/src/middleware/requireSecretsAuth.ts b/backend/src/middleware/requireSecretsAuth.ts new file mode 100644 index 0000000000..c8b89a74c7 --- /dev/null +++ b/backend/src/middleware/requireSecretsAuth.ts @@ -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; \ No newline at end of file diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 6da699216e..98e4934d90 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -1,9 +1,5 @@ import { Schema, model, Types } from 'mongoose'; import { - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -13,7 +9,7 @@ import { export interface IIntegration { _id: Types.ObjectId; workspace: Types.ObjectId; - environment: 'dev' | 'test' | 'staging' | 'prod'; + environment: string; isActive: boolean; app: string; target: string; @@ -32,7 +28,6 @@ const integrationSchema = new Schema( }, environment: { type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], required: true }, isActive: { diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index bbaaff8c3a..6887c8b0f6 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -2,10 +2,6 @@ import { Schema, model, Types } from 'mongoose'; import { SECRET_SHARED, SECRET_PERSONAL, - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD } from '../variables'; export interface ISecret { @@ -53,7 +49,6 @@ const secretSchema = new Schema( }, environment: { type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], required: true }, secretKeyCiphertext: { @@ -69,8 +64,7 @@ const secretSchema = new Schema( required: true }, secretKeyHash: { - type: String, - required: true + type: String }, secretValueCiphertext: { type: String, @@ -85,8 +79,7 @@ const secretSchema = new Schema( required: true }, secretValueHash: { - type: String, - required: true + type: String }, secretCommentCiphertext: { type: String, diff --git a/backend/src/models/serviceToken.ts b/backend/src/models/serviceToken.ts index b5a2f4ec97..9d91b076ea 100644 --- a/backend/src/models/serviceToken.ts +++ b/backend/src/models/serviceToken.ts @@ -1,7 +1,4 @@ import { Schema, model, Types } from 'mongoose'; -import { ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD } from '../variables'; - -// TODO: deprecate export interface IServiceToken { _id: Types.ObjectId; name: string; @@ -33,7 +30,6 @@ const serviceTokenSchema = new Schema( }, environment: { type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], required: true }, expiresAt: { diff --git a/backend/src/models/workspace.ts b/backend/src/models/workspace.ts index 1e886c5239..fa7dbc8b58 100644 --- a/backend/src/models/workspace.ts +++ b/backend/src/models/workspace.ts @@ -4,6 +4,10 @@ export interface IWorkspace { _id: Types.ObjectId; name: string; organization: Types.ObjectId; + environments: Array<{ + name: string; + slug: string; + }>; } const workspaceSchema = new Schema({ @@ -15,7 +19,33 @@ const workspaceSchema = new Schema({ type: Schema.Types.ObjectId, ref: 'Organization', 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('Workspace', workspaceSchema); diff --git a/backend/src/routes/status/index.ts b/backend/src/routes/status/index.ts new file mode 100644 index 0000000000..d3c694b92e --- /dev/null +++ b/backend/src/routes/status/index.ts @@ -0,0 +1,5 @@ +import healthCheck from './status'; + +export { + healthCheck +} \ No newline at end of file diff --git a/backend/src/routes/status/status.ts b/backend/src/routes/status/status.ts new file mode 100644 index 0000000000..4ab82cca9f --- /dev/null +++ b/backend/src/routes/status/status.ts @@ -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 \ No newline at end of file diff --git a/backend/src/routes/v2/environment.ts b/backend/src/routes/v2/environment.ts new file mode 100644 index 0000000000..924db18fcd --- /dev/null +++ b/backend/src/routes/v2/environment.ts @@ -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; diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index d0f3833ba3..2740c35304 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -1,11 +1,15 @@ -import secret from './secret'; +import secret from './secret'; // stop-supporting +import secrets from './secrets'; import workspace from './workspace'; import serviceTokenData from './serviceTokenData'; import apiKeyData from './apiKeyData'; +import environment from "./environment" export { secret, + secrets, workspace, serviceTokenData, - apiKeyData -} + apiKeyData, + environment +} \ No newline at end of file diff --git a/backend/src/routes/v2/secret.ts b/backend/src/routes/v2/secret.ts index 52e0e316e8..b0c0f8f363 100644 --- a/backend/src/routes/v2/secret.ts +++ b/backend/src/routes/v2/secret.ts @@ -1,18 +1,21 @@ -import express, { Request, Response } from 'express'; -import { requireAuth, requireWorkspaceAuth, validateRequest } from '../../middleware'; +import express from 'express'; +import { + requireAuth, + requireWorkspaceAuth, + requireSecretAuth, + validateRequest +} from '../../middleware'; import { body, param, query } from 'express-validator'; import { ADMIN, MEMBER } from '../../variables'; import { CreateSecretRequestBody, ModifySecretRequestBody } from '../../types/secret'; import { secretController } from '../../controllers/v2'; -import { fetchAllSecrets } from '../../controllers/v2/secretController'; + +// note to devs: stop supporting const router = express.Router(); -/** - * Create many secrets for a given workspace and environmentName - */ router.post( - '/batch-create/workspace/:workspaceId/environment/:environmentName', + '/batch-create/workspace/:workspaceId/environment/:environment', requireAuth({ acceptedAuthModes: ['jwt'] }), @@ -20,15 +23,29 @@ router.post( acceptedRoles: [ADMIN, MEMBER] }), 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('channel'), + validateRequest, + 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.batchCreateSecrets + secretController.createSecret ); -/** - * Get all secrets for a given environment and workspace id - */ router.get( '/workspace/:workspaceId', param('workspaceId').exists().trim(), @@ -39,13 +56,23 @@ router.get( requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER] }), + query('channel'), + validateRequest, + secretController.getSecrets +); + +router.get( + '/:secretId', + requireAuth({ + acceptedAuthModes: ['jwt', 'serviceToken'] + }), + requireSecretAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), validateRequest, - fetchAllSecrets + secretController.getSecret ); -/** - * Batch delete secrets in a given workspace and environment name - */ router.delete( '/batch/workspace/:workspaceId/environment/:environmentName', requireAuth({ @@ -58,13 +85,22 @@ router.delete( acceptedRoles: [ADMIN, MEMBER] }), 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( '/batch-modify/workspace/:workspaceId/environment/:environmentName', requireAuth({ @@ -77,7 +113,23 @@ router.patch( acceptedRoles: [ADMIN, MEMBER] }), 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; diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts new file mode 100644 index 0000000000..ccdce53215 --- /dev/null +++ b/backend/src/routes/v2/secrets.ts @@ -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; + + + diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 32f5f5a88b..43746aee4a 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -11,10 +11,6 @@ import { setIntegrationAuthAccessHelper, } from '../helpers/integration'; import { exchangeCode } from '../integrations'; -import { - ENV_DEV, - EVENT_PUSH_SECRETS -} from '../variables'; // should sync stuff be here too? Probably. // TODO: move bot functions to IntegrationService. @@ -32,22 +28,26 @@ class IntegrationService { * - Create bot sequence for integration * @param {Object} obj * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.environment - workspace environment * @param {String} obj.integration - name of integration * @param {String} obj.code - code */ static async handleOAuthExchange({ workspaceId, integration, - code + code, + environment }: { workspaceId: string; integration: string; code: string; + environment: string; }) { await handleOAuthExchangeHelper({ workspaceId, integration, - code + code, + environment }); } diff --git a/backend/src/services/PostHogClient.ts b/backend/src/services/PostHogClient.ts index 4ce0117f05..0d91a1c134 100644 --- a/backend/src/services/PostHogClient.ts +++ b/backend/src/services/PostHogClient.ts @@ -7,12 +7,12 @@ import { } from '../config'; import { getLogger } from '../utils/logger'; -if(TELEMETRY_ENABLED){ +if(!TELEMETRY_ENABLED){ getLogger("backend-main").info([ "", - "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.", - "To opt out of telemetry, you can set `TELEMETRY_ENABLED=false` within the environment variables", + "To improve, Infisical collects telemetry data about general usage.", + "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 into telemetry, you can set `TELEMETRY_ENABLED=true` within the environment variables.", ].join('\n')) } diff --git a/backend/src/services/smtp.ts b/backend/src/services/smtp.ts index 12841eee7d..f960f6bae3 100644 --- a/backend/src/services/smtp.ts +++ b/backend/src/services/smtp.ts @@ -28,7 +28,13 @@ if (SMTP_SECURE) { } break; default: - mailOpts.secure = true; + if (SMTP_HOST.includes('amazonaws.com')) { + mailOpts.tls = { + ciphers: 'TLSv1.2' + } + } else { + mailOpts.secure = true; + } break; } } diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts index f43b5fa79c..ae9edb4c53 100644 --- a/backend/src/types/express/index.d.ts +++ b/backend/src/types/express/index.d.ts @@ -1,4 +1,5 @@ import * as express from 'express'; +import { ISecret } from '../../models'; // TODO: fix (any) types declare global { @@ -12,7 +13,8 @@ declare global { integration: any; integrationAuth: any; bot: any; - secret: any; + _secret: any; + secrets: any; secretSnapshot: any; serviceToken: any; accessToken: any; diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 16c068925c..4f7ffd8b0f 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -19,7 +19,6 @@ import { INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, INTEGRATION_NETLIFY_API_URL, - INTEGRATION_GITHUB_API_URL, INTEGRATION_OPTIONS } from './integration'; import { @@ -66,7 +65,6 @@ export { INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, INTEGRATION_NETLIFY_API_URL, - INTEGRATION_GITHUB_API_URL, EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS, ACTION_ADD_SECRETS, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index ed18c5a2ac..b298a784a0 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -11,10 +11,10 @@ const INTEGRATION_VERCEL = 'vercel'; const INTEGRATION_NETLIFY = 'netlify'; const INTEGRATION_GITHUB = 'github'; const INTEGRATION_SET = new Set([ - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_GITHUB + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB ]); // integration types @@ -23,22 +23,21 @@ const INTEGRATION_OAUTH2 = 'oauth2'; // integration oauth endpoints const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; const INTEGRATION_VERCEL_TOKEN_URL = - 'https://api.vercel.com/v2/oauth/access_token'; + 'https://api.vercel.com/v2/oauth/access_token'; const INTEGRATION_NETLIFY_TOKEN_URL = 'https://api.netlify.com/oauth/token'; const INTEGRATION_GITHUB_TOKEN_URL = - 'https://github.com/login/oauth/access_token'; + 'https://github.com/login/oauth/access_token'; // integration apps endpoints const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com'; const INTEGRATION_VERCEL_API_URL = 'https://api.vercel.com'; const INTEGRATION_NETLIFY_API_URL = 'https://api.netlify.com'; -const INTEGRATION_GITHUB_API_URL = 'https://api.github.com'; const INTEGRATION_OPTIONS = [ { name: 'Heroku', slug: 'heroku', - image: 'Heroku', + image: 'Heroku', isAvailable: true, type: 'oauth2', clientId: CLIENT_ID_HEROKU, @@ -47,8 +46,8 @@ const INTEGRATION_OPTIONS = [ { name: 'Vercel', slug: 'vercel', - image: 'Vercel', - isAvailable: false, + image: 'Vercel', + isAvailable: true, type: 'vercel', clientId: '', clientSlug: CLIENT_SLUG_VERCEL, @@ -57,7 +56,7 @@ const INTEGRATION_OPTIONS = [ { name: 'Netlify', slug: 'netlify', - image: 'Netlify', + image: 'Netlify', isAvailable: false, type: 'oauth2', clientId: CLIENT_ID_NETLIFY, @@ -66,17 +65,17 @@ const INTEGRATION_OPTIONS = [ { name: 'GitHub', slug: 'github', - image: 'GitHub', + image: 'GitHub', isAvailable: false, type: 'oauth2', clientId: CLIENT_ID_GITHUB, docsLink: '' - + }, { name: 'Google Cloud Platform', slug: 'gcp', - image: 'Google Cloud Platform', + image: 'Google Cloud Platform', isAvailable: false, type: '', clientId: '', @@ -85,7 +84,7 @@ const INTEGRATION_OPTIONS = [ { name: 'Amazon Web Services', slug: 'aws', - image: 'Amazon Web Services', + image: 'Amazon Web Services', isAvailable: false, type: '', clientId: '', @@ -94,7 +93,7 @@ const INTEGRATION_OPTIONS = [ { name: 'Microsoft Azure', slug: 'azure', - image: 'Microsoft Azure', + image: 'Microsoft Azure', isAvailable: false, type: '', clientId: '', @@ -103,7 +102,7 @@ const INTEGRATION_OPTIONS = [ { name: 'Travis CI', slug: 'travisci', - image: 'Travis CI', + image: 'Travis CI', isAvailable: false, type: '', clientId: '', @@ -112,7 +111,7 @@ const INTEGRATION_OPTIONS = [ { name: 'Circle CI', slug: 'circleci', - image: 'Circle CI', + image: 'Circle CI', isAvailable: false, type: '', clientId: '', @@ -121,19 +120,18 @@ const INTEGRATION_OPTIONS = [ ] export { - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_GITHUB, - INTEGRATION_SET, - INTEGRATION_OAUTH2, - INTEGRATION_HEROKU_TOKEN_URL, - INTEGRATION_VERCEL_TOKEN_URL, - INTEGRATION_NETLIFY_TOKEN_URL, - INTEGRATION_GITHUB_TOKEN_URL, - INTEGRATION_HEROKU_API_URL, - INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL, - INTEGRATION_GITHUB_API_URL, - INTEGRATION_OPTIONS + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB, + INTEGRATION_SET, + INTEGRATION_OAUTH2, + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, + INTEGRATION_GITHUB_TOKEN_URL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL, + INTEGRATION_OPTIONS }; diff --git a/backend/swagger.ts b/backend/swagger.ts new file mode 100644 index 0000000000..0505bb813a --- /dev/null +++ b/backend/swagger.ts @@ -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); \ No newline at end of file diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index 96d8817b54..fd04e9ce2c 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -31,8 +31,8 @@ var exportCmd = &cobra.Command{ Args: cobra.NoArgs, PreRun: func(cmd *cobra.Command, args []string) { toggleDebug(cmd, args) - util.RequireLogin() - util.RequireLocalWorkspaceFile() + // util.RequireLogin() + // util.RequireLocalWorkspaceFile() }, Run: func(cmd *cobra.Command, args []string) { envName, err := cmd.Flags().GetString("env") diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index 923782e3fe..c10f38fc43 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -6,6 +6,7 @@ package cmd import ( "encoding/base64" "encoding/hex" + "strings" "errors" "fmt" @@ -17,6 +18,7 @@ import ( "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/srp" "github.com/Infisical/infisical-merge/packages/util" + "github.com/fatih/color" "github.com/go-resty/resty/v2" "github.com/manifoldco/promptui" log "github.com/sirupsen/logrus" @@ -31,7 +33,9 @@ var loginCmd = &cobra.Command{ PreRun: toggleDebug, Run: func(cmd *cobra.Command, args []string) { 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) } @@ -97,7 +101,7 @@ var loginCmd = &cobra.Command{ 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) }, } diff --git a/cli/packages/cmd/root.go b/cli/packages/cmd/root.go index b1b53d2ff2..e68c6826c6 100644 --- a/cli/packages/cmd/root.go +++ b/cli/packages/cmd/root.go @@ -15,7 +15,7 @@ var rootCmd = &cobra.Command{ 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.`, 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. diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 87b4cda4e5..fe07dee82e 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -12,8 +12,8 @@ import ( "strings" "syscall" - "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" + "github.com/fatih/color" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -85,16 +85,50 @@ var runCmd = &cobra.Command{ 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") { command := cmd.Flag("command").Value.String() - err = executeMultipleCommandWithEnvs(command, secrets) + err = executeMultipleCommandWithEnvs(command, len(secretsByKey), env) if err != nil { util.HandleError(err, "Unable to execute your chained command") } } else { - err = executeSingleCommandWithEnvs(args, secrets) + err = executeSingleCommandWithEnvs(args, len(secretsByKey), env) if err != nil { 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 -func executeSingleCommandWithEnvs(args []string, secrets []models.SingleEnvironmentVariable) error { +func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string) error { command := args[0] argsForCommand := args[1:] - numberOfSecretsInjected := fmt.Sprintf("\u2713 Injected %v Infisical secrets into your application process successfully", len(secrets)) - log.Infof("\x1b[%dm%s\x1b[0m", 32, numberOfSecretsInjected) - log.Debugf("executing command: %s %s \n", command, strings.Join(argsForCommand, " ")) - log.Debugf("Secrets injected: %v", secrets) + color.Green("Injecting %v Infisical secrets into your application process", secretsCount) cmd := exec.Command(command, argsForCommand...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - cmd.Env = getAllEnvs(secrets) + cmd.Env = env 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"} if runtime.GOOS == "windows" { shell = [2]string{"cmd", "/C"} @@ -140,12 +171,10 @@ func executeMultipleCommandWithEnvs(fullCommand string, secrets []models.SingleE cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout 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)) - log.Infof("\x1b[%dm%s\x1b[0m", 32, numberOfSecretsInjected) + color.Green("Injecting %v Infisical secrets into your application process", secretsCount) log.Debugf("executing command: %s %s %s \n", shell[0], shell[1], fullCommand) - log.Debugf("Secrets injected: %v", secrets) return execCmd(cmd) } @@ -175,23 +204,3 @@ func execCmd(cmd *exec.Cmd) error { os.Exit(waitStatus.ExitStatus()) 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 -} diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index df77e67d45..5a9ce2724e 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -311,14 +311,25 @@ var secretsDeleteCmd = &cobra.Command{ func init() { secretsCmd.AddCommand(secretsGetCmd) + secretsGetCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + } + secretsCmd.AddCommand(secretsSetCmd) + secretsSetCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + } + 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) { + 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) } diff --git a/cli/packages/util/errors.go b/cli/packages/util/log.go similarity index 88% rename from cli/packages/util/errors.go rename to cli/packages/util/log.go index 1761d6f7b1..a701987fc2 100644 --- a/cli/packages/util/errors.go +++ b/cli/packages/util/log.go @@ -23,6 +23,10 @@ func PrintErrorAndExit(exitCode int, err error, messages ...string) { os.Exit(exitCode) } +func PrintWarning(message string) { + color.Yellow("Warning: %v", message) +} + func PrintMessageAndExit(messages ...string) { if len(messages) > 0 { for _, message := range messages { diff --git a/cli/packages/util/vault.go b/cli/packages/util/vault.go index b42308cc4f..7e4ce1b31c 100644 --- a/cli/packages/util/vault.go +++ b/cli/packages/util/vault.go @@ -33,6 +33,7 @@ func GetKeyRing() (keyring.Keyring, error) { LibSecretCollectionName: KEYRING_SERVICE_NAME, KWalletAppID: KEYRING_SERVICE_NAME, KWalletFolder: KEYRING_SERVICE_NAME, + KeychainName: "login", // default so user will not be prompted KeychainTrustApplication: true, WinCredPrefix: 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) { - 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 { 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) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6cf75ad47d..014d858c46 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -28,6 +28,8 @@ services: - ./backend/src:/app/src - ./backend/nodemon.json:/app/nodemon.json - /app/node_modules + - ./backend/api-documentation.json:/app/api-documentation.json + - ./backend/swagger.ts:/app/swagger.ts command: npm run dev env_file: .env environment: @@ -48,6 +50,7 @@ services: - ./frontend/public:/app/public - ./frontend/styles:/app/styles - ./frontend/components:/app/components + - ./frontend/ee:/app/ee - ./frontend/locales:/app/locales - ./frontend/next-i18next.config.js:/app/next-i18next.config.js env_file: .env diff --git a/docs/api-reference/endpoints/secrets/create.mdx b/docs/api-reference/endpoints/secrets/create.mdx new file mode 100644 index 0000000000..27c167f76c --- /dev/null +++ b/docs/api-reference/endpoints/secrets/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/secrets/" +--- diff --git a/docs/api-reference/endpoints/secrets/delete.mdx b/docs/api-reference/endpoints/secrets/delete.mdx new file mode 100644 index 0000000000..af87e7120a --- /dev/null +++ b/docs/api-reference/endpoints/secrets/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secrets/" +--- diff --git a/docs/api-reference/endpoints/secrets/read.mdx b/docs/api-reference/endpoints/secrets/read.mdx new file mode 100644 index 0000000000..4305f192c5 --- /dev/null +++ b/docs/api-reference/endpoints/secrets/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Read" +openapi: "GET /api/v2/secrets/" +--- diff --git a/docs/api-reference/endpoints/secrets/update.mdx b/docs/api-reference/endpoints/secrets/update.mdx new file mode 100644 index 0000000000..2193fccc35 --- /dev/null +++ b/docs/api-reference/endpoints/secrets/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secrets/" +--- diff --git a/docs/api-reference/overview/authentication.mdx b/docs/api-reference/overview/authentication.mdx new file mode 100644 index 0000000000..27a2dc1345 --- /dev/null +++ b/docs/api-reference/overview/authentication.mdx @@ -0,0 +1,3 @@ +--- +title: "Authentication" +--- diff --git a/docs/api-reference/overview/introduction.mdx b/docs/api-reference/overview/introduction.mdx new file mode 100644 index 0000000000..9632e3788d --- /dev/null +++ b/docs/api-reference/overview/introduction.mdx @@ -0,0 +1,3 @@ +--- +title: "Introduction" +--- diff --git a/docs/getting-started/dashboard/audit-logs.mdx b/docs/getting-started/dashboard/audit-logs.mdx index bb31423b40..808a7112d1 100644 --- a/docs/getting-started/dashboard/audit-logs.mdx +++ b/docs/getting-started/dashboard/audit-logs.mdx @@ -2,8 +2,10 @@ 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? - Did Bob read environment variables last week (if at all)? - What IP address was used for that action? + +![Activity logs](../../images/activity-logs.png) diff --git a/docs/getting-started/dashboard/pit-recovery.mdx b/docs/getting-started/dashboard/pit-recovery.mdx index 534cc2718b..8881f117f7 100644 --- a/docs/getting-started/dashboard/pit-recovery.mdx +++ b/docs/getting-started/dashboard/pit-recovery.mdx @@ -2,4 +2,22 @@ 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) + + + Rolling back environment variables to a past snapshot creates a new commit and + snapshot at the top of the stack and updates secret versions. + diff --git a/docs/getting-started/dashboard/secret-versioning.mdx b/docs/getting-started/dashboard/secret-versioning.mdx new file mode 100644 index 0000000000..ccf94fef1b --- /dev/null +++ b/docs/getting-started/dashboard/secret-versioning.mdx @@ -0,0 +1,14 @@ +--- +title: "Secret Versioning" +--- + +Secret versioning records changes made to every secret. + +![secret versioning](../../images/secret-versioning.png) + + + 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. + diff --git a/docs/getting-started/dashboard/versioning.mdx b/docs/getting-started/dashboard/versioning.mdx deleted file mode 100644 index 3a6ba2e2c1..0000000000 --- a/docs/getting-started/dashboard/versioning.mdx +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: "Secret Versioning" ---- - -Secret versioning allows an individual environment variable to be rolled back without touching other project environment variables. diff --git a/docs/images/activity-logs.png b/docs/images/activity-logs.png new file mode 100644 index 0000000000..29349c82bc Binary files /dev/null and b/docs/images/activity-logs.png differ diff --git a/docs/images/email-aws-ses-console.png b/docs/images/email-aws-ses-console.png new file mode 100644 index 0000000000..2882ba4d5f Binary files /dev/null and b/docs/images/email-aws-ses-console.png differ diff --git a/docs/images/email-aws-ses-user.png b/docs/images/email-aws-ses-user.png new file mode 100644 index 0000000000..f740e58a5e Binary files /dev/null and b/docs/images/email-aws-ses-user.png differ diff --git a/docs/images/pit-commits.png b/docs/images/pit-commits.png new file mode 100644 index 0000000000..19cfa4976e Binary files /dev/null and b/docs/images/pit-commits.png differ diff --git a/docs/images/pit-snapshot.png b/docs/images/pit-snapshot.png new file mode 100644 index 0000000000..7e790e875a Binary files /dev/null and b/docs/images/pit-snapshot.png differ diff --git a/docs/images/pit-snapshots.png b/docs/images/pit-snapshots.png new file mode 100644 index 0000000000..f222316488 Binary files /dev/null and b/docs/images/pit-snapshots.png differ diff --git a/docs/images/secret-versioning.png b/docs/images/secret-versioning.png new file mode 100644 index 0000000000..ec17342899 Binary files /dev/null and b/docs/images/secret-versioning.png differ diff --git a/docs/mint.json b/docs/mint.json index 05ae427475..4b83b5c171 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -81,7 +81,7 @@ "getting-started/dashboard/project", "getting-started/dashboard/integrations", "getting-started/dashboard/pit-recovery", - "getting-started/dashboard/versioning", + "getting-started/dashboard/secret-versioning", "getting-started/dashboard/audit-logs", "getting-started/dashboard/token" ] diff --git a/docs/self-hosting/configuration/email.mdx b/docs/self-hosting/configuration/email.mdx index b1e911fb67..666ca76224 100644 --- a/docs/self-hosting/configuration/email.mdx +++ b/docs/self-hosting/configuration/email.mdx @@ -48,7 +48,7 @@ SMTP_FROM_NAME=Infisical ``` - 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. ## Mailgun @@ -70,6 +70,28 @@ SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out em 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 +``` + - Remember that you will need to restart Infisical for this to work properly. - \ No newline at end of file + Remember that you will need to restart Infisical for this to work properly. + diff --git a/frontend/components/analytics/posthog.ts b/frontend/components/analytics/posthog.ts index e0dedc7fbf..8482eeb06e 100644 --- a/frontend/components/analytics/posthog.ts +++ b/frontend/components/analytics/posthog.ts @@ -9,7 +9,6 @@ export const initPostHog = () => { if (typeof window !== 'undefined') { // @ts-ignore 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, { api_host: POSTHOG_HOST }); diff --git a/frontend/components/basic/Error.tsx b/frontend/components/basic/Error.tsx index 79cc6e5f17..b5f0b7a4c7 100644 --- a/frontend/components/basic/Error.tsx +++ b/frontend/components/basic/Error.tsx @@ -4,13 +4,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; export default function Error({ text }: { text: string }): JSX.Element { return ( -
+
{text && ( -

{text}

+

{text}

)}
); diff --git a/frontend/components/basic/EventFilter.tsx b/frontend/components/basic/EventFilter.tsx index c9b31fd436..dc5ffe109b 100644 --- a/frontend/components/basic/EventFilter.tsx +++ b/frontend/components/basic/EventFilter.tsx @@ -55,7 +55,7 @@ export default function EventFilter({ {selected != '' ? (

{t("activity:event." + selected)}

) : ( -

Select an event

+

{String(t("common:select-event"))}

)} {selected != '' ? ( { - if (user.status == "accepted") { + if (user.status == "accepted" && user.email != myUser.email) { const result = await addUserToWorkspace( user.user.email, newWorkspaceId @@ -184,6 +203,7 @@ export default function Layout({ children }: LayoutProps) { if ( userWorkspaces.length == 0 && router.asPath != "/noprojects" && + !router.asPath.includes("home")&& !router.asPath.includes("settings") ) { router.push("/noprojects"); @@ -191,9 +211,16 @@ export default function Layout({ children }: LayoutProps) { const intendedWorkspaceId = router.asPath .split("/") [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 ( - intendedWorkspaceId != "heroku" && + !["heroku", "vercel", "github", "netlify"].includes(intendedWorkspaceId) && !userWorkspaces .map((workspace: { _id: string }) => workspace._id) .includes(intendedWorkspaceId) @@ -239,14 +266,14 @@ export default function Layout({ children }: LayoutProps) { .split("/") [router.asPath.split("/").length - 1].split("?")[0] ) { - router.push( - "/dashboard/" + - workspaceMapping[workspaceSelected as any] - ); localStorage.setItem( "projectData.id", `${workspaceMapping[workspaceSelected as any]}` ); + router.push( + "/dashboard/" + + workspaceMapping[workspaceSelected as any] + ); } } catch (error) { console.log(error); diff --git a/frontend/components/basic/Listbox.tsx b/frontend/components/basic/Listbox.tsx index 95e3f33c64..a65135dfad 100644 --- a/frontend/components/basic/Listbox.tsx +++ b/frontend/components/basic/Listbox.tsx @@ -46,7 +46,7 @@ export default function ListBox({ >
{text} - + {' '} {selected} @@ -69,7 +69,7 @@ export default function ListBox({ - `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' : '' } ${ active && !selected diff --git a/frontend/components/basic/Toggle.tsx b/frontend/components/basic/Toggle.tsx index d15aed622a..1a5ddaa826 100644 --- a/frontend/components/basic/Toggle.tsx +++ b/frontend/components/basic/Toggle.tsx @@ -1,27 +1,11 @@ import React from "react"; import { Switch } from "@headlessui/react"; - -interface OverrideProps { - id: string; - keyName: string; - value: string; - pos: number; - comment: string; -} - interface ToggleProps { enabled: boolean; setEnabled: (value: boolean) => void; - addOverride: (value: OverrideProps) => void; - keyName: string; - value: string; + addOverride: (value: string | undefined, pos: number) => void; 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 {function} obj.setEnabled - change the state of the toggle * @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 - #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 */ export default function Toggle ({ enabled, setEnabled, addOverride, - keyName, - value, - pos, - id, - comment, - deleteOverride, - sharedToHide, - setSharedToHide + pos }: ToggleProps): JSX.Element { return ( { if (enabled == false) { - addOverride({ id, keyName, value, pos, comment }); - setSharedToHide([ - ...sharedToHide!, - id - ]) + addOverride('', pos); } else { - deleteOverride(id); + addOverride(undefined, pos); } setEnabled(!enabled); }} diff --git a/frontend/components/basic/buttons/Button.tsx b/frontend/components/basic/buttons/Button.tsx index 939d9b17de..2405abce77 100644 --- a/frontend/components/basic/buttons/Button.tsx +++ b/frontend/components/basic/buttons/Button.tsx @@ -3,7 +3,6 @@ import Image from "next/image"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { FontAwesomeIcon, - FontAwesomeIconProps, } from "@fortawesome/react-fontawesome"; const classNames = require("classnames"); @@ -101,7 +100,7 @@ export default function Button(props: ButtonProps): JSX.Element {
)}
diff --git a/frontend/components/basic/dialog/AddServiceTokenDialog.js b/frontend/components/basic/dialog/AddServiceTokenDialog.js index f9ef6d27cd..0be3307082 100644 --- a/frontend/components/basic/dialog/AddServiceTokenDialog.js +++ b/frontend/components/basic/dialog/AddServiceTokenDialog.js @@ -8,7 +8,6 @@ import nacl from "tweetnacl"; import addServiceToken from "~/pages/api/serviceToken/addServiceToken"; import getLatestFileKey from "~/pages/api/workspace/getLatestFileKey"; -import { envMapping } from "../../../public/data/frequentConstants"; import { decryptAssymmetric, encryptAssymmetric, @@ -34,11 +33,12 @@ const AddServiceTokenDialog = ({ workspaceId, workspaceName, serviceTokens, + environments, setServiceTokens }) => { const [serviceToken, setServiceToken] = useState(""); const [serviceTokenName, setServiceTokenName] = useState(""); - const [serviceTokenEnv, setServiceTokenEnv] = useState("Development"); + const [selectedServiceTokenEnv, setSelectedServiceTokenEnv] = useState(environments?.[0]); const [serviceTokenExpiresIn, setServiceTokenExpiresIn] = useState("1 day"); const [serviceTokenCopied, setServiceTokenCopied] = useState(false); const { t } = useTranslation(); @@ -66,15 +66,13 @@ const AddServiceTokenDialog = ({ let newServiceToken = await addServiceToken({ name: serviceTokenName, workspaceId, - environment: envMapping[serviceTokenEnv], + environment: selectedServiceTokenEnv?.slug ? selectedServiceTokenEnv.slug : environments[0]?.name, expiresIn: expiryMapping[serviceTokenExpiresIn], encryptedKey: ciphertext, iv, tag }); - console.log('newServiceToken', newServiceToken); - setServiceTokens(serviceTokens.concat([newServiceToken.serviceTokenData])); setServiceToken(newServiceToken.serviceToken + "." + randomBytes); }; @@ -103,155 +101,159 @@ const AddServiceTokenDialog = ({ }; return ( -
+
- + -
+
-
-
+
+
- {serviceToken == "" ? ( - + {serviceToken == '' ? ( + - {t("section-token:add-dialog.title", { + {t('section-token:add-dialog.title', { target: workspaceName, })} -
-
-

- {t("section-token:add-dialog.description")} +

+
+

+ {t('section-token:add-dialog.description')}

-
+
-
+
name)} + onChange={(envName) => + setSelectedServiceTokenEnv( + environments.find( + ({ name }) => envName === name + ) || { + name: 'unknown', + slug: 'unknown', + } + ) + } isFull={true} - text={`${t("common:environment")}: `} + text={`${t('common:environment')}: `} />
-
+
-
-
+
+
) : ( - + - {t("section-token:add-dialog.copy-service-token")} + {t('section-token:add-dialog.copy-service-token')} -
-
-

+

+
+

{t( - "section-token:add-dialog.copy-service-token-description" + 'section-token:add-dialog.copy-service-token-description' )}

-
-
+
+
-
+
{serviceToken}
-
+
- - {t("common:click-to-copy")} + + {t('common:click-to-copy')}
-
+
diff --git a/frontend/components/basic/dialog/AddUpdateEnvironmentDialog.tsx b/frontend/components/basic/dialog/AddUpdateEnvironmentDialog.tsx new file mode 100644 index 0000000000..9476dd8f65 --- /dev/null +++ b/frontend/components/basic/dialog/AddUpdateEnvironmentDialog.tsx @@ -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({ + 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 ( +
+ + + +
+ + +
+
+ + + + {isEditMode + ? 'Update environment' + : 'Create a new environment'} + +
+
+ onInputChange('name', val)} + type='varName' + value={formInput.name} + placeholder='' + isRequired + // error={error.length > 0} + // errorText={error} + /> +
+
+ onInputChange('slug', val)} + type='varName' + value={formInput.slug} + placeholder='' + isRequired + // error={error.length > 0} + // errorText={error} + /> +
+

+ Slugs are shorthands used in cli to access environment +

+
+
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/frontend/components/basic/dialog/DeleteActionModal.tsx b/frontend/components/basic/dialog/DeleteActionModal.tsx new file mode 100644 index 0000000000..9a1b7a46af --- /dev/null +++ b/frontend/components/basic/dialog/DeleteActionModal.tsx @@ -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 ( +
+ + + +
+ +
+
+ + + + {title} + +
+

+ This action is irrevertible. +

+
+
+ setDeleteInputField(val)} + value={deleteInputField} + type='text' + /> +
+
+ + +
+
+
+
+
+
+
+
+ ); +}; + +export default DeleteActionModal; diff --git a/frontend/components/basic/dialog/DeleteEnvVar.tsx b/frontend/components/basic/dialog/DeleteEnvVar.tsx new file mode 100644 index 0000000000..4bb5840397 --- /dev/null +++ b/frontend/components/basic/dialog/DeleteEnvVar.tsx @@ -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 ( +
+ + {}}> +
+ +
+ +
+ + + + {t('dashboard:sidebar.delete-key-dialog.title')} + +
+

+ {t('dashboard:sidebar.delete-key-dialog.confirm-delete-message')} +

+
+
+ + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/frontend/components/basic/popups/BottomRightPopup.tsx b/frontend/components/basic/popups/BottomRightPopup.tsx index e6ea60f92c..71b4f4a660 100644 --- a/frontend/components/basic/popups/BottomRightPopup.tsx +++ b/frontend/components/basic/popups/BottomRightPopup.tsx @@ -36,7 +36,7 @@ export default function BottonRightPopup({ }: PopupProps): JSX.Element { return (
diff --git a/frontend/components/basic/table/EnvironmentsTable.tsx b/frontend/components/basic/table/EnvironmentsTable.tsx new file mode 100644 index 0000000000..56536b679e --- /dev/null +++ b/frontend/components/basic/table/EnvironmentsTable.tsx @@ -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; + onUpdateEnv: (oldSlug: string, arg0: Env) => Promise; + onDeleteEnv: (slug: string) => Promise; +}; + +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)?.slug, + env + ); + handlePopUpClose('createUpdateEnv'); + } catch (error) { + console.error(error); + } + }; + + const onEnvDeleteCB = async () => { + try { + await onDeleteEnv( + (popUp.deleteEnv?.data as Pick)?.slug + ); + handlePopUpClose('deleteEnv'); + } catch (error) { + console.error(error); + } + }; + + return ( + <> +
+
+

Project Environments

+

+ Choose which environments will show up in your dashboard like + development, staging, production +

+

+ Note: the text in slugs shows how these environmant should be + accessed in CLI. +

+
+
+
+
+
+
+ + + + + + + + + + {data?.length > 0 ? ( + data.map(({ name, slug }) => { + return ( + + + + + + ); + }) + ) : ( + + + + )} + +
NameSlug
+ {name} + + {slug} + +
+
+
+
+
+ No environmants found +
+ handlePopUpClose('deleteEnv')} + onSubmit={onEnvDeleteCB} + /> + handlePopUpClose('createUpdateEnv')} + onCreateSubmit={onEnvCreateCB} + onEditSubmit={onEnvUpdateCB} + /> +
+ + ); +}; + +export default EnvironmentTable; diff --git a/frontend/components/basic/table/ServiceTokenTable.tsx b/frontend/components/basic/table/ServiceTokenTable.tsx index 412a0dbfbc..4d30b30139 100644 --- a/frontend/components/basic/table/ServiceTokenTable.tsx +++ b/frontend/components/basic/table/ServiceTokenTable.tsx @@ -3,7 +3,6 @@ import { faX } from '@fortawesome/free-solid-svg-icons'; import { useNotificationContext } from '~/components/context/Notifications/NotificationProvider'; import deleteServiceToken from "../../../pages/api/serviceToken/deleteServiceToken"; -import { reverseEnvMapping } from '../../../public/data/frequentConstants'; import guidGenerator from '../../utilities/randomId'; import Button from '../buttons/Button'; @@ -60,7 +59,7 @@ const ServiceTokenTable = ({ data, workspaceName, setServiceTokens }: ServiceTok {workspaceName} - {reverseEnvMapping[row.environment]} + {row.environment} {new Date(row.expiresAt).toUTCString()} diff --git a/frontend/components/basic/table/UserTable.js b/frontend/components/basic/table/UserTable.js index 449d23cc64..677b0d978d 100644 --- a/frontend/components/basic/table/UserTable.js +++ b/frontend/components/basic/table/UserTable.js @@ -117,13 +117,13 @@ const UserTable = ({ return (
-
- - +
+
+ - - - + + + diff --git a/frontend/components/dashboard/CommentField.tsx b/frontend/components/dashboard/CommentField.tsx index ea29aa73ce..62ff0c2f12 100644 --- a/frontend/components/dashboard/CommentField.tsx +++ b/frontend/components/dashboard/CommentField.tsx @@ -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 { t } = useTranslation(); - return
-

{t("dashboard:sidebar.comments")}

+ return
+

{t("dashboard:sidebar.comments")}

First NameLast NameEmailFIRST NAMELAST NAMEEMAIL