diff --git a/.dockerignore b/.dockerignore index 6a47c2536..d60c2cb95 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,10 @@ backend/node_modules -frontend/node_modules \ No newline at end of file +frontend/node_modules +backend/frontend-build +**/node_modules +**/.next +.dockerignore +.git +README.md +.dockerignore +**/Dockerfile diff --git a/.env.example b/.env.example index 1f3f64591..6b8639a74 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,12 @@ # Keys # Required key for platform encryption/decryption ops -# THIS IS A SAMPLE ENCRYPTION KEY AND SHOULD NOT BE USED FOR PRODUCTION +# THIS IS A SAMPLE ENCRYPTION KEY AND SHOULD NEVER BE USED FOR PRODUCTION ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218 # JWT # Required secrets to sign JWT tokens -JWT_SIGNUP_SECRET=3679e04ca949f914c03332aaaeba805a -JWT_REFRESH_SECRET=5f2f3c8f0159068dc2bbb3a652a716ff -JWT_AUTH_SECRET=4be6ba5602e0fa0ac6ac05c3cd4d247f -JWT_SERVICE_SECRET=f32f716d70a42c5703f4656015e76200 -JWT_PROVIDER_AUTH_SECRET=f32f716d70a42c5703f4656015e76201 - -# JWT lifetime -# Optional lifetimes for JWT tokens expressed in seconds or a string -# describing a time span (e.g. 60, "2 days", "10h", "7d") -JWT_AUTH_LIFETIME= -JWT_REFRESH_LIFETIME= -JWT_SIGNUP_LIFETIME= -JWT_PROVIDER_AUTH_LIFETIME= +# THIS IS A SAMPLE AUTH_SECRET KEY AND SHOULD NEVER BE USED FOR PRODUCTION +AUTH_SECRET=5lrMXKKWCVocS/uerPsl7V+TX/aaUaI7iDkgl3tSmLE= # MongoDB # Backend will connect to the MongoDB instance at connection string MONGO_URL which can either be a ref @@ -67,5 +56,12 @@ SENTRY_DSN= POSTHOG_HOST= POSTHOG_PROJECT_API_KEY= -CLIENT_ID_GOOGLE= -CLIENT_SECRET_GOOGLE= +# SSO-specific variables +CLIENT_ID_GOOGLE_LOGIN= +CLIENT_SECRET_GOOGLE_LOGIN= + +CLIENT_ID_GITHUB_LOGIN= +CLIENT_SECRET_GITHUB_LOGIN= + +CLIENT_ID_GITLAB_LOGIN= +CLIENT_SECRET_GITLAB_LOGIN= diff --git a/.github/resources/docker-compose.be-test.yml b/.github/resources/docker-compose.be-test.yml index c256649c1..373c88dc0 100644 --- a/.github/resources/docker-compose.be-test.yml +++ b/.github/resources/docker-compose.be-test.yml @@ -6,7 +6,7 @@ services: restart: unless-stopped depends_on: - mongo - image: infisical/backend:test + image: infisical/infisical:test command: npm run start environment: - NODE_ENV=production diff --git a/.github/values.yaml b/.github/values.yaml index 54a56e076..6c9367736 100644 --- a/.github/values.yaml +++ b/.github/values.yaml @@ -1,29 +1,3 @@ -# secretScanningGitApp: -# enabled: false -# deploymentAnnotations: -# secrets.infisical.com/auto-reload: "true" -# image: -# repository: infisical/staging_deployment_secret-scanning-git-app - -frontend: - enabled: true - name: frontend - podAnnotations: {} - deploymentAnnotations: - secrets.infisical.com/auto-reload: "true" - replicaCount: 2 - image: - repository: infisical/staging_deployment_frontend - tag: "latest" - pullPolicy: Always - kubeSecretRef: managed-secret-frontend - service: - annotations: {} - type: ClusterIP - nodePort: "" - -frontendEnvironmentVariables: null - backend: enabled: true name: backend @@ -32,7 +6,7 @@ backend: secrets.infisical.com/auto-reload: "true" replicaCount: 2 image: - repository: infisical/staging_deployment_backend + repository: infisical/staging_infisical tag: "latest" pullPolicy: Always kubeSecretRef: managed-backend-secret @@ -40,12 +14,15 @@ backend: annotations: {} type: ClusterIP nodePort: "" + resources: + limits: + memory: 300Mi backendEnvironmentVariables: null ## Mongo DB persistence mongodb: - enabled: true + enabled: false persistence: enabled: false @@ -60,14 +37,8 @@ ingress: enabled: true # annotations: # kubernetes.io/ingress.class: "nginx" - # cert-manager.io/issuer: letsencrypt-nginx + # cert-manager.io/issuer: letsencrypt-nginx hostName: gamma.infisical.com ## <- Replace with your own domain - frontend: - path: / - pathType: Prefix - backend: - path: /api - pathType: Prefix tls: [] # - secretName: letsencrypt-nginx diff --git a/.github/workflows/build-docker-image-to-prod.yml b/.github/workflows/build-docker-image-to-prod.yml index 322a553c4..04e40f1e9 100644 --- a/.github/workflows/build-docker-image-to-prod.yml +++ b/.github/workflows/build-docker-image-to-prod.yml @@ -17,9 +17,9 @@ jobs: - name: ๐Ÿ“ฆ Install dependencies to test all dependencies run: npm ci --only-production working-directory: backend - - name: ๐Ÿงช Run tests - run: npm run test:ci - working-directory: backend + # - name: ๐Ÿงช Run tests + # run: npm run test:ci + # working-directory: backend - name: Save commit hashes for tag id: commit uses: pr-mpt/actions-commit-hash@v2 @@ -39,7 +39,7 @@ jobs: token: ${{ secrets.DEPOT_PROJECT_TOKEN }} load: true context: backend - tags: infisical/backend:test + tags: infisical/infisical:test - name: โป Spawn backend container and dependencies run: | docker compose -f .github/resources/docker-compose.be-test.yml up --wait --quiet-pull @@ -93,6 +93,7 @@ jobs: tags: infisical/frontend:test build-args: | POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} + NEXT_INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} - name: โป Spawn frontend container run: | docker run -d --rm --name infisical-frontend-test infisical/frontend:test @@ -116,3 +117,4 @@ jobs: platforms: linux/amd64,linux/arm64 build-args: | POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} + NEXT_INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} diff --git a/.github/workflows/build-staging-img.yml b/.github/workflows/build-staging-img.yml index 806e89b87..6e094871e 100644 --- a/.github/workflows/build-staging-img.yml +++ b/.github/workflows/build-staging-img.yml @@ -2,7 +2,7 @@ name: Build, Publish and Deploy to Gamma on: [workflow_dispatch] jobs: - backend-image: + infisical-image: name: Build backend image runs-on: ubuntu-latest steps: @@ -32,8 +32,9 @@ jobs: project: 64mmf0n610 token: ${{ secrets.DEPOT_PROJECT_TOKEN }} load: true - context: backend - tags: infisical/backend:test + context: . + file: Dockerfile.standalone-infisical + tags: infisical/infisical:test - name: โป Spawn backend container and dependencies run: | docker compose -f .github/resources/docker-compose.be-test.yml up --wait --quiet-pull @@ -49,66 +50,20 @@ jobs: project: 64mmf0n610 token: ${{ secrets.DEPOT_PROJECT_TOKEN }} push: true - context: backend + context: . + file: Dockerfile.standalone-infisical tags: | - infisical/staging_deployment_backend:${{ steps.commit.outputs.short }} - infisical/staging_deployment_backend:latest + infisical/staging_infisical:${{ steps.commit.outputs.short }} + infisical/staging_infisical:latest platforms: linux/amd64,linux/arm64 + build-args: | + POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} + INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} - frontend-image: - name: Build frontend image - runs-on: ubuntu-latest - steps: - - name: โ˜๏ธ Checkout source - uses: actions/checkout@v3 - - 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 - uses: docker/login-action@v2 - 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: depot/build-push-action@v1 - with: - load: true - token: ${{ secrets.DEPOT_PROJECT_TOKEN }} - project: 64mmf0n610 - context: frontend - tags: infisical/staging_deployment_frontend:test - build-args: | - POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} - - name: โป Spawn frontend container - run: | - docker run -d --rm --name infisical-frontend-test infisical/staging_deployment_frontend:test - - name: ๐Ÿงช Test frontend image - run: | - ./.github/resources/healthcheck.sh infisical-frontend-test - - name: โป Shut down frontend container - run: | - docker stop infisical-frontend-test - - name: ๐Ÿ—๏ธ Build frontend and push - uses: depot/build-push-action@v1 - with: - project: 64mmf0n610 - push: true - token: ${{ secrets.DEPOT_PROJECT_TOKEN }} - context: frontend - tags: | - infisical/staging_deployment_frontend:${{ steps.commit.outputs.short }} - infisical/staging_deployment_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] + needs: [infisical-image] steps: - name: โ˜๏ธ Checkout source uses: actions/checkout@v3 diff --git a/.github/workflows/release-standalone-docker-img.yml b/.github/workflows/release-standalone-docker-img.yml index 5d91bd59c..fd1d59c59 100644 --- a/.github/workflows/release-standalone-docker-img.yml +++ b/.github/workflows/release-standalone-docker-img.yml @@ -73,3 +73,6 @@ jobs: infisical/infisical:${{ steps.extract_version.outputs.version }} platforms: linux/amd64,linux/arm64 file: Dockerfile.standalone-infisical + build-args: | + POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} + INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} diff --git a/.gitignore b/.gitignore index da6eb5142..24dc274ba 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,7 @@ reports junit.xml # next.js -/.next/ +.next/ /out/ # production @@ -59,4 +59,6 @@ yarn-error.log* .infisical.json # Editor specific -.vscode/* \ No newline at end of file +.vscode/* + +frontend-build \ No newline at end of file diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index 8770bb234..56d6735b0 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -1,7 +1,13 @@ ARG POSTHOG_HOST=https://app.posthog.com ARG POSTHOG_API_KEY=posthog-api-key +ARG INTERCOM_ID=intercom-id -FROM node:16-alpine AS frontend-dependencies +FROM node:16-alpine AS base + +FROM base AS frontend-dependencies + +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat WORKDIR /app @@ -11,7 +17,7 @@ COPY frontend/package.json frontend/package-lock.json frontend/next.config.js ./ RUN npm ci --only-production --ignore-scripts # Rebuild the source code only when needed -FROM node:16-alpine AS frontend-builder +FROM base AS frontend-builder WORKDIR /app # Copy dependencies @@ -27,41 +33,38 @@ ARG POSTHOG_API_KEY ENV NEXT_PUBLIC_POSTHOG_API_KEY $POSTHOG_API_KEY ARG INTERCOM_ID ENV NEXT_PUBLIC_INTERCOM_ID $INTERCOM_ID +ARG INFISICAL_PLATFORM_VERSION +ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION # Build RUN npm run build # Production image -FROM node:16-alpine AS frontend-runner +FROM base AS frontend-runner WORKDIR /app RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs +RUN adduser --system --uid 1001 non-root-user -RUN mkdir -p /app/.next/cache/images && chown nextjs:nodejs /app/.next/cache/images +RUN mkdir -p /app/.next/cache/images && chown non-root-user:nodejs /app/.next/cache/images VOLUME /app/.next/cache/images -ARG POSTHOG_API_KEY -ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \ - BAKED_NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY -ARG INTERCOM_ID -ENV NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID \ - BAKED_NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID - -COPY --chown=nextjs:nodejs --chmod=555 frontend/scripts ./scripts +COPY --chown=non-root-user:nodejs --chmod=555 frontend/scripts ./scripts COPY --from=frontend-builder /app/public ./public -RUN chown nextjs:nodejs ./public/data -COPY --from=frontend-builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=frontend-builder --chown=nextjs:nodejs /app/.next/static ./.next/static +RUN chown non-root-user:nodejs ./public/data +COPY --from=frontend-builder --chown=non-root-user:nodejs /app/.next/standalone ./ +COPY --from=frontend-builder --chown=non-root-user:nodejs /app/.next/static ./.next/static -USER nextjs +USER non-root-user ENV NEXT_TELEMETRY_DISABLED 1 ## ## BACKEND ## -FROM node:16-alpine AS backend-build +FROM base AS backend-build +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 non-root-user WORKDIR /app @@ -69,10 +72,11 @@ COPY backend/package*.json ./ RUN npm ci --only-production COPY /backend . +COPY --chown=non-root-user:nodejs standalone-entrypoint.sh standalone-entrypoint.sh RUN npm run build # Production stage -FROM node:16-alpine AS backend-runner +FROM base AS backend-runner WORKDIR /app @@ -81,27 +85,44 @@ RUN npm ci --only-production COPY --from=backend-build /app . +RUN mkdir frontend-build + # Production stage -FROM node:16-alpine AS production +FROM base AS production +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 non-root-user + +## set pre baked keys +ARG POSTHOG_API_KEY +ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \ + BAKED_NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY +ARG INTERCOM_ID=intercom-id +ENV NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID \ + BAKED_NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID WORKDIR / -# Install PM2 -RUN npm install -g pm2 -# Copy ecosystem.config.js -COPY ecosystem.config.js . - -RUN apk add --no-cache nginx - -COPY nginx/default-stand-alone-docker.conf /etc/nginx/nginx.conf - COPY --from=backend-runner /app /backend -COPY --from=frontend-runner /app/ /app/ +COPY --from=frontend-runner /app ./backend/frontend-build -EXPOSE 80 +ENV PORT 8080 ENV HTTPS_ENABLED false +ENV NODE_ENV production +ENV STANDALONE_BUILD true + +WORKDIR /backend + +ENV TELEMETRY_ENABLED true + +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \ + CMD node healthcheck.js + +EXPOSE 8080 + +USER non-root-user + +CMD ["./standalone-entrypoint.sh"] -CMD ["pm2-runtime", "start", "ecosystem.config.js"] diff --git a/README.md b/README.md index 499f534a1..a48b4934e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@

- infisical infisical

-

Open-source, end-to-end encrypted secret management platform: distribute secrets/configs across your team/infrastructure and prevent secret leaks.

+

The open-source secret management platform: Sync secrets/configs across your team/infrastructure and prevent secret leaks.

@@ -34,7 +33,7 @@ git commit activity - Cloudsmith downloads + Cloudsmith downloads Slack community channel @@ -44,11 +43,11 @@

-Dashboard +Dashboard ## Introduction -**[Infisical](https://infisical.com)** is an open source, end-to-end encrypted secret management platform that teams use to centralize their secrets like API keys, database credentials, and configurations. +**[Infisical](https://infisical.com)** is the open source secret management platform that teams use to centralize their secrets like API keys, database credentials, and configurations. We're on a mission to make secret management more accessible to everyone, not just security teams, and that means redesigning the entire developer experience from ground up. @@ -134,7 +133,6 @@ Whether it's big or small, we love contributions. Check out our guide to see how Not sure where to get started? You can: -- [Book a free, non-pressure pairing session / code walkthrough with one of our teammates](https://cal.com/tony-infisical/30-min-meeting-contributing)! - Join our Slack, and ask us any questions there. - Join our [community calls](https://us06web.zoom.us/j/82623506356) every Wednesday at 11am EST to ask any questions, provide feedback, hangout and more. diff --git a/backend/.eslintrc b/backend/.eslintrc index 6aa49ee31..31bcc259b 100644 --- a/backend/.eslintrc +++ b/backend/.eslintrc @@ -26,6 +26,7 @@ ], "@typescript-eslint/no-unused-vars": "off", "unused-imports/no-unused-imports": "error", + "@typescript-eslint/no-extra-semi": "off", // added to be able to push "unused-imports/no-unused-vars": [ "warn", { diff --git a/backend/package-lock.json b/backend/package-lock.json index 66aeceee2..3429181a2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -50,6 +50,7 @@ "nodemailer": "^6.8.0", "passport": "^0.6.0", "passport-github": "^1.1.0", + "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", "posthog-node": "^2.6.0", "probot": "^12.3.1", @@ -63,7 +64,7 @@ "utility-types": "^3.10.0", "winston": "^3.8.2", "winston-loki": "^6.0.6", - "zod": "^3.21.4" + "zod": "^3.22.3" }, "devDependencies": { "@jest/globals": "^29.3.1", @@ -13727,6 +13728,17 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-gitlab2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/passport-gitlab2/-/passport-gitlab2-5.0.0.tgz", + "integrity": "sha512-cXQMgM6JQx9wHVh7JLH30D8fplfwjsDwRz+zS0pqC8JS+4bNmc1J04NGp5g2M4yfwylH9kQRrMN98GxMw7q7cg==", + "dependencies": { + "passport-oauth2": "^1.4.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/passport-google-oauth20": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", @@ -16684,9 +16696,9 @@ } }, "node_modules/zod": { - "version": "3.21.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", - "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -27163,6 +27175,14 @@ "passport-oauth2": "1.x.x" } }, + "passport-gitlab2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/passport-gitlab2/-/passport-gitlab2-5.0.0.tgz", + "integrity": "sha512-cXQMgM6JQx9wHVh7JLH30D8fplfwjsDwRz+zS0pqC8JS+4bNmc1J04NGp5g2M4yfwylH9kQRrMN98GxMw7q7cg==", + "requires": { + "passport-oauth2": "^1.4.0" + } + }, "passport-google-oauth20": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", @@ -29384,9 +29404,9 @@ "dev": true }, "zod": { - "version": "3.21.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", - "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==" + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==" } } } diff --git a/backend/package.json b/backend/package.json index e8ecb384f..cd38aa20f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,6 +41,7 @@ "nodemailer": "^6.8.0", "passport": "^0.6.0", "passport-github": "^1.1.0", + "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", "posthog-node": "^2.6.0", "probot": "^12.3.1", @@ -54,7 +55,7 @@ "utility-types": "^3.10.0", "winston": "^3.8.2", "winston-loki": "^6.0.6", - "zod": "^3.21.4" + "zod": "^3.22.3" }, "name": "infisical-api", "version": "1.0.0", diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index b5e50d9da..402fd5874 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,3 +1,5 @@ +import { GITLAB_URL } from "../variables"; + import InfisicalClient from "infisical-node"; export const client = new InfisicalClient({ @@ -15,17 +17,14 @@ export const getRootEncryptionKey = async () => { } export const getInviteOnlySignup = async () => (await client.getSecret("INVITE_ONLY_SIGNUP")).secretValue === "true" export const getSaltRounds = async () => parseInt((await client.getSecret("SALT_ROUNDS")).secretValue) || 10; +export const getAuthSecret = async () => (await client.getSecret("JWT_AUTH_SECRET")).secretValue ?? (await client.getSecret("AUTH_SECRET")).secretValue; export const getJwtAuthLifetime = async () => (await client.getSecret("JWT_AUTH_LIFETIME")).secretValue || "10d"; -export const getJwtAuthSecret = async () => (await client.getSecret("JWT_AUTH_SECRET")).secretValue; export const getJwtMfaLifetime = async () => (await client.getSecret("JWT_MFA_LIFETIME")).secretValue || "5m"; -export const getJwtMfaSecret = async () => (await client.getSecret("JWT_MFA_LIFETIME")).secretValue || "5m"; export const getJwtRefreshLifetime = async () => (await client.getSecret("JWT_REFRESH_LIFETIME")).secretValue || "90d"; -export const getJwtRefreshSecret = async () => (await client.getSecret("JWT_REFRESH_SECRET")).secretValue; -export const getJwtServiceSecret = async () => (await client.getSecret("JWT_SERVICE_SECRET")).secretValue; +export const getJwtServiceSecret = async () => (await client.getSecret("JWT_SERVICE_SECRET")).secretValue; // TODO: deprecate (related to ST V1) export const getJwtSignupLifetime = async () => (await client.getSecret("JWT_SIGNUP_LIFETIME")).secretValue || "15m"; -export const getJwtProviderAuthSecret = async () => (await client.getSecret("JWT_PROVIDER_AUTH_SECRET")).secretValue; export const getJwtProviderAuthLifetime = async () => (await client.getSecret("JWT_PROVIDER_AUTH_LIFETIME")).secretValue || "15m"; -export const getJwtSignupSecret = async () => (await client.getSecret("JWT_SIGNUP_SECRET")).secretValue; +export const getJwtServiceTokenSecret = async () => (await client.getSecret("JWT_SERVICE_TOKEN_SECRET")).secretValue; export const getMongoURL = async () => (await client.getSecret("MONGO_URL")).secretValue; export const getNodeEnv = async () => (await client.getSecret("NODE_ENV")).secretValue || "production"; export const getVerboseErrorOutput = async () => (await client.getSecret("VERBOSE_ERROR_OUTPUT")).secretValue === "true" && true; @@ -52,6 +51,9 @@ export const getClientIdGoogleLogin = async () => (await client.getSecret("CLIEN export const getClientSecretGoogleLogin = async () => (await client.getSecret("CLIENT_SECRET_GOOGLE_LOGIN")).secretValue; export const getClientIdGitHubLogin = async () => (await client.getSecret("CLIENT_ID_GITHUB_LOGIN")).secretValue; export const getClientSecretGitHubLogin = async () => (await client.getSecret("CLIENT_SECRET_GITHUB_LOGIN")).secretValue; +export const getClientIdGitLabLogin = async () => (await client.getSecret("CLIENT_ID_GITLAB_LOGIN")).secretValue; +export const getClientSecretGitLabLogin = async () => (await client.getSecret("CLIENT_SECRET_GITLAB_LOGIN")).secretValue; +export const getUrlGitLabLogin = async () => (await client.getSecret("URL_GITLAB_LOGIN")).secretValue || GITLAB_URL; export const getPostHogHost = async () => (await client.getSecret("POSTHOG_HOST")).secretValue || "https://app.posthog.com"; export const getPostHogProjectApiKey = async () => (await client.getSecret("POSTHOG_PROJECT_API_KEY")).secretValue || "phc_nSin8j5q2zdhpFDI1ETmFNUIuTG4DwKVyIigrY10XiE"; diff --git a/backend/src/controllers/v1/authController.ts b/backend/src/controllers/v1/authController.ts index 6f6411541..e426eef5a 100644 --- a/backend/src/controllers/v1/authController.ts +++ b/backend/src/controllers/v1/authController.ts @@ -6,15 +6,18 @@ const jsrp = require("jsrp"); import { LoginSRPDetail, TokenVersion, User } from "../../models"; import { clearTokens, createToken, issueAuthTokens } from "../../helpers/auth"; import { checkUserDevice } from "../../helpers/user"; -import { ACTION_LOGIN, ACTION_LOGOUT } from "../../variables"; +import { + ACTION_LOGIN, + ACTION_LOGOUT, + AuthTokenType +} from "../../variables"; import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; import { EELogService } from "../../ee/services"; import { getUserAgentType } from "../../utils/posthog"; import { + getAuthSecret, getHttpsEnabled, - getJwtAuthLifetime, - getJwtAuthSecret, - getJwtRefreshSecret + getJwtAuthLifetime } from "../../config"; import { ActorType } from "../../ee/models"; import { validateRequest } from "../../helpers/validation"; @@ -238,6 +241,7 @@ export const checkAuth = async (req: Request, res: Response) => { * @returns */ export const getNewToken = async (req: Request, res: Response) => { + const refreshToken = req.cookies.jid; if (!refreshToken) @@ -245,7 +249,9 @@ export const getNewToken = async (req: Request, res: Response) => { message: "Failed to find refresh token in request cookies" }); - const decodedToken = jwt.verify(refreshToken, await getJwtRefreshSecret()); + const decodedToken = jwt.verify(refreshToken, await getAuthSecret()); + + if (decodedToken.authTokenType !== AuthTokenType.REFRESH_TOKEN) throw UnauthorizedRequestError(); const user = await User.findOne({ _id: decodedToken.userId @@ -268,12 +274,13 @@ export const getNewToken = async (req: Request, res: Response) => { const token = createToken({ payload: { + authTokenType: AuthTokenType.ACCESS_TOKEN, userId: decodedToken.userId, tokenVersionId: tokenVersion._id.toString(), accessVersion: tokenVersion.refreshVersion }, expiresIn: await getJwtAuthLifetime(), - secret: await getJwtAuthSecret() + secret: await getAuthSecret() }); return res.status(200).send({ diff --git a/backend/src/controllers/v1/index.ts b/backend/src/controllers/v1/index.ts index a9bde9971..b32debeca 100644 --- a/backend/src/controllers/v1/index.ts +++ b/backend/src/controllers/v1/index.ts @@ -16,7 +16,6 @@ import * as workspaceController from "./workspaceController"; import * as secretScanningController from "./secretScanningController"; import * as webhookController from "./webhookController"; import * as secretImpsController from "./secretImpsController"; - export { authController, botController, diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index a7390abaa..06b245659 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -12,6 +12,7 @@ import { INTEGRATION_BITBUCKET_API_URL, INTEGRATION_GCP_SECRET_MANAGER, INTEGRATION_NORTHFLANK_API_URL, + INTEGRATION_QOVERY_API_URL, INTEGRATION_RAILWAY_API_URL, INTEGRATION_SET, INTEGRATION_VERCEL_API_URL, @@ -344,6 +345,362 @@ export const getIntegrationAuthVercelBranches = async (req: Request, res: Respon }); }; +/** + * Return list of Qovery Orgs for a specific user + * @param req + * @param res + */ +export const getIntegrationAuthQoveryOrgs = async (req: Request, res: Response) => { + const { + params: { integrationAuthId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryOrgsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/organization`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + interface QoveryOrg { + id: string; + name: string; + } + + const orgs = data.results.map((a: QoveryOrg) => { + return { + name: a.name, + orgId: a.id, + }; + }); + + return res.status(200).send({ + orgs + }); +}; + +/** + * Return list of Qovery Projects for a specific orgId + * @param req + * @param res + */ +export const getIntegrationAuthQoveryProjects = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { orgId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryProjectsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + interface Project { + name: string; + projectId: string; + } + + interface QoveryProject { + id: string; + name: string; + } + + let projects: Project[] = []; + + if (orgId && orgId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/organization/${orgId}/project`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + projects = data.results.map((a: QoveryProject) => { + return { + name: a.name, + projectId: a.id, + }; + }); + } + + return res.status(200).send({ + projects + }); +}; + +/** + * Return list of Qovery environments for project with id [projectId] + * @param req + * @param res + */ +export const getIntegrationAuthQoveryEnvironments = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { projectId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryEnvironmentsV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + interface Environment { + name: string; + environmentId: string; + } + + interface QoveryEnvironment { + id: string; + name: string; + } + + let environments: Environment[] = []; + + if (projectId && projectId !== "" && projectId !== "none") { // TODO: fix + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/project/${projectId}/environment`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + environments = data.results.map((a: QoveryEnvironment) => { + return { + name: a.name, + environmentId: a.id, + }; + }); + } + + return res.status(200).send({ + environments + }); +}; + +/** + * Return list of Qovery apps for environment with id [environmentId] + * @param req + * @param res + */ +export const getIntegrationAuthQoveryApps = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { environmentId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryScopesV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + interface App { + name: string; + appId: string; + } + + interface QoveryApp { + id: string; + name: string; + } + + let apps: App[] = []; + + if (environmentId && environmentId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/application`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + apps = data.results.map((a: QoveryApp) => { + return { + name: a.name, + appId: a.id, + }; + }); + } + + return res.status(200).send({ + apps + }); +}; + +/** + * Return list of Qovery containers for environment with id [environmentId] + * @param req + * @param res + */ +export const getIntegrationAuthQoveryContainers = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { environmentId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryScopesV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + interface Container { + name: string; + appId: string; + } + + interface QoveryContainer { + id: string; + name: string; + } + + let containers: Container[] = []; + + if (environmentId && environmentId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/container`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + containers = data.results.map((a: QoveryContainer) => { + return { + name: a.name, + appId: a.id, + }; + }); + } + + return res.status(200).send({ + containers + }); +}; + +/** + * Return list of Qovery jobs for environment with id [environmentId] + * @param req + * @param res + */ +export const getIntegrationAuthQoveryJobs = async (req: Request, res: Response) => { + const { + params: { integrationAuthId }, + query: { environmentId } + } = await validateRequest(reqValidator.GetIntegrationAuthQoveryScopesV1, req); + + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ + integrationAuthId: new ObjectId(integrationAuthId) + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + integrationAuth.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); + + interface Job { + name: string; + appId: string; + } + + interface QoveryJob { + id: string; + name: string; + } + + let jobs: Job[] = []; + + if (environmentId && environmentId !== "") { + const { data } = await standardRequest.get( + `${INTEGRATION_QOVERY_API_URL}/environment/${environmentId}/job`, + { + headers: { + Authorization: `Token ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + jobs = data.results.map((a: QoveryJob) => { + return { + name: a.name, + appId: a.id, + }; + }); + } + + return res.status(200).send({ + jobs + }); +}; + /** * Return list of Railway environments for Railway project with * id [appId] @@ -713,11 +1070,12 @@ export const getIntegrationAuthNorthflankSecretGroups = async (req: Request, res */ export const getIntegrationAuthTeamCityBuildConfigs = async (req: Request, res: Response) => { const { - params: { integrationAuthId, appId } + params: { integrationAuthId }, + query: { appId } } = await validateRequest(reqValidator.GetIntegrationAuthTeamCityBuildConfigsV1, req); - + // TODO(akhilmhdh): remove class -> static function path and makes these into reusable independent functions - const { integrationAuth } = await getIntegrationAuthAccessHelper({ + const { integrationAuth, accessToken } = await getIntegrationAuthAccessHelper({ integrationAuthId: new ObjectId(integrationAuthId) }); @@ -749,13 +1107,13 @@ export const getIntegrationAuthTeamCityBuildConfigs = async (req: Request, res: const { data: { buildType } } = await standardRequest.get( - `${req.integrationAuth.url}/app/rest/buildTypes`, + `${integrationAuth.url}/app/rest/buildTypes`, { params: { locator: `project:${appId}` }, headers: { - Authorization: `Bearer ${req.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/json" } } diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index b847b382c..e709d9d0c 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -34,6 +34,7 @@ export const createIntegration = async (req: Request, res: Response) => { appId, owner, region, + scope, targetService, targetServiceId, integrationAuthId, @@ -42,7 +43,7 @@ export const createIntegration = async (req: Request, res: Response) => { metadata } } = await validateRequest(reqValidator.CreateIntegrationV1, req); - + const integrationAuth = await IntegrationAuth.findById(integrationAuthId) .populate<{ workspace: IWorkspace }>("workspace") .select( @@ -90,6 +91,7 @@ export const createIntegration = async (req: Request, res: Response) => { owner, path, region, + scope, secretPath, integration: integrationAuth.integration, integrationAuth: new Types.ObjectId(integrationAuthId), diff --git a/backend/src/controllers/v1/membershipController.ts b/backend/src/controllers/v1/membershipController.ts index b022ac5de..928d5b50b 100644 --- a/backend/src/controllers/v1/membershipController.ts +++ b/backend/src/controllers/v1/membershipController.ts @@ -1,7 +1,7 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { IUser, Key, Membership, MembershipOrg, User, Workspace } from "../../models"; -import { EventType } from "../../ee/models"; +import { EventType, Role } from "../../ee/models"; import { deleteMembership as deleteMember, findMembership } from "../../helpers/membership"; import { sendMail } from "../../helpers/nodemailer"; import { ACCEPTED, ADMIN, CUSTOM, MEMBER, VIEWER } from "../../variables"; @@ -15,7 +15,6 @@ import { getUserProjectPermissions } from "../../ee/services/ProjectRoleService"; import { ForbiddenError } from "@casl/ability"; -import Role from "../../ee/models/role"; import { BadRequestError } from "../../utils/errors"; import { InviteUserToWorkspaceV1 } from "../../validation/workspace"; diff --git a/backend/src/controllers/v1/membershipOrgController.ts b/backend/src/controllers/v1/membershipOrgController.ts index 57ebb79aa..eeba96783 100644 --- a/backend/src/controllers/v1/membershipOrgController.ts +++ b/backend/src/controllers/v1/membershipOrgController.ts @@ -8,11 +8,11 @@ import { updateSubscriptionOrgQuantity } from "../../helpers/organization"; import { sendMail } from "../../helpers/nodemailer"; import { TokenService } from "../../services"; import { EELicenseService } from "../../ee/services"; -import { ACCEPTED, INVITED, MEMBER, TOKEN_EMAIL_ORG_INVITATION } from "../../variables"; +import { ACCEPTED, AuthTokenType, INVITED, MEMBER, TOKEN_EMAIL_ORG_INVITATION } from "../../variables"; import * as reqValidator from "../../validation/membershipOrg"; import { + getAuthSecret, getJwtSignupLifetime, - getJwtSignupSecret, getSiteURL, getSmtpConfigured } from "../../config"; @@ -272,10 +272,11 @@ export const verifyUserToOrganization = async (req: Request, res: Response) => { // generate temporary signup token const token = createToken({ payload: { + authTokenType: AuthTokenType.SIGNUP_TOKEN, userId: user._id.toString() }, expiresIn: await getJwtSignupLifetime(), - secret: await getJwtSignupSecret() + secret: await getAuthSecret() }); return res.status(200).send({ diff --git a/backend/src/controllers/v1/organizationController.ts b/backend/src/controllers/v1/organizationController.ts index 757d1aed7..2d216b6c5 100644 --- a/backend/src/controllers/v1/organizationController.ts +++ b/backend/src/controllers/v1/organizationController.ts @@ -6,13 +6,11 @@ import { Organization, Workspace } from "../../models"; -import { createOrganization as create } from "../../helpers/organization"; -import { addMembershipsOrg } from "../../helpers/membershipOrg"; -import { ACCEPTED, ADMIN } from "../../variables"; import { getLicenseServerUrl, getSiteURL } from "../../config"; import { licenseServerKeyRequest } from "../../config/request"; import { validateRequest } from "../../helpers/validation"; import * as reqValidator from "../../validation/organization"; +import { ACCEPTED } from "../../variables"; import { OrgPermissionActions, OrgPermissionSubjects, @@ -34,36 +32,6 @@ export const getOrganizations = async (req: Request, res: Response) => { }); }; -/** - * Create new organization named [organizationName] - * and add user as owner - * @param req - * @param res - * @returns - */ -export const createOrganization = async (req: Request, res: Response) => { - const { - body: { organizationName } - } = await validateRequest(reqValidator.CreateOrgv1, req); - - // create organization and add user as member - const organization = await create({ - email: req.user.email, - name: organizationName - }); - - await addMembershipsOrg({ - userIds: [req.user._id.toString()], - organizationId: organization._id.toString(), - roles: [ADMIN], - statuses: [ACCEPTED] - }); - - return res.status(200).send({ - organization - }); -}; - /** * Return organization with id [organizationId] * @param req diff --git a/backend/src/controllers/v1/passwordController.ts b/backend/src/controllers/v1/passwordController.ts index 65447bcf8..d0b59f317 100644 --- a/backend/src/controllers/v1/passwordController.ts +++ b/backend/src/controllers/v1/passwordController.ts @@ -5,12 +5,12 @@ import * as bigintConversion from "bigint-conversion"; import { BackupPrivateKey, LoginSRPDetail, User } from "../../models"; import { clearTokens, createToken, sendMail } from "../../helpers"; import { TokenService } from "../../services"; -import { TOKEN_EMAIL_PASSWORD_RESET } from "../../variables"; +import { AuthTokenType, TOKEN_EMAIL_PASSWORD_RESET } from "../../variables"; import { BadRequestError } from "../../utils/errors"; import { + getAuthSecret, getHttpsEnabled, getJwtSignupLifetime, - getJwtSignupSecret, getSiteURL } from "../../config"; import { ActorType } from "../../ee/models"; @@ -88,10 +88,11 @@ export const emailPasswordResetVerify = async (req: Request, res: Response) => { // generate temporary password-reset token const token = createToken({ payload: { + authTokenType: AuthTokenType.SIGNUP_TOKEN, userId: user._id.toString() }, expiresIn: await getJwtSignupLifetime(), - secret: await getJwtSignupSecret() + secret: await getAuthSecret() }); return res.status(200).send({ diff --git a/backend/src/controllers/v1/secretImpsController.ts b/backend/src/controllers/v1/secretImpsController.ts index a16d666c4..956229b7c 100644 --- a/backend/src/controllers/v1/secretImpsController.ts +++ b/backend/src/controllers/v1/secretImpsController.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { isValidScope } from "../../helpers"; import { Folder, IServiceTokenData, SecretImport, ServiceTokenData } from "../../models"; import { getAllImportedSecrets } from "../../services/SecretImportService"; -import { getFolderWithPathFromId } from "../../services/FolderService"; +import { getFolderByPath, getFolderWithPathFromId } from "../../services/FolderService"; import { BadRequestError, ResourceNotFoundError, @@ -95,37 +95,12 @@ export const createSecretImp = async (req: Request, res: Response) => { */ const { - body: { workspaceId, environment, folderId, secretImport } + body: { workspaceId, environment, directory, secretImport } } = await validateRequest(reqValidator.CreateSecretImportV1, req); - const folders = await Folder.findOne({ - workspace: workspaceId, - environment - }).lean(); - - if (!folders && folderId !== "root") { - throw ResourceNotFoundError({ - message: "Failed to find folder" - }); - } - - let secretPath = "/"; - if (folders) { - const { folderPath } = getFolderWithPathFromId(folders.nodes, folderId); - secretPath = folderPath; - } - if (req.authData.authPayload instanceof ServiceTokenData) { // root check - let isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - isValidScopeAccess = isValidScope( - req.authData.authPayload, - secretImport.environment, - secretImport.secretPath - ); + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); if (!isValidScopeAccess) { throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); } @@ -133,27 +108,31 @@ export const createSecretImp = async (req: Request, res: Response) => { const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: secretImport.environment, - secretPath: secretImport.secretPath - }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory }) ); } + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }).lean(); + + if (!folders && directory !== "/") + throw ResourceNotFoundError({ message: "Failed to find folder" }); + + let folderId = "root"; + if (folders) { + const folder = getFolderByPath(folders.nodes, directory); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + folderId = folder.id; + } + const importSecDoc = await SecretImport.findOne({ workspace: workspaceId, environment, folderId }); - const importToSecretPath = folders - ? getFolderWithPathFromId(folders.nodes, folderId).folderPath - : "/"; - if (!importSecDoc) { const doc = new SecretImport({ workspace: workspaceId, @@ -173,7 +152,7 @@ export const createSecretImp = async (req: Request, res: Response) => { importFromEnvironment: secretImport.environment, importFromSecretPath: secretImport.secretPath, importToEnvironment: environment, - importToSecretPath + importToSecretPath: directory } }, { @@ -206,7 +185,7 @@ export const createSecretImp = async (req: Request, res: Response) => { importFromEnvironment: secretImport.environment, importFromSecretPath: secretImport.secretPath, importToEnvironment: environment, - importToSecretPath + importToSecretPath: directory } }, { @@ -563,8 +542,38 @@ export const getSecretImports = async (req: Request, res: Response) => { } */ const { - query: { workspaceId, environment, folderId } + query: { workspaceId, environment, directory } } = await validateRequest(reqValidator.GetSecretImportsV1, req); + + if (req.authData.authPayload instanceof ServiceTokenData) { + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath: directory + }) + ); + } + + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }).lean(); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + let folderId = "root"; + if (folders) { + const folder = getFolderByPath(folders.nodes, directory); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + folderId = folder.id; + } + const importSecDoc = await SecretImport.findOne({ workspace: workspaceId, environment, @@ -575,41 +584,6 @@ export const getSecretImports = async (req: Request, res: Response) => { return res.status(200).json({ secretImport: {} }); } - // check for service token validity - const folders = await Folder.findOne({ - workspace: importSecDoc.workspace, - environment: importSecDoc.environment - }).lean(); - - let secretPath = "/"; - if (folders) { - const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); - secretPath = folderPath; - } - - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - importSecDoc.environment, - secretPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } else { - const { permission } = await getUserProjectPermissions( - req.user._id, - importSecDoc.workspace.toString() - ); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: importSecDoc.environment, - secretPath - }) - ); - } - return res.status(200).json({ secretImport: importSecDoc }); }; @@ -621,9 +595,39 @@ export const getSecretImports = async (req: Request, res: Response) => { */ export const getAllSecretsFromImport = async (req: Request, res: Response) => { const { - query: { workspaceId, environment, folderId } + query: { workspaceId, environment, directory } } = await validateRequest(reqValidator.GetAllSecretsFromImportV1, req); + if (req.authData.authPayload instanceof ServiceTokenData) { + // check for service token validity + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath: directory + }) + ); + } + + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }).lean(); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + let folderId = "root"; + if (folders) { + const folder = getFolderByPath(folders.nodes, directory); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + folderId = folder.id; + } + const importSecDoc = await SecretImport.findOne({ workspace: workspaceId, environment, @@ -634,11 +638,6 @@ export const getAllSecretsFromImport = async (req: Request, res: Response) => { return res.status(200).json({ secrets: [] }); } - const folders = await Folder.findOne({ - workspace: importSecDoc.workspace, - environment: importSecDoc.environment - }).lean(); - let secretPath = "/"; if (folders) { const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId); diff --git a/backend/src/controllers/v1/secretScanningController.ts b/backend/src/controllers/v1/secretScanningController.ts index ff1e5f3f0..581a168b1 100644 --- a/backend/src/controllers/v1/secretScanningController.ts +++ b/backend/src/controllers/v1/secretScanningController.ts @@ -1,12 +1,15 @@ import { Request, Response } from "express"; -import GitAppInstallationSession from "../../ee/models/gitAppInstallationSession"; +import { + GitAppInstallationSession, + GitAppOrganizationInstallation, + GitRisks +} from "../../ee/models"; import crypto from "crypto"; import { Types } from "mongoose"; import { OrganizationNotFoundError, UnauthorizedRequestError } from "../../utils/errors"; -import GitAppOrganizationInstallation from "../../ee/models/gitAppOrganizationInstallation"; import { scanGithubFullRepoForSecretLeaks } from "../../queues/secret-scanning/githubScanFullRepository"; import { getSecretScanningGitAppId, getSecretScanningPrivateKey } from "../../config"; -import GitRisks, { +import { STATUS_RESOLVED_FALSE_POSITIVE, STATUS_RESOLVED_NOT_REVOKED, STATUS_RESOLVED_REVOKED diff --git a/backend/src/controllers/v1/secretsFolderController.ts b/backend/src/controllers/v1/secretsFolderController.ts index b6f60249c..835551e76 100644 --- a/backend/src/controllers/v1/secretsFolderController.ts +++ b/backend/src/controllers/v1/secretsFolderController.ts @@ -9,12 +9,9 @@ import { Secret, ServiceTokenData } from "../../models"; import { Folder } from "../../models/folder"; import { appendFolder, - deleteFolderById, - generateFolderId, getAllFolderIds, getFolderByPath, getFolderWithPathFromId, - getParentFromFolderId, validateFolderName } from "../../services/FolderService"; import { @@ -25,13 +22,9 @@ import { import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; import * as reqValidator from "../../validation/folders"; -/** - * Create folder with name [folderName] for workspace with id [workspaceId] - * and environment [environment] - * @param req - * @param res - * @returns - */ +const ERR_FOLDER_NOT_FOUND = BadRequestError({ message: "The folder doesn't exist" }); + +// verify workspace id/environment export const createFolder = async (req: Request, res: Response) => { /* #swagger.summary = 'Create a folder' @@ -107,7 +100,7 @@ export const createFolder = async (req: Request, res: Response) => { } */ const { - body: { workspaceId, environment, folderName, parentFolderId } + body: { workspaceId, environment, folderName, directory } } = await validateRequest(reqValidator.CreateFolderV1, req); if (!validateFolderName(folderName)) { @@ -116,35 +109,28 @@ export const createFolder = async (req: Request, res: Response) => { }); } + if (req.authData.authPayload instanceof ServiceTokenData) { + // token check + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + // user check + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory }) + ); + } + const folders = await Folder.findOne({ workspace: workspaceId, environment }).lean(); - if (req.user) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - const secretPath = - folders && parentFolderId - ? getFolderWithPathFromId(folders.nodes, parentFolderId).folderPath - : "/"; - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } - // space has no folders initialized - if (!folders) { - if (req.authData.authPayload instanceof ServiceTokenData) { - // root check - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, "/"); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - - const id = generateFolderId(); const folder = new Folder({ workspace: workspaceId, environment, @@ -152,14 +138,15 @@ export const createFolder = async (req: Request, res: Response) => { id: "root", name: "root", version: 1, - children: [{ id, name: folderName, children: [], version: 1 }] + children: [] } }); + const { parent, child } = appendFolder(folder.nodes, { folderName, directory }); await folder.save(); const folderVersion = new FolderVersion({ workspace: workspaceId, environment, - nodes: folder.nodes + nodes: parent }); await folderVersion.save(); await EESecretService.takeSecretSnapshot({ @@ -173,9 +160,9 @@ export const createFolder = async (req: Request, res: Response) => { type: EventType.CREATE_FOLDER, metadata: { environment, - folderId: id, + folderId: child.id, folderName, - folderPath: `root/${folderName}` + folderPath: directory } }, { @@ -183,56 +170,37 @@ export const createFolder = async (req: Request, res: Response) => { } ); - return res.json({ folder: { id, name: folderName } }); + return res.json({ folder: { id: child.id, name: folderName } }); } - const folder = appendFolder(folders.nodes, { folderName, parentFolderId }); + const { parent, child, hasCreated } = appendFolder(folders.nodes, { folderName, directory }); - await Folder.findByIdAndUpdate(folders._id, folders); - - const { folder: parentFolder, folderPath: parentFolderPath } = getFolderWithPathFromId( - folders.nodes, - parentFolderId || "root" - ); - - if (req.authData.authPayload instanceof ServiceTokenData) { - // root check - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - environment, - parentFolderPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } + if (!hasCreated) return res.json({ folder: child }); await Folder.findByIdAndUpdate(folders._id, folders); const folderVersion = new FolderVersion({ workspace: workspaceId, environment, - nodes: parentFolder + nodes: parent }); await folderVersion.save(); await EESecretService.takeSecretSnapshot({ workspaceId: new Types.ObjectId(workspaceId), environment, - folderId: parentFolderId + folderId: child.id }); - const { folderPath } = getFolderWithPathFromId(folders.nodes, folder.id); - await EEAuditLogService.createAuditLog( req.authData, { type: EventType.CREATE_FOLDER, metadata: { environment, - folderId: folder.id, + folderId: child.id, folderName, - folderPath + folderPath: directory } }, { @@ -240,7 +208,7 @@ export const createFolder = async (req: Request, res: Response) => { } ); - return res.json({ folder }); + return res.json({ folder: child }); }; /** @@ -332,8 +300,8 @@ export const updateFolderById = async (req: Request, res: Response) => { } */ const { - body: { workspaceId, environment, name }, - params: { folderId } + body: { workspaceId, environment, name, directory }, + params: { folderName } } = await validateRequest(reqValidator.UpdateFolderV1, req); if (!validateFolderName(name)) { @@ -342,38 +310,31 @@ export const updateFolderById = async (req: Request, res: Response) => { }); } + if (req.authData.authPayload instanceof ServiceTokenData) { + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); + if (!isValidScopeAccess) { + throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); + } + } else { + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory }) + ); + } + const folders = await Folder.findOne({ workspace: workspaceId, environment }); if (!folders) { throw BadRequestError({ message: "The folder doesn't exist" }); } - const parentFolder = getParentFromFolderId(folders.nodes, folderId); + const parentFolder = getFolderByPath(folders.nodes, directory); if (!parentFolder) { throw BadRequestError({ message: "The folder doesn't exist" }); } - if (req.user) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - const secretPath = getFolderWithPathFromId(folders.nodes, parentFolder.id).folderPath; - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } - - const folder = parentFolder.children.find(({ id }) => id === folderId); - if (!folder) { - throw BadRequestError({ message: "The folder doesn't exist" }); - } - - if (req.authData.authPayload instanceof ServiceTokenData) { - const { folderPath: secretPath } = getFolderWithPathFromId(folders.nodes, parentFolder.id); - // root check - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } + const folder = parentFolder.children.find(({ name }) => name === folderName); + if (!folder) throw ERR_FOLDER_NOT_FOUND; const oldFolderName = folder.name; parentFolder.version += 1; @@ -505,24 +466,12 @@ export const deleteFolder = async (req: Request, res: Response) => { } */ const { - params: { folderId }, - body: { environment, workspaceId } + params: { folderName }, + body: { environment, workspaceId, directory } } = await validateRequest(reqValidator.DeleteFolderV1, req); - const folders = await Folder.findOne({ workspace: workspaceId, environment }); - if (!folders) { - throw BadRequestError({ message: "The folder doesn't exist" }); - } - - const delOp = deleteFolderById(folders.nodes, folderId); - if (!delOp) { - throw BadRequestError({ message: "The folder doesn't exist" }); - } - const { deletedNode: delFolder, parent: parentFolder } = delOp; - const { folderPath: secretPath } = getFolderWithPathFromId(folders.nodes, parentFolder.id); - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath); + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); if (!isValidScopeAccess) { throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); } @@ -531,12 +480,23 @@ export const deleteFolder = async (req: Request, res: Response) => { const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory }) ); } + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders) throw ERR_FOLDER_NOT_FOUND; + + const parentFolder = getFolderByPath(folders.nodes, directory); + if (!parentFolder) throw ERR_FOLDER_NOT_FOUND; + + const index = parentFolder.children.findIndex(({ name }) => name === folderName); + if (index === -1) throw ERR_FOLDER_NOT_FOUND; + + const deletedFolder = parentFolder.children.splice(index, 1)[0]; + parentFolder.version += 1; - const delFolderIds = getAllFolderIds(delFolder); + const delFolderIds = getAllFolderIds(deletedFolder); await Folder.findByIdAndUpdate(folders._id, folders); const folderVersion = new FolderVersion({ @@ -565,9 +525,9 @@ export const deleteFolder = async (req: Request, res: Response) => { type: EventType.DELETE_FOLDER, metadata: { environment, - folderId, - folderName: delFolder.name, - folderPath: secretPath + folderId: deletedFolder.id, + folderName: deletedFolder.name, + folderPath: directory } }, { @@ -575,7 +535,7 @@ export const deleteFolder = async (req: Request, res: Response) => { } ); - res.send({ message: "successfully deleted folders", folders: delFolderIds }); + return res.send({ message: "successfully deleted folders", folders: delFolderIds }); }; /** @@ -677,69 +637,27 @@ export const getFolders = async (req: Request, res: Response) => { } */ const { - query: { workspaceId, environment, parentFolderId, parentFolderPath } + query: { workspaceId, environment, directory } } = await validateRequest(reqValidator.GetFoldersV1, req); - const folders = await Folder.findOne({ workspace: workspaceId, environment }); - - if (req.user) await getUserProjectPermissions(req.user._id, workspaceId); - - if (!folders) { - res.send({ folders: [], dir: [] }); - return; - } - - // if instead of parentFolderId given a path like /folder1/folder2 - if (parentFolderPath) { - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope( - req.authData.authPayload, - environment, - parentFolderPath - ); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - const folder = getFolderByPath(folders.nodes, parentFolderPath); - - if (!folder) { - res.send({ folders: [], dir: [] }); - return; - } - // dir is not needed at present as this is only used in overview section of secrets - res.send({ - folders: folder.children.map(({ id, name }) => ({ id, name })), - dir: [{ name: folder.name, id: folder.id }] - }); - } - - if (!parentFolderId) { - if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, "/"); - if (!isValidScopeAccess) { - throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); - } - } - - const rootFolders = folders.nodes.children.map(({ id, name }) => ({ - id, - name - })); - res.send({ folders: rootFolders }); - return; - } - - const { folder, folderPath, dir } = getFolderWithPathFromId(folders.nodes, parentFolderId); if (req.authData.authPayload instanceof ServiceTokenData) { - const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, folderPath); + const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory); if (!isValidScopeAccess) { throw UnauthorizedRequestError({ message: "Folder Permission Denied" }); } + } else { + // check that user is a member of the workspace + await getUserProjectPermissions(req.user._id, workspaceId); } - res.send({ - folders: folder.children.map(({ id, name }) => ({ id, name })), - dir + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders) { + return res.send({ folders: [], dir: [] }); + } + + const folder = getFolderByPath(folders.nodes, directory); + + return res.send({ + folders: folder?.children?.map(({ id, name }) => ({ id, name })) || [] }); }; diff --git a/backend/src/controllers/v1/signupController.ts b/backend/src/controllers/v1/signupController.ts index 68a4e07f2..4dceb2783 100644 --- a/backend/src/controllers/v1/signupController.ts +++ b/backend/src/controllers/v1/signupController.ts @@ -4,14 +4,15 @@ import { checkEmailVerification, sendEmailVerification } from "../../helpers/sig import { createToken } from "../../helpers/auth"; import { BadRequestError } from "../../utils/errors"; import { + getAuthSecret, getInviteOnlySignup, getJwtSignupLifetime, - getJwtSignupSecret, getSmtpConfigured } from "../../config"; import { validateUserEmail } from "../../validation"; import { validateRequest } from "../../helpers/validation"; import * as reqValidator from "../../validation/auth"; +import { AuthTokenType } from "../../variables"; /** * Signup step 1: Initialize account for user under email [email] and send a verification code @@ -95,10 +96,11 @@ export const verifyEmailSignup = async (req: Request, res: Response) => { // generate temporary signup token const token = createToken({ payload: { + authTokenType: AuthTokenType.SIGNUP_TOKEN, userId: user._id.toString() }, expiresIn: await getJwtSignupLifetime(), - secret: await getJwtSignupSecret() + secret: await getAuthSecret() }); return res.status(200).send({ diff --git a/backend/src/controllers/v1/workspaceController.ts b/backend/src/controllers/v1/workspaceController.ts index cbefc98a7..1b3a9fd3f 100644 --- a/backend/src/controllers/v1/workspaceController.ts +++ b/backend/src/controllers/v1/workspaceController.ts @@ -202,12 +202,12 @@ export const deleteWorkspace = async (req: Request, res: Response) => { ); // delete workspace - await deleteWork({ - id: workspaceId + const workspace = await deleteWork({ + workspaceId: new Types.ObjectId(workspaceId) }); return res.status(200).send({ - message: "Successfully deleted workspace" + workspace }); }; diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts index d75206c64..46c288ea0 100644 --- a/backend/src/controllers/v2/authController.ts +++ b/backend/src/controllers/v2/authController.ts @@ -10,9 +10,9 @@ import { sendMail } from "../../helpers/nodemailer"; import { TokenService } from "../../services"; import { EELogService } from "../../ee/services"; import { BadRequestError, InternalServerError } from "../../utils/errors"; -import { ACTION_LOGIN, TOKEN_EMAIL_MFA } from "../../variables"; +import { ACTION_LOGIN, AuthTokenType, TOKEN_EMAIL_MFA } from "../../variables"; import { getUserAgentType } from "../../utils/posthog"; // TODO: move this -import { getHttpsEnabled, getJwtMfaLifetime, getJwtMfaSecret } from "../../config"; +import { getAuthSecret, getHttpsEnabled, getJwtMfaLifetime } from "../../config"; import { validateRequest } from "../../helpers/validation"; import * as reqValidator from "../../validation/auth"; @@ -109,10 +109,11 @@ export const login2 = async (req: Request, res: Response) => { // generate temporary MFA token const token = createToken({ payload: { + authTokenType: AuthTokenType.MFA_TOKEN, userId: user._id.toString() }, expiresIn: await getJwtMfaLifetime(), - secret: await getJwtMfaSecret() + secret: await getAuthSecret() }); const code = await TokenService.createToken({ diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts index 72365d7da..9441734f9 100644 --- a/backend/src/controllers/v2/environmentController.ts +++ b/backend/src/controllers/v2/environmentController.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { + Folder, Integration, Membership, Secret, @@ -21,19 +22,22 @@ import { getUserProjectPermissions } from "../../ee/services/ProjectRoleService"; import { ForbiddenError } from "@casl/ability"; +import { SecretImport } from "../../models"; +import { ServiceAccountWorkspacePermission } from "../../models"; +import { Webhook } from "../../models"; /** - * Create new workspace environment named [environmentName] + * Create new workspace environment named [environmentName] * with slug [environmentSlug] under workspace with id * @param req * @param res * @returns */ export const createWorkspaceEnvironment = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Create environment' #swagger.description = 'Create environment' - + #swagger.security = [{ "apiKeyAuth": [] }] @@ -42,12 +46,12 @@ export const createWorkspaceEnvironment = async (req: Request, res: Response) => "description": "ID of project", "required": true, "type": "string" - } + } - /* + /* #swagger.summary = 'Create environment' #swagger.description = 'Create environment' - + #swagger.security = [{ "apiKeyAuth": [] }] @@ -56,7 +60,7 @@ export const createWorkspaceEnvironment = async (req: Request, res: Response) => "description": "ID of project", "required": true, "type": "string" - } + } #swagger.requestBody = { content: { @@ -84,7 +88,7 @@ export const createWorkspaceEnvironment = async (req: Request, res: Response) => #swagger.responses[200] = { content: { "application/json": { - "schema": { + "schema": { "type": "object", "properties": { "message": { @@ -111,7 +115,7 @@ export const createWorkspaceEnvironment = async (req: Request, res: Response) => }, "description": "Response after creating a new environment" } - } + } } } */ @@ -242,7 +246,7 @@ export const reorderWorkspaceEnvironments = async (req: Request, res: Response) * @returns */ export const renameWorkspaceEnvironment = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Rename workspace environment' #swagger.description = 'Rename a specific environment within a workspace' @@ -313,7 +317,7 @@ export const renameWorkspaceEnvironment = async (req: Request, res: Response) => } } } - } + } */ const { params: { workspaceId }, @@ -369,13 +373,43 @@ export const renameWorkspaceEnvironment = async (req: Request, res: Response) => { environment: environmentSlug } ); await ServiceTokenData.updateMany( - { workspace: workspaceId, environment: oldEnvironmentSlug }, - { environment: environmentSlug } + { + workspace: workspaceId, + "scopes.environment": oldEnvironmentSlug + }, + { $set: { "scopes.$[element].environment": environmentSlug } }, + { arrayFilters: [{ "element.environment": oldEnvironmentSlug }] } ); await Integration.updateMany( { workspace: workspaceId, environment: oldEnvironmentSlug }, { environment: environmentSlug } ); + + await Folder.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + + await SecretImport.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + await SecretImport.updateMany( + { workspace: workspaceId, "imports.environment": oldEnvironmentSlug }, + { $set: { "imports.$[element].environment": environmentSlug } }, + { arrayFilters: [{ "element.environment": oldEnvironmentSlug }] }, + ); + + await ServiceAccountWorkspacePermission.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + + await Webhook.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + await Membership.updateMany( { workspace: workspaceId, @@ -418,10 +452,10 @@ export const renameWorkspaceEnvironment = async (req: Request, res: Response) => * @returns */ export const deleteWorkspaceEnvironment = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Delete workspace environment' #swagger.description = 'Delete a specific environment from a workspace' - + #swagger.security = [{ "apiKeyAuth": [] }] @@ -454,7 +488,7 @@ export const deleteWorkspaceEnvironment = async (req: Request, res: Response) => #swagger.responses[200] = { content: { "application/json": { - "schema": { + "schema": { "type": "object", "properties": { "message": { @@ -472,9 +506,9 @@ export const deleteWorkspaceEnvironment = async (req: Request, res: Response) => }, "description": "Response after deleting an environment from a workspace" } - } + } } - } + } */ const { params: { workspaceId }, @@ -561,10 +595,10 @@ export const deleteWorkspaceEnvironment = async (req: Request, res: Response) => // TODO(akhilmhdh) after rbac this can be completely removed export const getAllAccessibleEnvironmentsOfWorkspace = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Get all accessible environments of a workspace' #swagger.description = 'Fetch all environments that the user has access to in a specified workspace' - + #swagger.security = [{ "apiKeyAuth": [] }] @@ -611,7 +645,7 @@ export const getAllAccessibleEnvironmentsOfWorkspace = async (req: Request, res: } } } - } + } */ const { params: { workspaceId } diff --git a/backend/src/controllers/v2/organizationsController.ts b/backend/src/controllers/v2/organizationsController.ts index ad776864c..384765f68 100644 --- a/backend/src/controllers/v2/organizationsController.ts +++ b/backend/src/controllers/v2/organizationsController.ts @@ -1,11 +1,25 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; -import { Membership, MembershipOrg, ServiceAccount, Workspace } from "../../models"; +import { + Membership, + MembershipOrg, + ServiceAccount, + Workspace +} from "../../models"; +import { Role } from "../../ee/models"; import { deleteMembershipOrg } from "../../helpers/membershipOrg"; -import { updateSubscriptionOrgQuantity } from "../../helpers/organization"; -import Role from "../../ee/models/role"; -import { BadRequestError } from "../../utils/errors"; -import { CUSTOM } from "../../variables"; +import { + createOrganization as create, + deleteOrganization, + updateSubscriptionOrgQuantity +} from "../../helpers/organization"; +import { addMembershipsOrg } from "../../helpers/membershipOrg"; +import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; +import { + ACCEPTED, + ADMIN, + CUSTOM +} from "../../variables"; import * as reqValidator from "../../validation/organization"; import { validateRequest } from "../../helpers/validation"; import { @@ -332,3 +346,60 @@ export const getOrganizationServiceAccounts = async (req: Request, res: Response serviceAccounts }); }; + +/** + * Create new organization named [organizationName] + * and add user as owner + * @param req + * @param res + * @returns + */ + export const createOrganization = async (req: Request, res: Response) => { + const { + body: { name } + } = await validateRequest(reqValidator.CreateOrgv2, req); + + // create organization and add user as member + const organization = await create({ + email: req.user.email, + name + }); + + await addMembershipsOrg({ + userIds: [req.user._id.toString()], + organizationId: organization._id.toString(), + roles: [ADMIN], + statuses: [ACCEPTED] + }); + + return res.status(200).send({ + organization + }); +}; + +/** + * Delete organization with id [organizationId] + * @param req + * @param res + */ +export const deleteOrganizationById = async (req: Request, res: Response) => { + const { + params: { organizationId } + } = await validateRequest(reqValidator.DeleteOrgv2, req); + + const membershipOrg = await MembershipOrg.findOne({ + user: req.user._id, + organization: new Types.ObjectId(organizationId), + role: ADMIN + }); + + if (!membershipOrg) throw UnauthorizedRequestError(); + + const organization = await deleteOrganization({ + organizationId: new Types.ObjectId(organizationId) + }); + + return res.status(200).send({ + organization + }); +} \ No newline at end of file diff --git a/backend/src/controllers/v2/usersController.ts b/backend/src/controllers/v2/usersController.ts index 0b859f749..3998956f7 100644 --- a/backend/src/controllers/v2/usersController.ts +++ b/backend/src/controllers/v2/usersController.ts @@ -5,49 +5,9 @@ import bcrypt from "bcrypt"; import { APIKeyData, AuthMethod, MembershipOrg, TokenVersion, User } from "../../models"; import { getSaltRounds } from "../../config"; import { validateRequest } from "../../helpers/validation"; +import { deleteUser } from "../../helpers/user"; import * as reqValidator from "../../validation"; -/** - * Return the current user. - * @param req - * @param res - * @returns - */ -export const getMe = async (req: Request, res: Response) => { - /* - #swagger.summary = "Retrieve the current user on the request" - #swagger.description = "Retrieve the current user on the request" - - #swagger.security = [{ - "apiKeyAuth": [] - }] - - #swagger.responses[200] = { - content: { - "application/json": { - "schema": { - "type": "object", - "properties": { - "user": { - "type": "object", - $ref: "#/components/schemas/CurrentUser", - "description": "Current user on request" - } - } - } - } - } - } - */ - const user = await User.findById(req.user._id).select( - "+salt +publicKey +encryptedPrivateKey +iv +tag +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag" - ); - - return res.status(200).send({ - user - }); -}; - /** * Update the current user's MFA-enabled status [isMfaEnabled]. * Note: Infisical currently only supports email-based 2FA only; this will expand to @@ -296,3 +256,59 @@ export const deleteMySessions = async (req: Request, res: Response) => { message: "Successfully revoked all sessions" }); }; + +/** + * Return the current user. + * @param req + * @param res + * @returns + */ + export const getMe = async (req: Request, res: Response) => { + /* + #swagger.summary = "Retrieve the current user on the request" + #swagger.description = "Retrieve the current user on the request" + + #swagger.security = [{ + "apiKeyAuth": [] + }] + + #swagger.responses[200] = { + content: { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user": { + "type": "object", + $ref: "#/components/schemas/CurrentUser", + "description": "Current user on request" + } + } + } + } + } + } + */ + const user = await User.findById(req.user._id).select( + "+salt +publicKey +encryptedPrivateKey +iv +tag +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag" + ); + + return res.status(200).send({ + user + }); +}; + +/** + * Delete the current user. + * @param req + * @param res + */ +export const deleteMe = async (req: Request, res: Response) => { + const user = await deleteUser({ + userId: req.user._id + }); + + return res.status(200).send({ + user + }); +} \ No newline at end of file diff --git a/backend/src/controllers/v3/authController.ts b/backend/src/controllers/v3/authController.ts index 1b228a39b..9cd23cf87 100644 --- a/backend/src/controllers/v3/authController.ts +++ b/backend/src/controllers/v3/authController.ts @@ -10,9 +10,9 @@ import { sendMail } from "../../helpers/nodemailer"; import { TokenService } from "../../services"; import { EELogService } from "../../ee/services"; import { BadRequestError, InternalServerError } from "../../utils/errors"; -import { ACTION_LOGIN, TOKEN_EMAIL_MFA } from "../../variables"; +import { ACTION_LOGIN, AuthTokenType, TOKEN_EMAIL_MFA } from "../../variables"; import { getUserAgentType } from "../../utils/posthog"; // TODO: move this -import { getHttpsEnabled, getJwtMfaLifetime, getJwtMfaSecret } from "../../config"; +import { getAuthSecret, getHttpsEnabled, getJwtMfaLifetime } from "../../config"; import { AuthMethod } from "../../models/user"; import { validateRequest } from "../../helpers/validation"; import * as reqValidator from "../../validation/auth"; @@ -134,10 +134,11 @@ export const login2 = async (req: Request, res: Response) => { // generate temporary MFA token const token = createToken({ payload: { + authTokenType: AuthTokenType.MFA_TOKEN, userId: user._id.toString() }, expiresIn: await getJwtMfaLifetime(), - secret: await getJwtMfaSecret() + secret: await getAuthSecret() }); const code = await TokenService.createToken({ diff --git a/backend/src/controllers/v3/index.ts b/backend/src/controllers/v3/index.ts index 959bab532..e52b1a0ea 100644 --- a/backend/src/controllers/v3/index.ts +++ b/backend/src/controllers/v3/index.ts @@ -7,5 +7,5 @@ export { authController, secretsController, signupController, - workspacesController, + workspacesController } diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index a528436af..2e0c8514a 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -3,10 +3,11 @@ import { Types } from "mongoose"; import { EventService, SecretService } from "../../services"; import { eventPushSecrets } from "../../events"; import { BotService } from "../../services"; -import { containsGlobPatterns, isValidScope, repackageSecretToRaw } from "../../helpers/secrets"; +import { containsGlobPatterns, isValidScopeV3, repackageSecretToRaw } from "../../helpers/secrets"; import { encryptSymmetric128BitHexKeyUTF8 } from "../../utils/crypto"; import { getAllImportedSecrets } from "../../services/SecretImportService"; -import { Folder, IServiceTokenData } from "../../models"; +import { Folder, IMembership, IServiceTokenData, IServiceTokenDataV3 } from "../../models"; +import { Permission } from "../../models/serviceTokenDataV3"; import { getFolderByPath, getFolderWithPathFromId } from "../../services/FolderService"; import { BadRequestError } from "../../utils/errors"; import { validateRequest } from "../../helpers/validation"; @@ -17,8 +18,115 @@ import { getUserProjectPermissions } from "../../ee/services/ProjectRoleService"; import { ForbiddenError, subject } from "@casl/ability"; -import { validateServiceTokenDataClientForWorkspace } from "../../validation"; +import { + validateServiceTokenDataClientForWorkspace, + validateServiceTokenDataV3ClientForWorkspace +} from "../../validation"; import { PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS } from "../../variables"; +import { ActorType } from "../../ee/models"; +import { UnauthorizedRequestError } from "../../utils/errors"; +import { AuthData } from "../../interfaces/middleware"; +import { + generateSecretApprovalRequest, + getSecretPolicyOfBoard +} from "../../ee/services/SecretApprovalService"; +import { CommitType } from "../../ee/models/secretApprovalRequest"; +import { IRole } from "../../ee/models/role"; + +const checkSecretsPermission = async ({ + authData, + workspaceId, + environment, + secretPath, + secretAction +}: { + authData: AuthData; + workspaceId: string; + environment: string; + secretPath: string; + secretAction: ProjectPermissionActions; // CRUD +}): Promise<{ + authVerifier: (env: string, secPath: string) => boolean; + membership?: Omit & { customRole: IRole }; +}> => { + let STV2RequiredPermissions = []; + let STV3RequiredPermissions: Permission[] = []; + + switch (secretAction) { + case ProjectPermissionActions.Create: + STV2RequiredPermissions = [PERMISSION_WRITE_SECRETS]; + STV3RequiredPermissions = [Permission.WRITE]; + break; + case ProjectPermissionActions.Read: + STV2RequiredPermissions = [PERMISSION_READ_SECRETS]; + STV3RequiredPermissions = [Permission.READ]; + break; + case ProjectPermissionActions.Edit: + STV2RequiredPermissions = [PERMISSION_WRITE_SECRETS]; + STV3RequiredPermissions = [Permission.WRITE]; + break; + case ProjectPermissionActions.Delete: + STV2RequiredPermissions = [PERMISSION_WRITE_SECRETS]; + STV3RequiredPermissions = [Permission.WRITE]; + break; + } + + switch (authData.actor.type) { + case ActorType.USER: { + const { permission, membership } = await getUserProjectPermissions( + authData.actor.metadata.userId, + workspaceId + ); + ForbiddenError.from(permission).throwUnlessCan( + secretAction, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + return { + authVerifier: (env: string, secPath: string) => + permission.can( + secretAction, + subject(ProjectPermissionSub.Secrets, { + environment: env, + secretPath: secPath + }) + ), + membership + }; + } + case ActorType.SERVICE: { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: STV2RequiredPermissions + }); + return { authVerifier: () => true }; + } + case ActorType.SERVICE_V3: { + await validateServiceTokenDataV3ClientForWorkspace({ + authData, + serviceTokenData: authData.authPayload as IServiceTokenDataV3, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: STV3RequiredPermissions + }); + return { + authVerifier: (env: string, secPath: string) => + isValidScopeV3({ + authPayload: authData.authPayload as IServiceTokenDataV3, + environment: env, + secretPath: secPath, + requiredPermissions: STV3RequiredPermissions + }) + }; + } + default: { + throw UnauthorizedRequestError(); + } + } +}; /** * Return secrets for workspace with id [workspaceId] and environment @@ -58,32 +166,13 @@ export const getSecretsRaw = async (req: Request, res: Response) => { if (!environment || !workspaceId) throw BadRequestError({ message: "Missing environment or workspace id" }); - let permissionCheckFn: (env: string, secPath: string) => boolean; // used to pass as callback function to import secret - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - permissionCheckFn = (env: string, secPath: string) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: env, - secretPath: secPath - }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_READ_SECRETS] - }); - permissionCheckFn = (env: string, secPath: string) => - isValidScope(req.authData.authPayload as IServiceTokenData, env, secPath); - } + const { authVerifier: permissionCheckFn } = await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Read + }); const secrets = await SecretService.getSecrets({ workspaceId: new Types.ObjectId(workspaceId), @@ -146,25 +235,17 @@ export const getSecretsRaw = async (req: Request, res: Response) => { */ export const getSecretByNameRaw = async (req: Request, res: Response) => { const { - query: { secretPath, environment, workspaceId, type }, + query: { secretPath, environment, workspaceId, type, include_imports }, params: { secretName } } = await validateRequest(reqValidator.GetSecretByNameRawV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_READ_SECRETS] - }); - } + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Read + }); const secret = await SecretService.getSecret({ secretName, @@ -172,7 +253,8 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => { environment, type, secretPath, - authData: req.authData + authData: req.authData, + include_imports }); const key = await BotService.getWorkspaceKeyWithBot({ @@ -195,24 +277,24 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => { export const createSecretRaw = async (req: Request, res: Response) => { const { params: { secretName }, - body: { secretPath, environment, workspaceId, type, secretValue, secretComment } + body: { + secretPath, + environment, + workspaceId, + type, + secretValue, + secretComment, + skipMultilineEncoding + } } = await validateRequest(reqValidator.CreateSecretRawV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); - } + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Create + }); const key = await BotService.getWorkspaceKeyWithBot({ workspaceId: new Types.ObjectId(workspaceId) @@ -248,7 +330,8 @@ export const createSecretRaw = async (req: Request, res: Response) => { secretPath, secretCommentCiphertext: secretCommentEncrypted.ciphertext, secretCommentIV: secretCommentEncrypted.iv, - secretCommentTag: secretCommentEncrypted.tag + secretCommentTag: secretCommentEncrypted.tag, + skipMultilineEncoding }); await EventService.handleEvent({ @@ -278,24 +361,16 @@ export const createSecretRaw = async (req: Request, res: Response) => { export const updateSecretByNameRaw = async (req: Request, res: Response) => { const { params: { secretName }, - body: { secretValue, environment, secretPath, type, workspaceId } + body: { secretValue, environment, secretPath, type, workspaceId, skipMultilineEncoding } } = await validateRequest(reqValidator.UpdateSecretByNameRawV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); - } + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Edit + }); const key = await BotService.getWorkspaceKeyWithBot({ workspaceId: new Types.ObjectId(workspaceId) @@ -315,7 +390,8 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => { secretValueCiphertext: secretValueEncrypted.ciphertext, secretValueIV: secretValueEncrypted.iv, secretValueTag: secretValueEncrypted.tag, - secretPath + secretPath, + skipMultilineEncoding }); await EventService.handleEvent({ @@ -345,21 +421,13 @@ export const deleteSecretByNameRaw = async (req: Request, res: Response) => { body: { environment, secretPath, type, workspaceId } } = await validateRequest(reqValidator.DeleteSecretByNameRawV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); - } + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Delete + }); const { secret } = await SecretService.deleteSecret({ secretName, @@ -408,37 +476,18 @@ export const getSecrets = async (req: Request, res: Response) => { if (folderId && folderId !== "root") { const folder = await Folder.findOne({ workspace: workspaceId, environment }); - if (!folder) throw BadRequestError({ message: "Folder not found" }); + if (!folder) return res.send({ secrets: [] }); secretPath = getFolderWithPathFromId(folder.nodes, folderId).folderPath; } - let permissionCheckFn: (env: string, secPath: string) => boolean; // used to pass as callback function to import secret - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - permissionCheckFn = (env: string, secPath: string) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: env, - secretPath: secPath - }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_READ_SECRETS] - }); - permissionCheckFn = (env: string, secPath: string) => - isValidScope(req.authData.authPayload as IServiceTokenData, env, secPath); - } + const { authVerifier: permissionCheckFn } = await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Read + }); const secrets = await SecretService.getSecrets({ workspaceId: new Types.ObjectId(workspaceId), @@ -483,25 +532,17 @@ export const getSecrets = async (req: Request, res: Response) => { */ export const getSecretByName = async (req: Request, res: Response) => { const { - query: { secretPath, environment, workspaceId, type }, + query: { secretPath, environment, workspaceId, type, include_imports }, params: { secretName } } = await validateRequest(reqValidator.GetSecretByNameV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_READ_SECRETS] - }); - } + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Read + }); const secret = await SecretService.getSecret({ secretName, @@ -509,7 +550,8 @@ export const getSecretByName = async (req: Request, res: Response) => { environment, type, secretPath, - authData: req.authData + authData: req.authData, + include_imports }); return res.status(200).send({ @@ -538,25 +580,50 @@ export const createSecret = async (req: Request, res: Response) => { secretCommentTag, secretKeyCiphertext, secretValueCiphertext, - secretCommentCiphertext + secretCommentCiphertext, + skipMultilineEncoding }, params: { secretName } } = await validateRequest(reqValidator.CreateSecretV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); + const { membership } = await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Create + }); + + if (membership && type !== "personal") { + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + if (secretApprovalPolicy) { + const secretApprovalRequest = await generateSecretApprovalRequest({ + workspaceId, + environment, + secretPath, + policy: secretApprovalPolicy, + commiterMembershipId: membership._id.toString(), + authData: req.authData, + data: { + [CommitType.CREATE]: [ + { + secretName, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretKeyTag, + secretKeyCiphertext, + secretKeyIV + } + ] + } + }); + return res.send({ approval: secretApprovalRequest }); + } } const secret = await SecretService.createSecret({ @@ -575,7 +642,8 @@ export const createSecret = async (req: Request, res: Response) => { secretCommentCiphertext, secretCommentIV, secretCommentTag, - metadata + metadata, + skipMultilineEncoding }); await EventService.handleEvent({ @@ -605,28 +673,68 @@ export const updateSecretByName = async (req: Request, res: Response) => { secretValueCiphertext, secretValueTag, secretValueIV, + secretId, type, environment, secretPath, - workspaceId + workspaceId, + tags, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + secretName: newSecretName, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + skipMultilineEncoding }, params: { secretName } } = await validateRequest(reqValidator.UpdateSecretByNameV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); + if (newSecretName && (!secretKeyIV || !secretKeyTag || !secretKeyCiphertext)) { + throw BadRequestError({ message: "Missing encrypted key" }); + } + + const { membership } = await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Edit + }); + + if (membership && type !== "personal") { + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + if (secretApprovalPolicy) { + const secretApprovalRequest = await generateSecretApprovalRequest({ + workspaceId, + environment, + secretPath, + policy: secretApprovalPolicy, + commiterMembershipId: membership._id.toString(), + authData: req.authData, + data: { + [CommitType.UPDATE]: [ + { + secretName, + newSecretName, + secretValueCiphertext, + secretValueIV, + secretValueTag, + tags, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretKeyTag, + secretKeyCiphertext, + secretKeyIV + } + ] + } + }); + return res.send({ approval: secretApprovalRequest }); + } } const secret = await SecretService.updateSecret({ @@ -634,11 +742,21 @@ export const updateSecretByName = async (req: Request, res: Response) => { workspaceId: new Types.ObjectId(workspaceId), environment, type, + secretId, authData: req.authData, + newSecretName, secretValueCiphertext, secretValueIV, secretValueTag, - secretPath + secretPath, + tags, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretKeyTag, + secretKeyCiphertext, + secretKeyIV }); await EventService.handleEvent({ @@ -661,28 +779,43 @@ export const updateSecretByName = async (req: Request, res: Response) => { */ export const deleteSecretByName = async (req: Request, res: Response) => { const { - body: { type, environment, secretPath, workspaceId }, + body: { type, environment, secretPath, workspaceId, secretId }, params: { secretName } } = await validateRequest(reqValidator.DeleteSecretByNameV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); + const { membership } = await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Delete + }); + + if (membership && type !== "personal") { + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + if (secretApprovalPolicy) { + const secretApprovalRequest = await generateSecretApprovalRequest({ + workspaceId, + environment, + secretPath, + authData: req.authData, + policy: secretApprovalPolicy, + commiterMembershipId: membership._id.toString(), + data: { + [CommitType.DELETE]: [ + { + secretName + } + ] + } + }); + return res.send({ approval: secretApprovalRequest }); + } } const { secret } = await SecretService.deleteSecret({ secretName, + secretId, workspaceId: new Types.ObjectId(workspaceId), environment, type, @@ -702,3 +835,143 @@ export const deleteSecretByName = async (req: Request, res: Response) => { secret }); }; + +export const createSecretByNameBatch = async (req: Request, res: Response) => { + const { + body: { secrets, secretPath, environment, workspaceId } + } = await validateRequest(reqValidator.CreateSecretByNameBatchV3, req); + + const { membership } = await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Create + }); + + if (membership) { + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + if (secretApprovalPolicy) { + const secretApprovalRequest = await generateSecretApprovalRequest({ + workspaceId, + environment, + secretPath, + authData: req.authData, + policy: secretApprovalPolicy, + commiterMembershipId: membership._id.toString(), + data: { + [CommitType.CREATE]: secrets.filter(({ type }) => type === "shared") + } + }); + return res.send({ approval: secretApprovalRequest }); + } + } + + const createdSecrets = await SecretService.createSecretBatch({ + secretPath, + environment, + workspaceId: new Types.ObjectId(workspaceId), + secrets, + authData: req.authData + }); + + return res.status(200).send({ + secrets: createdSecrets + }); +}; + +export const updateSecretByNameBatch = async (req: Request, res: Response) => { + const { + body: { secrets, secretPath, environment, workspaceId } + } = await validateRequest(reqValidator.UpdateSecretByNameBatchV3, req); + + const { membership } = await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Edit + }); + + if (membership) { + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + if (secretApprovalPolicy) { + const secretApprovalRequest = await generateSecretApprovalRequest({ + workspaceId, + environment, + secretPath, + policy: secretApprovalPolicy, + commiterMembershipId: membership._id.toString(), + data: { + [CommitType.UPDATE]: secrets.filter(({ type }) => type === "shared") + }, + authData: req.authData + }); + return res.send({ approval: secretApprovalRequest }); + } + } + + const updatedSecrets = await SecretService.updateSecretBatch({ + secretPath, + environment, + workspaceId: new Types.ObjectId(workspaceId), + secrets, + authData: req.authData + }); + + return res.status(200).send({ + secrets: updatedSecrets + }); +}; + +export const deleteSecretByNameBatch = async (req: Request, res: Response) => { + const { + body: { secrets, secretPath, environment, workspaceId } + } = await validateRequest(reqValidator.DeleteSecretByNameBatchV3, req); + + const { membership } = await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Delete + }); + + if (membership) { + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + if (secretApprovalPolicy) { + const secretApprovalRequest = await generateSecretApprovalRequest({ + workspaceId, + environment, + secretPath, + policy: secretApprovalPolicy, + commiterMembershipId: membership._id.toString(), + data: { + [CommitType.DELETE]: secrets.filter(({ type }) => type === "shared") + }, + authData: req.authData + }); + return res.send({ approval: secretApprovalRequest }); + } + } + + const deletedSecrets = await SecretService.deleteSecretBatch({ + secretPath, + environment, + workspaceId: new Types.ObjectId(workspaceId), + secrets, + authData: req.authData + }); + + await EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath + }) + }); + + return res.status(200).send({ + secrets: deletedSecrets + }); +}; diff --git a/backend/src/controllers/v3/signupController.ts b/backend/src/controllers/v3/signupController.ts index 79d661b58..d16fbe9ca 100644 --- a/backend/src/controllers/v3/signupController.ts +++ b/backend/src/controllers/v3/signupController.ts @@ -5,10 +5,10 @@ import { MembershipOrg, User } from "../../models"; import { completeAccount } from "../../helpers/user"; import { initializeDefaultOrg } from "../../helpers/signup"; import { issueAuthTokens, validateProviderAuthToken } from "../../helpers/auth"; -import { ACCEPTED, INVITED } from "../../variables"; +import { ACCEPTED, AuthTokenType, INVITED } from "../../variables"; import { standardRequest } from "../../config/request"; -import { getHttpsEnabled, getJwtSignupSecret, getLoopsApiKey } from "../../config"; -import { BadRequestError } from "../../utils/errors"; +import { getAuthSecret, getHttpsEnabled, getLoopsApiKey } from "../../config"; +import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; import { TelemetryService } from "../../services"; import { AuthMethod } from "../../models"; import { validateRequest } from "../../helpers/validation"; @@ -78,12 +78,11 @@ export const completeAccountSignup = async (req: Request, res: Response) => { } const decodedToken = ( - jwt.verify(AUTH_TOKEN_VALUE, await getJwtSignupSecret()) + jwt.verify(AUTH_TOKEN_VALUE, await getAuthSecret()) ); - - if (decodedToken.userId !== user.id) { - throw BadRequestError(); - } + + if (decodedToken.authTokenType !== AuthTokenType.SIGNUP_TOKEN) throw UnauthorizedRequestError(); + if (decodedToken.userId !== user.id) throw UnauthorizedRequestError(); } // complete setting up user's account diff --git a/backend/src/controllers/v3/workspacesController.ts b/backend/src/controllers/v3/workspacesController.ts index 8469cdb5e..f1edca1f7 100644 --- a/backend/src/controllers/v3/workspacesController.ts +++ b/backend/src/controllers/v3/workspacesController.ts @@ -1,7 +1,7 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { validateRequest } from "../../helpers/validation"; -import { Secret } from "../../models"; +import { Secret, ServiceTokenDataV3 } from "../../models"; import { SecretService } from "../../services"; import { getUserProjectPermissions } from "../../ee/services/ProjectRoleService"; import { UnauthorizedRequestError } from "../../utils/errors"; @@ -101,3 +101,17 @@ export const nameWorkspaceSecrets = async (req: Request, res: Response) => { message: "Successfully named workspace secrets" }); }; + +export const getWorkspaceServiceTokenData = async (req: Request, res: Response) => { + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceServiceTokenDataV3, req); + + const serviceTokenData = await ServiceTokenDataV3.find({ + workspace: new Types.ObjectId(workspaceId) + }); + + return res.status(200).send({ + serviceTokenData + }); +} \ No newline at end of file diff --git a/backend/src/ee/controllers/v1/index.ts b/backend/src/ee/controllers/v1/index.ts index b57a07c9d..b023599e7 100644 --- a/backend/src/ee/controllers/v1/index.ts +++ b/backend/src/ee/controllers/v1/index.ts @@ -8,6 +8,8 @@ import * as actionController from "./actionController"; import * as membershipController from "./membershipController"; import * as cloudProductsController from "./cloudProductsController"; import * as roleController from "./roleController"; +import * as secretApprovalPolicyController from "./secretApprovalPolicyController"; +import * as secretApprovalRequestController from "./secretApprovalRequestsController"; export { secretController, @@ -19,5 +21,7 @@ export { actionController, membershipController, cloudProductsController, - roleController + roleController, + secretApprovalPolicyController, + secretApprovalRequestController }; diff --git a/backend/src/ee/controllers/v1/roleController.ts b/backend/src/ee/controllers/v1/roleController.ts index 0d5f105ea..d80ef2817 100644 --- a/backend/src/ee/controllers/v1/roleController.ts +++ b/backend/src/ee/controllers/v1/roleController.ts @@ -23,7 +23,7 @@ import { memberPermissions } from "../../services/RoleService"; import { BadRequestError } from "../../../utils/errors"; -import Role from "../../models/role"; +import { Role } from "../../models"; import { validateRequest } from "../../../helpers/validation"; import { packRules } from "@casl/ability/extra"; @@ -212,6 +212,7 @@ export const getUserPermissions = async (req: Request, res: Response) => { const { params: { orgId } } = await validateRequest(GetUserPermission, req); + const { permission } = await getUserOrgPermissions(req.user._id, orgId); res.status(200).json({ diff --git a/backend/src/ee/controllers/v1/secretApprovalPolicyController.ts b/backend/src/ee/controllers/v1/secretApprovalPolicyController.ts new file mode 100644 index 000000000..eb2f48718 --- /dev/null +++ b/backend/src/ee/controllers/v1/secretApprovalPolicyController.ts @@ -0,0 +1,128 @@ +import { ForbiddenError, subject } from "@casl/ability"; +import { Request, Response } from "express"; +import { nanoid } from "nanoid"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../services/ProjectRoleService"; +import { validateRequest } from "../../../helpers/validation"; +import { SecretApprovalPolicy } from "../../models/secretApprovalPolicy"; +import { getSecretPolicyOfBoard } from "../../services/SecretApprovalService"; +import { BadRequestError } from "../../../utils/errors"; +import * as reqValidator from "../../validation/secretApproval"; + +const ERR_SECRET_APPROVAL_NOT_FOUND = BadRequestError({ message: "secret approval not found" }); + +export const createSecretApprovalPolicy = async (req: Request, res: Response) => { + const { + body: { approvals, secretPath, approvers, environment, workspaceId, name } + } = await validateRequest(reqValidator.CreateSecretApprovalRule, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SecretApproval + ); + + const secretApproval = new SecretApprovalPolicy({ + workspace: workspaceId, + name: name ?? `${environment}-${nanoid(3)}`, + secretPath, + environment, + approvals, + approvers + }); + await secretApproval.save(); + + return res.send({ + approval: secretApproval + }); +}; + +export const updateSecretApprovalPolicy = async (req: Request, res: Response) => { + const { + body: { approvals, approvers, secretPath, name }, + params: { id } + } = await validateRequest(reqValidator.UpdateSecretApprovalRule, req); + + const secretApproval = await SecretApprovalPolicy.findById(id); + if (!secretApproval) throw ERR_SECRET_APPROVAL_NOT_FOUND; + + const { permission } = await getUserProjectPermissions( + req.user._id, + secretApproval.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.SecretApproval + ); + + const updatedDoc = await SecretApprovalPolicy.findByIdAndUpdate(id, { + approvals, + approvers, + name: (name || secretApproval?.name) ?? `${secretApproval.environment}-${nanoid(3)}`, + ...(secretPath === null ? { $unset: { secretPath: 1 } } : { secretPath }) + }); + + return res.send({ + approval: updatedDoc + }); +}; + +export const deleteSecretApprovalPolicy = async (req: Request, res: Response) => { + const { + params: { id } + } = await validateRequest(reqValidator.DeleteSecretApprovalRule, req); + + const secretApproval = await SecretApprovalPolicy.findById(id); + if (!secretApproval) throw ERR_SECRET_APPROVAL_NOT_FOUND; + + const { permission } = await getUserProjectPermissions( + req.user._id, + secretApproval.workspace.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SecretApproval + ); + + const deletedDoc = await SecretApprovalPolicy.findByIdAndDelete(id); + + return res.send({ + approval: deletedDoc + }); +}; + +export const getSecretApprovalPolicy = async (req: Request, res: Response) => { + const { + query: { workspaceId } + } = await validateRequest(reqValidator.GetSecretApprovalRuleList, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SecretApproval + ); + + const doc = await SecretApprovalPolicy.find({ workspace: workspaceId }); + + return res.send({ + approvals: doc + }); +}; + +export const getSecretApprovalPolicyOfBoard = async (req: Request, res: Response) => { + const { + query: { workspaceId, environment, secretPath } + } = await validateRequest(reqValidator.GetSecretApprovalPolicyOfABoard, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { secretPath, environment }) + ); + + const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); + return res.send({ policy: secretApprovalPolicy }); +}; diff --git a/backend/src/ee/controllers/v1/secretApprovalRequestsController.ts b/backend/src/ee/controllers/v1/secretApprovalRequestsController.ts new file mode 100644 index 000000000..be2e59202 --- /dev/null +++ b/backend/src/ee/controllers/v1/secretApprovalRequestsController.ts @@ -0,0 +1,333 @@ +import { Request, Response } from "express"; +import { getUserProjectPermissions } from "../../services/ProjectRoleService"; +import { validateRequest } from "../../../helpers/validation"; +import { Folder } from "../../../models"; +import { ApprovalStatus, SecretApprovalRequest } from "../../models/secretApprovalRequest"; +import * as reqValidator from "../../validation/secretApprovalRequest"; +import { getFolderWithPathFromId } from "../../../services/FolderService"; +import { BadRequestError, UnauthorizedRequestError } from "../../../utils/errors"; +import { ISecretApprovalPolicy, SecretApprovalPolicy } from "../../models/secretApprovalPolicy"; +import { performSecretApprovalRequestMerge } from "../../services/SecretApprovalService"; +import { Types } from "mongoose"; +import { EEAuditLogService } from "../../services"; +import { EventType } from "../../models"; + +export const getSecretApprovalRequestCount = async (req: Request, res: Response) => { + const { + query: { workspaceId } + } = await validateRequest(reqValidator.getSecretApprovalRequestCount, req); + + const { membership } = await getUserProjectPermissions(req.user._id, workspaceId); + const approvalRequestCount = await SecretApprovalRequest.aggregate([ + { + $match: { + workspace: new Types.ObjectId(workspaceId) + } + }, + { + $lookup: { + from: SecretApprovalPolicy.collection.name, + localField: "policy", + foreignField: "_id", + as: "policy" + } + }, + { $unwind: "$policy" }, + ...(membership.role !== "admin" + ? [ + { + $match: { + $or: [ + { committer: new Types.ObjectId(membership.id) }, + { "policy.approvers": new Types.ObjectId(membership.id) } + ] + } + } + ] + : []), + { + $group: { + _id: "$status", + count: { $sum: 1 } + } + } + ]); + const openRequests = approvalRequestCount.find(({ _id }) => _id === "open"); + const closedRequests = approvalRequestCount.find(({ _id }) => _id === "close"); + + return res.send({ + approvals: { open: openRequests?.count || 0, closed: closedRequests?.count || 0 } + }); +}; + +export const getSecretApprovalRequests = async (req: Request, res: Response) => { + const { + query: { status, committer, workspaceId, environment, limit, offset } + } = await validateRequest(reqValidator.getSecretApprovalRequests, req); + + const { membership } = await getUserProjectPermissions(req.user._id, workspaceId); + + const query = { + workspace: new Types.ObjectId(workspaceId), + environment, + committer: committer ? new Types.ObjectId(committer) : undefined, + status + }; + // to strip of undefined in query we use es6 spread to ignore those fields + Object.entries(query).forEach( + ([key, value]) => value === undefined && delete query[key as keyof typeof query] + ); + const approvalRequests = await SecretApprovalRequest.aggregate([ + { + $match: query + }, + { $sort: { createdAt: -1 } }, + { + $lookup: { + from: SecretApprovalPolicy.collection.name, + localField: "policy", + foreignField: "_id", + as: "policy" + } + }, + { $unwind: "$policy" }, + ...(membership.role !== "admin" + ? [ + { + $match: { + $or: [ + { committer: new Types.ObjectId(membership.id) }, + { "policy.approvers": new Types.ObjectId(membership.id) } + ] + } + } + ] + : []), + { $skip: offset }, + { $limit: limit } + ]); + if (!approvalRequests.length) return res.send({ approvals: [] }); + + const unqiueEnvs = environment ?? { + $in: [...new Set(approvalRequests.map(({ environment }) => environment))] + }; + const approvalRootFolders = await Folder.find({ + workspace: workspaceId, + environment: unqiueEnvs + }).lean(); + + const formatedApprovals = approvalRequests.map((el) => { + let secretPath = "/"; + const folders = approvalRootFolders.find(({ environment }) => environment === el.environment); + if (folders) { + secretPath = getFolderWithPathFromId(folders?.nodes, el.folderId)?.folderPath || "/"; + } + return { ...el, secretPath }; + }); + + return res.send({ + approvals: formatedApprovals + }); +}; + +export const getSecretApprovalRequestDetails = async (req: Request, res: Response) => { + const { + params: { id } + } = await validateRequest(reqValidator.getSecretApprovalRequestDetails, req); + const secretApprovalRequest = await SecretApprovalRequest.findById(id) + .populate<{ policy: ISecretApprovalPolicy }>("policy") + .populate({ + path: "commits.secretVersion", + populate: { + path: "tags" + } + }) + .populate("commits.secret", "version") + .populate("commits.newVersion.tags") + .lean(); + if (!secretApprovalRequest) + throw BadRequestError({ message: "Secret approval request not found" }); + + const { membership } = await getUserProjectPermissions( + req.user._id, + secretApprovalRequest.workspace.toString() + ); + // allow to fetch only if its admin or is the committer or approver + if ( + membership.role !== "admin" && + secretApprovalRequest.committer !== membership.id && + !secretApprovalRequest.policy.approvers.find( + (approverId) => approverId.toString() === membership._id.toString() + ) + ) { + throw UnauthorizedRequestError({ message: "User has no access" }); + } + + let secretPath = "/"; + const approvalRootFolders = await Folder.findOne({ + workspace: secretApprovalRequest.workspace, + environment: secretApprovalRequest.environment + }).lean(); + if (approvalRootFolders) { + secretPath = + getFolderWithPathFromId(approvalRootFolders?.nodes, secretApprovalRequest.folderId) + ?.folderPath || "/"; + } + + return res.send({ + approval: { ...secretApprovalRequest, secretPath } + }); +}; + +export const updateSecretApprovalReviewStatus = async (req: Request, res: Response) => { + const { + body: { status }, + params: { id } + } = await validateRequest(reqValidator.updateSecretApprovalReviewStatus, req); + const secretApprovalRequest = await SecretApprovalRequest.findById(id).populate<{ + policy: ISecretApprovalPolicy; + }>("policy"); + if (!secretApprovalRequest) + throw BadRequestError({ message: "Secret approval request not found" }); + + const { membership } = await getUserProjectPermissions( + req.user._id, + secretApprovalRequest.workspace.toString() + ); + if ( + membership.role !== "admin" && + secretApprovalRequest.committer !== membership.id && + !secretApprovalRequest.policy.approvers.find((approverId) => approverId.equals(membership.id)) + ) { + throw UnauthorizedRequestError({ message: "User has no access" }); + } + + const reviewerPos = secretApprovalRequest.reviewers.findIndex( + ({ member }) => member.toString() === membership._id.toString() + ); + if (reviewerPos !== -1) { + secretApprovalRequest.reviewers[reviewerPos].status = status; + } else { + secretApprovalRequest.reviewers.push({ member: membership._id, status }); + } + await secretApprovalRequest.save(); + + return res.send({ status }); +}; + +export const mergeSecretApprovalRequest = async (req: Request, res: Response) => { + const { + params: { id } + } = await validateRequest(reqValidator.mergeSecretApprovalRequest, req); + + const secretApprovalRequest = await SecretApprovalRequest.findById(id).populate<{ + policy: ISecretApprovalPolicy; + }>("policy"); + + if (!secretApprovalRequest) + throw BadRequestError({ message: "Secret approval request not found" }); + + const { membership } = await getUserProjectPermissions( + req.user._id, + secretApprovalRequest.workspace.toString() + ); + if ( + membership.role !== "admin" && + secretApprovalRequest.committer !== membership.id && + !secretApprovalRequest.policy.approvers.find((approverId) => approverId.equals(membership.id)) + ) { + throw UnauthorizedRequestError({ message: "User has no access" }); + } + + const reviewers = secretApprovalRequest.reviewers.reduce>( + (prev, curr) => ({ ...prev, [curr.member.toString()]: curr.status }), + {} + ); + const hasMinApproval = + secretApprovalRequest.policy.approvals <= + secretApprovalRequest.policy.approvers.filter( + (approverId) => reviewers[approverId.toString()] === ApprovalStatus.APPROVED + ).length; + + if (!hasMinApproval) throw BadRequestError({ message: "Doesn't have minimum approvals needed" }); + + const approval = await performSecretApprovalRequestMerge( + id, + req.authData, + membership._id.toString() + ); + return res.send({ approval }); +}; + +export const updateSecretApprovalRequestStatus = async (req: Request, res: Response) => { + const { + body: { status }, + params: { id } + } = await validateRequest(reqValidator.updateSecretApprovalRequestStatus, req); + + const secretApprovalRequest = await SecretApprovalRequest.findById(id).populate<{ + policy: ISecretApprovalPolicy; + }>("policy"); + + if (!secretApprovalRequest) + throw BadRequestError({ message: "Secret approval request not found" }); + + const { membership } = await getUserProjectPermissions( + req.user._id, + secretApprovalRequest.workspace.toString() + ); + + if ( + membership.role !== "admin" && + secretApprovalRequest.committer !== membership.id && + !secretApprovalRequest.policy.approvers.find((approverId) => approverId.equals(membership._id)) + ) { + throw UnauthorizedRequestError({ message: "User has no access" }); + } + + if (secretApprovalRequest.hasMerged) + throw BadRequestError({ message: "Approval request has been merged" }); + if (secretApprovalRequest.status === "close" && status === "close") + throw BadRequestError({ message: "Approval request is already closed" }); + if (secretApprovalRequest.status === "open" && status === "open") + throw BadRequestError({ message: "Approval request is already open" }); + + const updatedRequest = await SecretApprovalRequest.findByIdAndUpdate( + id, + { status, statusChangeBy: membership._id }, + { new: true } + ); + + if (status === "close") { + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.SECRET_APPROVAL_CLOSED, + metadata: { + closedBy: membership._id.toString(), + secretApprovalRequestId: id, + secretApprovalRequestSlug: secretApprovalRequest.slug + } + }, + { + workspaceId: secretApprovalRequest.workspace + } + ); + } else { + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.SECRET_APPROVAL_REOPENED, + metadata: { + reopenedBy: membership._id.toString(), + secretApprovalRequestId: id, + secretApprovalRequestSlug: secretApprovalRequest.slug + } + }, + { + workspaceId: secretApprovalRequest.workspace + } + ); + } + return res.send({ approval: updatedRequest }); +}; diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 7c9502656..3ee7a3229 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -5,6 +5,7 @@ import { Membership, Secret, ServiceTokenData, + ServiceTokenDataV3, TFolderSchema, User, Workspace @@ -20,6 +21,7 @@ import { SecretSnapshot, SecretVersion, ServiceActor, + ServiceActorV3, TFolderRootVersionSchema, TrustedIP, UserActor @@ -27,7 +29,7 @@ import { import { EESecretService } from "../../services"; import { getLatestSecretVersionIds } from "../../helpers/secretVersion"; // import Folder, { TFolderSchema } from "../../../models/folder"; -import { searchByFolderId } from "../../../services/FolderService"; +import { getFolderByPath, searchByFolderId } from "../../../services/FolderService"; import { EEAuditLogService, EELicenseService } from "../../services"; import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip"; import { validateRequest } from "../../../helpers/validation"; @@ -104,7 +106,7 @@ export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) = */ const { params: { workspaceId }, - query: { environment, folderId, offset, limit } + query: { environment, directory, offset, limit } } = await validateRequest(GetWorkspaceSecretSnapshotsV1, req); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); @@ -113,10 +115,20 @@ export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) = ProjectPermissionSub.SecretRollback ); + let folderId = "root"; + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + if (folders) { + const folder = getFolderByPath(folders?.nodes, directory); + if (!folder) throw BadRequestError({ message: "Invalid folder id" }); + folderId = folder.id; + } + const secretSnapshots = await SecretSnapshot.find({ workspace: workspaceId, environment, - folderId: folderId || "root" + folderId }) .sort({ createdAt: -1 }) .skip(offset) @@ -135,7 +147,7 @@ export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) = export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Response) => { const { params: { workspaceId }, - query: { environment, folderId } + query: { environment, directory } } = await validateRequest(GetWorkspaceSecretSnapshotsCountV1, req); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); @@ -144,10 +156,20 @@ export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Respon ProjectPermissionSub.SecretRollback ); + let folderId = "root"; + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + if (folders) { + const folder = getFolderByPath(folders?.nodes, directory); + if (!folder) throw BadRequestError({ message: "Invalid folder id" }); + folderId = folder.id; + } + const count = await SecretSnapshot.countDocuments({ workspace: workspaceId, environment, - folderId: folderId || "root" + folderId }); return res.status(200).send({ @@ -215,7 +237,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons const { params: { workspaceId }, - body: { folderId, environment, version } + body: { directory, environment, version } } = await validateRequest(RollbackWorkspaceSecretSnapshotV1, req); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); @@ -224,6 +246,16 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons ProjectPermissionSub.SecretRollback ); + let folderId = "root"; + const folders = await Folder.findOne({ workspace: workspaceId, environment }); + if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" }); + + if (folders) { + const folder = getFolderByPath(folders?.nodes, directory); + if (!folder) throw BadRequestError({ message: "Invalid folder id" }); + folderId = folder.id; + } + // validate secret snapshot const secretSnapshot = await SecretSnapshot.findOne({ workspace: workspaceId, @@ -653,7 +685,7 @@ export const getWorkspaceAuditLogs = async (req: Request, res: Response) => { ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs ); - + const query = { workspace: new Types.ObjectId(workspaceId), ...(eventType @@ -668,13 +700,13 @@ export const getWorkspaceAuditLogs = async (req: Request, res: Response) => { : {}), ...(actor ? { - "actor.type": actor.split("-", 2)[0], + "actor.type": actor.substring(0, actor.lastIndexOf("-")), ...(actor.split("-", 2)[0] === ActorType.USER ? { - "actor.metadata.userId": actor.split("-", 2)[1] + "actor.metadata.userId": actor.substring(actor.lastIndexOf("-") + 1) } : { - "actor.metadata.serviceId": actor.split("-", 2)[1] + "actor.metadata.serviceId": actor.substring(actor.lastIndexOf("-") + 1) }) } : {}), @@ -742,9 +774,27 @@ export const getWorkspaceAuditLogActorFilterOpts = async (req: Request, res: Res name: serviceTokenData.name } })); + + const serviceV3Actors: ServiceActorV3[] = ( + await ServiceTokenDataV3.find({ + workspace: new Types.ObjectId(workspaceId) + }) + ).map((serviceTokenData) => ({ + type: ActorType.SERVICE_V3, + metadata: { + serviceId: serviceTokenData._id.toString(), + name: serviceTokenData.name + } + })); + + const actors = [ + ...userActors, + ...serviceActors, + ...serviceV3Actors + ]; return res.status(200).send({ - actors: [...userActors, ...serviceActors] + actors }); }; diff --git a/backend/src/ee/controllers/v3/index.ts b/backend/src/ee/controllers/v3/index.ts new file mode 100644 index 000000000..af6f3d306 --- /dev/null +++ b/backend/src/ee/controllers/v3/index.ts @@ -0,0 +1,5 @@ +import * as serviceTokenDataController from "./serviceTokenDataController"; + +export { + serviceTokenDataController +} \ No newline at end of file diff --git a/backend/src/ee/controllers/v3/serviceTokenDataController.ts b/backend/src/ee/controllers/v3/serviceTokenDataController.ts new file mode 100644 index 000000000..6b6507cd0 --- /dev/null +++ b/backend/src/ee/controllers/v3/serviceTokenDataController.ts @@ -0,0 +1,325 @@ +import { Request, Response } from "express"; +import { Types } from "mongoose"; +import { + IServiceTokenDataV3, + IUser, + ServiceTokenDataV3, + ServiceTokenDataV3Key, + Workspace +} from "../../../models"; +import { + IServiceTokenV3Scope, + IServiceTokenV3TrustedIp +} from "../../../models/serviceTokenDataV3"; +import { + ActorType, + EventType +} from "../../models"; +import { validateRequest } from "../../../helpers/validation"; +import * as reqValidator from "../../../validation/serviceTokenDataV3"; +import { createToken } from "../../../helpers/auth"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../services/ProjectRoleService"; +import { ForbiddenError } from "@casl/ability"; +import { BadRequestError, ResourceNotFoundError } from "../../../utils/errors"; +import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip"; +import { EEAuditLogService, EELicenseService } from "../../services"; +import { getJwtServiceTokenSecret } from "../../../config"; + +/** + * Return project key for service token + * @param req + * @param res + */ +export const getServiceTokenDataKey = async (req: Request, res: Response) => { + const key = await ServiceTokenDataV3Key.findOne({ + serviceTokenData: (req.authData.authPayload as IServiceTokenDataV3)._id + }).populate<{ sender: IUser }>("sender", "publicKey"); + + if (!key) throw ResourceNotFoundError({ + message: "Failed to find project key for service token" + }); + + const { _id, workspace, encryptedKey, nonce, sender: { publicKey } } = key; + + return res.status(200).send({ + key: { + _id, + workspace, + encryptedKey, + publicKey, + nonce + } + }); +} + +/** + * Create service token data + * @param req + * @param res + * @returns + */ +export const createServiceTokenData = async (req: Request, res: Response) => { + const { + body: { + name, + workspaceId, + publicKey, + scopes, + trustedIps, + expiresIn, + encryptedKey, // for ServiceTokenDataV3Key + nonce // for ServiceTokenDataV3Key + } + } = await validateRequest(reqValidator.CreateServiceTokenV3, req); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.ServiceTokens + ); + + const workspace = await Workspace.findById(workspaceId); + if (!workspace) throw BadRequestError({ message: "Workspace not found" }); + + const plan = await EELicenseService.getPlan(workspace.organization); + + // validate trusted ips + const reformattedTrustedIps = trustedIps.map((trustedIp) => { + if (!plan.ipAllowlisting && trustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ + message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." + }); + + const isValidIPOrCidr = isValidIpOrCidr(trustedIp.ipAddress); + + if (!isValidIPOrCidr) return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + return extractIPDetails(trustedIp.ipAddress); + }); + + let expiresAt; + if (expiresIn) { + expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); + } + + let user; + if (req.authData.actor.type === ActorType.USER) { + user = req.authData.authPayload._id; + } + + const isActive = true; + const serviceTokenData = await new ServiceTokenDataV3({ + name, + user, + workspace: new Types.ObjectId(workspaceId), + publicKey, + usageCount: 0, + trustedIps: reformattedTrustedIps, + scopes, + isActive, + expiresAt + }).save(); + + await new ServiceTokenDataV3Key({ + encryptedKey, + nonce, + sender: req.user._id, + serviceTokenData: serviceTokenData._id, + workspace: new Types.ObjectId(workspaceId) + }).save(); + + const token = createToken({ + payload: { + _id: serviceTokenData._id.toString() + }, + expiresIn, + secret: await getJwtServiceTokenSecret() + }); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.CREATE_SERVICE_TOKEN_V3, + metadata: { + name, + isActive, + scopes: scopes as Array, + trustedIps: reformattedTrustedIps as Array, + expiresAt + } + }, + { + workspaceId: new Types.ObjectId(workspaceId) + } + ); + + return res.status(200).send({ + serviceTokenData, + serviceToken: `stv3.${token}` + }); +} + +/** + * Update service token data with id [serviceTokenDataId] + * @param req + * @param res + * @returns + */ +export const updateServiceTokenData = async (req: Request, res: Response) => { + const { + params: { serviceTokenDataId }, + body: { + name, + isActive, + scopes, + trustedIps, + expiresIn + } + } = await validateRequest(reqValidator.UpdateServiceTokenV3, req); + + let serviceTokenData = await ServiceTokenDataV3.findById(serviceTokenDataId); + if (!serviceTokenData) throw ResourceNotFoundError({ + message: "Service token not found" + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + serviceTokenData.workspace.toString() + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.ServiceTokens + ); + + const workspace = await Workspace.findById(serviceTokenData.workspace); + if (!workspace) throw BadRequestError({ message: "Workspace not found" }); + + const plan = await EELicenseService.getPlan(workspace.organization); + + // validate trusted ips + let reformattedTrustedIps; + if (trustedIps) { + reformattedTrustedIps = trustedIps.map((trustedIp) => { + if (!plan.ipAllowlisting && trustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ + message: "Failed to update IP access range to service token due to plan restriction. Upgrade plan to update IP access range." + }); + + const isValidIPOrCidr = isValidIpOrCidr(trustedIp.ipAddress); + + if (!isValidIPOrCidr) return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + return extractIPDetails(trustedIp.ipAddress); + }); + } + + let expiresAt; + if (expiresIn) { + expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); + } + + serviceTokenData = await ServiceTokenDataV3.findByIdAndUpdate( + serviceTokenDataId, + { + name, + isActive, + scopes, + trustedIps: reformattedTrustedIps, + expiresAt + }, + { + new: true + } + ); + + if (!serviceTokenData) throw BadRequestError({ + message: "Failed to update service token" + }); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.UPDATE_SERVICE_TOKEN_V3, + metadata: { + name: serviceTokenData.name, + isActive, + scopes: scopes as Array, + trustedIps: reformattedTrustedIps as Array, + expiresAt + } + }, + { + workspaceId: serviceTokenData.workspace + } + ); + + return res.status(200).send({ + serviceTokenData + }); +} + +/** + * Delete service token data with id [serviceTokenDataId] + * @param req + * @param res + * @returns + */ +export const deleteServiceTokenData = async (req: Request, res: Response) => { + const { + params: { serviceTokenDataId } + } = await validateRequest(reqValidator.DeleteServiceTokenV3, req); + + let serviceTokenData = await ServiceTokenDataV3.findById(serviceTokenDataId); + if (!serviceTokenData) throw ResourceNotFoundError({ + message: "Service token not found" + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + serviceTokenData.workspace.toString() + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.ServiceTokens + ); + + serviceTokenData = await ServiceTokenDataV3.findByIdAndDelete(serviceTokenDataId); + + if (!serviceTokenData) throw BadRequestError({ + message: "Failed to delete service token" + }); + + await ServiceTokenDataV3Key.findOneAndDelete({ + serviceTokenData: serviceTokenData._id + }); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.DELETE_SERVICE_TOKEN_V3, + metadata: { + name: serviceTokenData.name, + isActive: serviceTokenData.isActive, + scopes: serviceTokenData.scopes as Array, + trustedIps: serviceTokenData.trustedIps as Array, + expiresAt: serviceTokenData.expiresAt + } + }, + { + workspaceId: serviceTokenData.workspace + } + ); + + return res.status(200).send({ + serviceTokenData + }); +} \ No newline at end of file diff --git a/backend/src/ee/models/auditLog/enums.ts b/backend/src/ee/models/auditLog/enums.ts index dda49c1cd..a7be37a65 100644 --- a/backend/src/ee/models/auditLog/enums.ts +++ b/backend/src/ee/models/auditLog/enums.ts @@ -1,47 +1,58 @@ export enum ActorType { USER = "user", - SERVICE = "service" + SERVICE = "service", + SERVICE_V3 = "service-v3" } export enum UserAgentType { - WEB = "web", - CLI = "cli", - K8_OPERATOR = "k8-operator", - OTHER = "other" + WEB = "web", + CLI = "cli", + K8_OPERATOR = "k8-operator", + OTHER = "other" } export enum EventType { - GET_SECRETS = "get-secrets", - GET_SECRET = "get-secret", - REVEAL_SECRET = "reveal-secret", - CREATE_SECRET = "create-secret", - UPDATE_SECRET = "update-secret", - DELETE_SECRET = "delete-secret", - GET_WORKSPACE_KEY = "get-workspace-key", - AUTHORIZE_INTEGRATION = "authorize-integration", - UNAUTHORIZE_INTEGRATION = "unauthorize-integration", - CREATE_INTEGRATION = "create-integration", - DELETE_INTEGRATION = "delete-integration", - ADD_TRUSTED_IP = "add-trusted-ip", - UPDATE_TRUSTED_IP = "update-trusted-ip", - DELETE_TRUSTED_IP = "delete-trusted-ip", - CREATE_SERVICE_TOKEN = "create-service-token", - DELETE_SERVICE_TOKEN = "delete-service-token", - CREATE_ENVIRONMENT = "create-environment", - UPDATE_ENVIRONMENT = "update-environment", - DELETE_ENVIRONMENT = "delete-environment", - ADD_WORKSPACE_MEMBER = "add-workspace-member", - REMOVE_WORKSPACE_MEMBER = "remove-workspace-member", - CREATE_FOLDER = "create-folder", - UPDATE_FOLDER = "update-folder", - DELETE_FOLDER = "delete-folder", - CREATE_WEBHOOK = "create-webhook", - UPDATE_WEBHOOK_STATUS = "update-webhook-status", - DELETE_WEBHOOK = "delete-webhook", - GET_SECRET_IMPORTS = "get-secret-imports", - CREATE_SECRET_IMPORT = "create-secret-import", - UPDATE_SECRET_IMPORT = "update-secret-import", - DELETE_SECRET_IMPORT = "delete-secret-import", - UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", - UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions" -} \ No newline at end of file + GET_SECRETS = "get-secrets", + GET_SECRET = "get-secret", + REVEAL_SECRET = "reveal-secret", + CREATE_SECRET = "create-secret", + CREATE_SECRETS = "create-secrets", + UPDATE_SECRET = "update-secret", + UPDATE_SECRETS = "update-secrets", + DELETE_SECRET = "delete-secret", + DELETE_SECRETS = "delete-secrets", + GET_WORKSPACE_KEY = "get-workspace-key", + AUTHORIZE_INTEGRATION = "authorize-integration", + UNAUTHORIZE_INTEGRATION = "unauthorize-integration", + CREATE_INTEGRATION = "create-integration", + DELETE_INTEGRATION = "delete-integration", + ADD_TRUSTED_IP = "add-trusted-ip", + UPDATE_TRUSTED_IP = "update-trusted-ip", + DELETE_TRUSTED_IP = "delete-trusted-ip", + CREATE_SERVICE_TOKEN = "create-service-token", // v2 + DELETE_SERVICE_TOKEN = "delete-service-token", // v2 + CREATE_SERVICE_TOKEN_V3 = "create-service-token-v3", // v3 + UPDATE_SERVICE_TOKEN_V3 = "update-service-token-v3", // v3 + DELETE_SERVICE_TOKEN_V3 = "delete-service-token-v3", // v3 + CREATE_ENVIRONMENT = "create-environment", + UPDATE_ENVIRONMENT = "update-environment", + DELETE_ENVIRONMENT = "delete-environment", + ADD_WORKSPACE_MEMBER = "add-workspace-member", + REMOVE_WORKSPACE_MEMBER = "remove-workspace-member", + CREATE_FOLDER = "create-folder", + UPDATE_FOLDER = "update-folder", + DELETE_FOLDER = "delete-folder", + CREATE_WEBHOOK = "create-webhook", + UPDATE_WEBHOOK_STATUS = "update-webhook-status", + DELETE_WEBHOOK = "delete-webhook", + GET_SECRET_IMPORTS = "get-secret-imports", + CREATE_SECRET_IMPORT = "create-secret-import", + UPDATE_SECRET_IMPORT = "update-secret-import", + DELETE_SECRET_IMPORT = "delete-secret-import", + UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", + UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions", + SECRET_APPROVAL_MERGED = "secret-approval-merged", + SECRET_APPROVAL_REQUEST = "secret-approval-request", + SECRET_APPROVAL_CLOSED = "secret-approval-closed", + SECRET_APPROVAL_REOPENED = "secret-approval-reopened" +} diff --git a/backend/src/ee/models/auditLog/types.ts b/backend/src/ee/models/auditLog/types.ts index 71507212e..579a02ccb 100644 --- a/backend/src/ee/models/auditLog/types.ts +++ b/backend/src/ee/models/auditLog/types.ts @@ -2,358 +2,428 @@ import { ActorType, EventType } from "./enums"; +import { + IServiceTokenV3Scope, + IServiceTokenV3TrustedIp +} from "../../../models/serviceTokenDataV3"; interface UserActorMetadata { - userId: string; - email: string; + userId: string; + email: string; } interface ServiceActorMetadata { - serviceId: string; - name: string; + serviceId: string; + name: string; } export interface UserActor { - type: ActorType.USER; - metadata: UserActorMetadata; + type: ActorType.USER; + metadata: UserActorMetadata; } export interface ServiceActor { - type: ActorType.SERVICE; + type: ActorType.SERVICE; + metadata: ServiceActorMetadata; +} + +export interface ServiceActorV3 { + type: ActorType.SERVICE_V3; metadata: ServiceActorMetadata; } export type Actor = | UserActor - | ServiceActor; + | ServiceActor + | ServiceActorV3; interface GetSecretsEvent { - type: EventType.GET_SECRETS; - metadata: { - environment: string; - secretPath: string; - numberOfSecrets: number; - }; + type: EventType.GET_SECRETS; + metadata: { + environment: string; + secretPath: string; + numberOfSecrets: number; + }; } interface GetSecretEvent { - type: EventType.GET_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - }; + type: EventType.GET_SECRET; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersion: number; + }; } interface CreateSecretEvent { - type: EventType.CREATE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - } + type: EventType.CREATE_SECRET; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersion: number; + }; +} + +interface CreateSecretBatchEvent { + type: EventType.CREATE_SECRETS; + metadata: { + environment: string; + secretPath: string; + secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>; + }; } interface UpdateSecretEvent { - type: EventType.UPDATE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - } + type: EventType.UPDATE_SECRET; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersion: number; + }; +} + +interface UpdateSecretBatchEvent { + type: EventType.UPDATE_SECRETS; + metadata: { + environment: string; + secretPath: string; + secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>; + }; } interface DeleteSecretEvent { - type: EventType.DELETE_SECRET; - metadata: { - environment: string; - secretPath: string; - secretId: string; - secretKey: string; - secretVersion: number; - } + type: EventType.DELETE_SECRET; + metadata: { + environment: string; + secretPath: string; + secretId: string; + secretKey: string; + secretVersion: number; + }; +} + +interface DeleteSecretBatchEvent { + type: EventType.DELETE_SECRETS; + metadata: { + environment: string; + secretPath: string; + secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>; + }; } interface GetWorkspaceKeyEvent { - type: EventType.GET_WORKSPACE_KEY, - metadata: { - keyId: string; - } + type: EventType.GET_WORKSPACE_KEY; + metadata: { + keyId: string; + }; } interface AuthorizeIntegrationEvent { - type: EventType.AUTHORIZE_INTEGRATION; - metadata: { - integration: string; - } + type: EventType.AUTHORIZE_INTEGRATION; + metadata: { + integration: string; + }; } interface UnauthorizeIntegrationEvent { - type: EventType.UNAUTHORIZE_INTEGRATION; - metadata: { - integration: string; - } + type: EventType.UNAUTHORIZE_INTEGRATION; + metadata: { + integration: string; + }; } interface CreateIntegrationEvent { - type: EventType.CREATE_INTEGRATION; - metadata: { - integrationId: string; - integration: string; // TODO: fix type - environment: string; - secretPath: string; - url?: string; - app?: string; - appId?: string; - targetEnvironment?: string; - targetEnvironmentId?: string; - targetService?: string; - targetServiceId?: string; - path?: string; - region?: string; - } + type: EventType.CREATE_INTEGRATION; + metadata: { + integrationId: string; + integration: string; // TODO: fix type + environment: string; + secretPath: string; + url?: string; + app?: string; + appId?: string; + targetEnvironment?: string; + targetEnvironmentId?: string; + targetService?: string; + targetServiceId?: string; + path?: string; + region?: string; + }; } interface DeleteIntegrationEvent { - type: EventType.DELETE_INTEGRATION; - metadata: { - integrationId: string; - integration: string; // TODO: fix type - environment: string; - secretPath: string; - url?: string; - app?: string; - appId?: string; - targetEnvironment?: string; - targetEnvironmentId?: string; - targetService?: string; - targetServiceId?: string; - path?: string; - region?: string; - } + type: EventType.DELETE_INTEGRATION; + metadata: { + integrationId: string; + integration: string; // TODO: fix type + environment: string; + secretPath: string; + url?: string; + app?: string; + appId?: string; + targetEnvironment?: string; + targetEnvironmentId?: string; + targetService?: string; + targetServiceId?: string; + path?: string; + region?: string; + }; } interface AddTrustedIPEvent { - type: EventType.ADD_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - } + type: EventType.ADD_TRUSTED_IP; + metadata: { + trustedIpId: string; + ipAddress: string; + prefix?: number; + }; } interface UpdateTrustedIPEvent { - type: EventType.UPDATE_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - } + type: EventType.UPDATE_TRUSTED_IP; + metadata: { + trustedIpId: string; + ipAddress: string; + prefix?: number; + }; } interface DeleteTrustedIPEvent { - type: EventType.DELETE_TRUSTED_IP; - metadata: { - trustedIpId: string; - ipAddress: string; - prefix?: number; - } + type: EventType.DELETE_TRUSTED_IP; + metadata: { + trustedIpId: string; + ipAddress: string; + prefix?: number; + }; } interface CreateServiceTokenEvent { - type: EventType.CREATE_SERVICE_TOKEN; - metadata: { - name: string; - scopes: Array<{ - environment: string; - secretPath: string; - }>; - } + type: EventType.CREATE_SERVICE_TOKEN; + metadata: { + name: string; + scopes: Array<{ + environment: string; + secretPath: string; + }>; + }; } interface DeleteServiceTokenEvent { - type: EventType.DELETE_SERVICE_TOKEN; + type: EventType.DELETE_SERVICE_TOKEN; + metadata: { + name: string; + scopes: Array<{ + environment: string; + secretPath: string; + }>; + }; +} + +interface CreateServiceTokenV3Event { + type: EventType.CREATE_SERVICE_TOKEN_V3; metadata: { name: string; - scopes: Array<{ - environment: string; - secretPath: string; - }>; + isActive: boolean; + scopes: Array; + trustedIps: Array; + expiresAt?: Date; + } +} + +interface UpdateServiceTokenV3Event { + type: EventType.UPDATE_SERVICE_TOKEN_V3; + metadata: { + name?: string; + isActive?: boolean; + scopes?: Array; + trustedIps?: Array; + expiresAt?: Date; + } +} + +interface DeleteServiceTokenV3Event { + type: EventType.DELETE_SERVICE_TOKEN_V3; + metadata: { + name: string; + isActive: boolean; + scopes: Array; + expiresAt?: Date; + trustedIps: Array; } } interface CreateEnvironmentEvent { - type: EventType.CREATE_ENVIRONMENT; - metadata: { - name: string; - slug: string; - } + type: EventType.CREATE_ENVIRONMENT; + metadata: { + name: string; + slug: string; + }; } interface UpdateEnvironmentEvent { - type: EventType.UPDATE_ENVIRONMENT; - metadata: { - oldName: string; - newName: string; - oldSlug: string; - newSlug: string; - } + type: EventType.UPDATE_ENVIRONMENT; + metadata: { + oldName: string; + newName: string; + oldSlug: string; + newSlug: string; + }; } interface DeleteEnvironmentEvent { - type: EventType.DELETE_ENVIRONMENT; - metadata: { - name: string; - slug: string; - } + type: EventType.DELETE_ENVIRONMENT; + metadata: { + name: string; + slug: string; + }; } interface AddWorkspaceMemberEvent { - type: EventType.ADD_WORKSPACE_MEMBER; - metadata: { - userId: string; - email: string; - } + type: EventType.ADD_WORKSPACE_MEMBER; + metadata: { + userId: string; + email: string; + }; } interface RemoveWorkspaceMemberEvent { - type: EventType.REMOVE_WORKSPACE_MEMBER; - metadata: { - userId: string; - email: string; - } + type: EventType.REMOVE_WORKSPACE_MEMBER; + metadata: { + userId: string; + email: string; + }; } interface CreateFolderEvent { - type: EventType.CREATE_FOLDER; - metadata: { - environment: string; - folderId: string; - folderName: string; - folderPath: string; - } + type: EventType.CREATE_FOLDER; + metadata: { + environment: string; + folderId: string; + folderName: string; + folderPath: string; + }; } interface UpdateFolderEvent { - type: EventType.UPDATE_FOLDER; - metadata: { - environment: string; - folderId: string; - oldFolderName: string; - newFolderName: string; - folderPath: string; - } + type: EventType.UPDATE_FOLDER; + metadata: { + environment: string; + folderId: string; + oldFolderName: string; + newFolderName: string; + folderPath: string; + }; } interface DeleteFolderEvent { - type: EventType.DELETE_FOLDER; - metadata: { - environment: string; - folderId: string; - folderName: string; - folderPath: string; - } + type: EventType.DELETE_FOLDER; + metadata: { + environment: string; + folderId: string; + folderName: string; + folderPath: string; + }; } interface CreateWebhookEvent { - type: EventType.CREATE_WEBHOOK, - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - } + type: EventType.CREATE_WEBHOOK; + metadata: { + webhookId: string; + environment: string; + secretPath: string; + webhookUrl: string; + isDisabled: boolean; + }; } interface UpdateWebhookStatusEvent { - type: EventType.UPDATE_WEBHOOK_STATUS, - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - } + type: EventType.UPDATE_WEBHOOK_STATUS; + metadata: { + webhookId: string; + environment: string; + secretPath: string; + webhookUrl: string; + isDisabled: boolean; + }; } interface DeleteWebhookEvent { - type: EventType.DELETE_WEBHOOK, - metadata: { - webhookId: string; - environment: string; - secretPath: string; - webhookUrl: string; - isDisabled: boolean; - } + type: EventType.DELETE_WEBHOOK; + metadata: { + webhookId: string; + environment: string; + secretPath: string; + webhookUrl: string; + isDisabled: boolean; + }; } interface GetSecretImportsEvent { - type: EventType.GET_SECRET_IMPORTS, - metadata: { - environment: string; - secretImportId: string; - folderId: string; - numberOfImports: number; - } + type: EventType.GET_SECRET_IMPORTS; + metadata: { + environment: string; + secretImportId: string; + folderId: string; + numberOfImports: number; + }; } interface CreateSecretImportEvent { - type: EventType.CREATE_SECRET_IMPORT, - metadata: { - secretImportId: string; - folderId: string; - importFromEnvironment: string; - importFromSecretPath: string; - importToEnvironment: string; - importToSecretPath: string; - } + type: EventType.CREATE_SECRET_IMPORT; + metadata: { + secretImportId: string; + folderId: string; + importFromEnvironment: string; + importFromSecretPath: string; + importToEnvironment: string; + importToSecretPath: string; + }; } interface UpdateSecretImportEvent { - type: EventType.UPDATE_SECRET_IMPORT, - metadata: { - secretImportId: string; - folderId: string; - importToEnvironment: string; - importToSecretPath: string; - orderBefore: { - environment: string; - secretPath: string; - }[], - orderAfter: { - environment: string; - secretPath: string; - }[] - } + type: EventType.UPDATE_SECRET_IMPORT; + metadata: { + secretImportId: string; + folderId: string; + importToEnvironment: string; + importToSecretPath: string; + orderBefore: { + environment: string; + secretPath: string; + }[]; + orderAfter: { + environment: string; + secretPath: string; + }[]; + }; } interface DeleteSecretImportEvent { - type: EventType.DELETE_SECRET_IMPORT, - metadata: { - secretImportId: string; - folderId: string; - importFromEnvironment: string; - importFromSecretPath: string; - importToEnvironment: string; - importToSecretPath: string; - } + type: EventType.DELETE_SECRET_IMPORT; + metadata: { + secretImportId: string; + folderId: string; + importFromEnvironment: string; + importFromSecretPath: string; + importToEnvironment: string; + importToSecretPath: string; + }; } interface UpdateUserRole { - type: EventType.UPDATE_USER_WORKSPACE_ROLE, - metadata: { - userId: string; - email: string; - oldRole: string; - newRole: string; - } + type: EventType.UPDATE_USER_WORKSPACE_ROLE; + metadata: { + userId: string; + email: string; + oldRole: string; + newRole: string; + }; } interface UpdateUserDeniedPermissions { @@ -367,37 +437,82 @@ interface UpdateUserDeniedPermissions { }[] } } +interface SecretApprovalMerge { + type: EventType.SECRET_APPROVAL_MERGED; + metadata: { + mergedBy: string; + secretApprovalRequestSlug: string; + secretApprovalRequestId: string; + }; +} -export type Event = - | GetSecretsEvent - | GetSecretEvent - | CreateSecretEvent - | UpdateSecretEvent - | DeleteSecretEvent - | GetWorkspaceKeyEvent - | AuthorizeIntegrationEvent - | UnauthorizeIntegrationEvent - | CreateIntegrationEvent - | DeleteIntegrationEvent - | AddTrustedIPEvent - | UpdateTrustedIPEvent - | DeleteTrustedIPEvent - | CreateServiceTokenEvent - | DeleteServiceTokenEvent - | CreateEnvironmentEvent - | UpdateEnvironmentEvent - | DeleteEnvironmentEvent - | AddWorkspaceMemberEvent - | RemoveWorkspaceMemberEvent - | CreateFolderEvent - | UpdateFolderEvent - | DeleteFolderEvent - | CreateWebhookEvent - | UpdateWebhookStatusEvent - | DeleteWebhookEvent - | GetSecretImportsEvent - | CreateSecretImportEvent - | UpdateSecretImportEvent - | DeleteSecretImportEvent - | UpdateUserRole - | UpdateUserDeniedPermissions; \ No newline at end of file +interface SecretApprovalClosed { + type: EventType.SECRET_APPROVAL_CLOSED; + metadata: { + closedBy: string; + secretApprovalRequestSlug: string; + secretApprovalRequestId: string; + }; +} + +interface SecretApprovalReopened { + type: EventType.SECRET_APPROVAL_REOPENED; + metadata: { + reopenedBy: string; + secretApprovalRequestSlug: string; + secretApprovalRequestId: string; + }; +} + +interface SecretApprovalRequest { + type: EventType.SECRET_APPROVAL_REQUEST; + metadata: { + committedBy: string; + secretApprovalRequestSlug: string; + secretApprovalRequestId: string; + }; +} + +export type Event = + | GetSecretsEvent + | GetSecretEvent + | CreateSecretEvent + | CreateSecretBatchEvent + | UpdateSecretEvent + | UpdateSecretBatchEvent + | DeleteSecretEvent + | DeleteSecretBatchEvent + | GetWorkspaceKeyEvent + | AuthorizeIntegrationEvent + | UnauthorizeIntegrationEvent + | CreateIntegrationEvent + | DeleteIntegrationEvent + | AddTrustedIPEvent + | UpdateTrustedIPEvent + | DeleteTrustedIPEvent + | CreateServiceTokenEvent + | DeleteServiceTokenEvent + | CreateServiceTokenV3Event + | UpdateServiceTokenV3Event + | DeleteServiceTokenV3Event + | CreateEnvironmentEvent + | UpdateEnvironmentEvent + | DeleteEnvironmentEvent + | AddWorkspaceMemberEvent + | RemoveWorkspaceMemberEvent + | CreateFolderEvent + | UpdateFolderEvent + | DeleteFolderEvent + | CreateWebhookEvent + | UpdateWebhookStatusEvent + | DeleteWebhookEvent + | GetSecretImportsEvent + | CreateSecretImportEvent + | UpdateSecretImportEvent + | DeleteSecretImportEvent + | UpdateUserRole + | UpdateUserDeniedPermissions + | SecretApprovalMerge + | SecretApprovalClosed + | SecretApprovalRequest + | SecretApprovalReopened; diff --git a/backend/src/ee/models/gitAppInstallationSession.ts b/backend/src/ee/models/gitAppInstallationSession.ts index a78629186..0cdf8df3c 100644 --- a/backend/src/ee/models/gitAppInstallationSession.ts +++ b/backend/src/ee/models/gitAppInstallationSession.ts @@ -29,6 +29,4 @@ const gitAppInstallationSession = new Schema({ }); -const GitAppInstallationSession = model("git_app_installation_session", gitAppInstallationSession); - -export default GitAppInstallationSession; +export const GitAppInstallationSession = model("git_app_installation_session", gitAppInstallationSession); \ No newline at end of file diff --git a/backend/src/ee/models/gitAppOrganizationInstallation.ts b/backend/src/ee/models/gitAppOrganizationInstallation.ts index e742c0cf1..4ce55b0cb 100644 --- a/backend/src/ee/models/gitAppOrganizationInstallation.ts +++ b/backend/src/ee/models/gitAppOrganizationInstallation.ts @@ -26,6 +26,4 @@ const gitAppOrganizationInstallation = new Schema({ }); -const GitAppOrganizationInstallation = model("git_app_organization_installation", gitAppOrganizationInstallation); - -export default GitAppOrganizationInstallation; +export const GitAppOrganizationInstallation = model("git_app_organization_installation", gitAppOrganizationInstallation); \ No newline at end of file diff --git a/backend/src/ee/models/gitRisks.ts b/backend/src/ee/models/gitRisks.ts index 0fd1ffd38..8d3f59208 100644 --- a/backend/src/ee/models/gitRisks.ts +++ b/backend/src/ee/models/gitRisks.ts @@ -5,7 +5,7 @@ export const STATUS_RESOLVED_REVOKED = "RESOLVED_REVOKED"; export const STATUS_RESOLVED_NOT_REVOKED = "RESOLVED_NOT_REVOKED"; export const STATUS_UNRESOLVED = "UNRESOLVED"; -export type GitRisks = { +export type IGitRisks = { id: string; description: string; startLine: string; @@ -42,7 +42,7 @@ export type GitRisks = { organization: Schema.Types.ObjectId, } -const gitRisks = new Schema({ +const gitRisks = new Schema({ id: { type: String, }, @@ -147,6 +147,4 @@ const gitRisks = new Schema({ } }, { timestamps: true }); -const GitRisks = model("GitRisks", gitRisks); - -export default GitRisks; +export const GitRisks = model("GitRisks", gitRisks); \ No newline at end of file diff --git a/backend/src/ee/models/index.ts b/backend/src/ee/models/index.ts index 6e9ec34a9..74abad3e4 100644 --- a/backend/src/ee/models/index.ts +++ b/backend/src/ee/models/index.ts @@ -2,6 +2,7 @@ export * from "./secretSnapshot"; export * from "./secretVersion"; export * from "./folderVersion"; export * from "./log"; +export * from "./role"; export * from "./action"; export * from "./ssoConfig"; export * from "./trustedIp"; @@ -9,3 +10,5 @@ export * from "./auditLog"; export * from "./gitRisks"; export * from "./gitAppOrganizationInstallation"; export * from "./gitAppInstallationSession"; +export * from "./secretApprovalPolicy"; +export * from "./secretApprovalRequest"; diff --git a/backend/src/ee/models/role.ts b/backend/src/ee/models/role.ts index 643acedcd..d3de1d3ae 100644 --- a/backend/src/ee/models/role.ts +++ b/backend/src/ee/models/role.ts @@ -50,6 +50,4 @@ const roleSchema = new Schema( roleSchema.index({ organization: 1, workspace: 1 }); -const Role = model("Role", roleSchema); - -export default Role; +export const Role = model("Role", roleSchema); \ No newline at end of file diff --git a/backend/src/ee/models/secretApprovalPolicy.ts b/backend/src/ee/models/secretApprovalPolicy.ts new file mode 100644 index 000000000..376b541c7 --- /dev/null +++ b/backend/src/ee/models/secretApprovalPolicy.ts @@ -0,0 +1,51 @@ +import { Schema, Types, model } from "mongoose"; + +export interface ISecretApprovalPolicy { + _id: Types.ObjectId; + workspace: Types.ObjectId; + name: string; + environment: string; + secretPath?: string; + approvers: Types.ObjectId[]; + approvals: number; +} + +const secretApprovalPolicySchema = new Schema( + { + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true + }, + approvers: [ + { + // user associated with the personal secret + type: Schema.Types.ObjectId, + ref: "Membership" + } + ], + name: { + type: String + }, + environment: { + type: String, + required: true + }, + secretPath: { + type: String, + required: false + }, + approvals: { + type: Number, + default: 1 + } + }, + { + timestamps: true + } +); + +export const SecretApprovalPolicy = model( + "SecretApprovalPolicy", + secretApprovalPolicySchema +); diff --git a/backend/src/ee/models/secretApprovalRequest.ts b/backend/src/ee/models/secretApprovalRequest.ts new file mode 100644 index 000000000..24e8af39a --- /dev/null +++ b/backend/src/ee/models/secretApprovalRequest.ts @@ -0,0 +1,203 @@ +import { Schema, Types, model } from "mongoose"; +import { customAlphabet } from "nanoid"; +import { + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_BASE64, + ENCODING_SCHEME_UTF8 +} from "../../variables"; + +export enum ApprovalStatus { + PENDING = "pending", + APPROVED = "approved", + REJECTED = "rejected" +} + +export enum CommitType { + DELETE = "delete", + UPDATE = "update", + CREATE = "create" +} + +const SLUG_ALPHABETS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const nanoId = customAlphabet(SLUG_ALPHABETS, 10); + +export interface ISecretApprovalSecChange { + _id: Types.ObjectId; + version: number; + secretBlindIndex?: string; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentIV?: string; + secretCommentTag?: string; + secretCommentCiphertext?: string; + skipMultilineEncoding?: boolean; + algorithm?: "aes-256-gcm"; + keyEncoding?: "utf8" | "base64"; + tags?: string[]; +} + +export type ISecretCommits = Array< + | { + newVersion: ISecretApprovalSecChange; + op: CommitType.CREATE; + } + | { + // secret is recorded to get the latest version, we can keep ref to secret for pulling change as it will also get changed + // on merge + secretVersion: J; + secret: T; + newVersion: Partial> & { _id: Types.ObjectId }; + op: CommitType.UPDATE; + } + | { + secret: T; + secretVersion: J; + op: CommitType.DELETE; + } +>; +export interface ISecretApprovalRequest { + _id: Types.ObjectId; + committer: Types.ObjectId; + slug: string; + statusChangeBy: Types.ObjectId; + reviewers: { + member: Types.ObjectId; + status: ApprovalStatus; + }[]; + workspace: Types.ObjectId; + environment: string; + folderId: string; + hasMerged: boolean; + status: "open" | "close"; + policy: Types.ObjectId; + commits: ISecretCommits; + conflicts: Array<{ secretId: string; op: CommitType }>; +} + +const secretApprovalSecretChangeSchema = new Schema({ + version: { + type: Number, + default: 1, + required: true + }, + secretBlindIndex: { + type: String, + select: false + }, + secretKeyCiphertext: { + type: String, + required: true + }, + secretKeyIV: { + type: String, // symmetric + required: true + }, + secretKeyTag: { + type: String, // symmetric + required: true + }, + secretValueCiphertext: { + type: String, + required: true + }, + secretValueIV: { + type: String, // symmetric + required: true + }, + secretValueTag: { + type: String, // symmetric + required: true + }, + skipMultilineEncoding: { + type: Boolean, + required: false + }, + algorithm: { + // the encryption algorithm used + type: String, + enum: [ALGORITHM_AES_256_GCM], + required: true, + default: ALGORITHM_AES_256_GCM + }, + keyEncoding: { + type: String, + enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64], + required: true, + default: ENCODING_SCHEME_UTF8 + }, + tags: { + ref: "Tag", + type: [Schema.Types.ObjectId], + default: [] + } +}); + +const secretApprovalRequestSchema = new Schema( + { + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true + }, + environment: { + type: String, + required: true + }, + folderId: { + type: String, + required: true, + default: "root" + }, + slug: { + type: String, + default: () => nanoId() + }, + reviewers: { + type: [ + { + member: { + // user associated with the personal secret + type: Schema.Types.ObjectId, + ref: "Membership" + }, + status: { type: String, enum: ApprovalStatus, default: ApprovalStatus.PENDING } + } + ], + default: [] + }, + policy: { type: Schema.Types.ObjectId, ref: "SecretApprovalPolicy" }, + hasMerged: { type: Boolean, default: false }, + status: { type: String, enum: ["close", "open"], default: "open" }, + committer: { type: Schema.Types.ObjectId, ref: "Membership" }, + statusChangeBy: { type: Schema.Types.ObjectId, ref: "Membership" }, + commits: [ + { + secret: { type: Types.ObjectId, ref: "Secret" }, + newVersion: secretApprovalSecretChangeSchema, + secretVersion: { type: Types.ObjectId, ref: "SecretVersion" }, + op: { type: String, enum: [CommitType], required: true } + } + ], + conflicts: { + type: [ + { + secretId: { type: String, required: true }, + op: { type: String, enum: [CommitType], required: true } + } + ], + default: [] + } + }, + { + timestamps: true + } +); + +export const SecretApprovalRequest = model( + "SecretApprovalRequest", + secretApprovalRequestSchema +); diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index 84174ac87..11ffa79ab 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -4,7 +4,7 @@ import { ENCODING_SCHEME_BASE64, ENCODING_SCHEME_UTF8, SECRET_PERSONAL, - SECRET_SHARED, + SECRET_SHARED } from "../../variables"; export interface ISecretVersion { @@ -23,6 +23,7 @@ export interface ISecretVersion { secretValueCiphertext: string; secretValueIV: string; secretValueTag: string; + skipMultilineEncoding?: boolean; algorithm: "aes-256-gcm"; keyEncoding: "utf8" | "base64"; createdAt: string; @@ -36,95 +37,96 @@ const secretVersionSchema = new Schema( // could be deleted type: Schema.Types.ObjectId, ref: "Secret", - required: true, + required: true }, version: { type: Number, default: 1, - required: true, + required: true }, workspace: { type: Schema.Types.ObjectId, ref: "Workspace", - required: true, + required: true }, type: { type: String, enum: [SECRET_SHARED, SECRET_PERSONAL], - required: true, + required: true }, user: { // user associated with the personal secret type: Schema.Types.ObjectId, - ref: "User", + ref: "User" }, environment: { type: String, - required: true, + required: true }, isDeleted: { // consider removing field type: Boolean, default: false, - required: true, + required: true }, secretBlindIndex: { type: String, - select: false, + select: false }, secretKeyCiphertext: { type: String, - required: true, + required: true }, secretKeyIV: { type: String, // symmetric - required: true, + required: true }, secretKeyTag: { type: String, // symmetric - required: true, + required: true }, secretValueCiphertext: { type: String, - required: true, + required: true }, secretValueIV: { type: String, // symmetric - required: true, + required: true }, secretValueTag: { type: String, // symmetric - required: true, + required: true + }, + skipMultilineEncoding: { + type: Boolean, + required: false }, algorithm: { // the encryption algorithm used type: String, enum: [ALGORITHM_AES_256_GCM], required: true, - default: ALGORITHM_AES_256_GCM, + default: ALGORITHM_AES_256_GCM }, keyEncoding: { type: String, enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64], required: true, - default: ENCODING_SCHEME_UTF8, + default: ENCODING_SCHEME_UTF8 }, folder: { type: String, - required: true, + required: true }, tags: { ref: "Tag", type: [Schema.Types.ObjectId], - default: [], + default: [] } }, { - timestamps: true, + timestamps: true } ); -export const SecretVersion = model( - "SecretVersion", - secretVersionSchema -); \ No newline at end of file +export const SecretVersion = model("SecretVersion", secretVersionSchema); diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 9b8d1bea3..34477cf72 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -8,6 +8,8 @@ import action from "./action"; import cloudProducts from "./cloudProducts"; import secretScanning from "./secretScanning"; import roles from "./role"; +import secretApprovalPolicy from "./secretApprovalPolicy"; +import secretApprovalRequest from "./secretApprovalRequest"; export { secret, @@ -19,5 +21,7 @@ export { action, cloudProducts, secretScanning, - roles + roles, + secretApprovalPolicy, + secretApprovalRequest }; diff --git a/backend/src/ee/routes/v1/secretApprovalPolicy.ts b/backend/src/ee/routes/v1/secretApprovalPolicy.ts new file mode 100644 index 000000000..c4b082286 --- /dev/null +++ b/backend/src/ee/routes/v1/secretApprovalPolicy.ts @@ -0,0 +1,47 @@ +import express from "express"; +const router = express.Router(); +import { requireAuth } from "../../../middleware"; +import { secretApprovalPolicyController } from "../../controllers/v1"; +import { AuthMode } from "../../../variables"; + +router.get( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalPolicyController.getSecretApprovalPolicy +); + +router.get( + "/board", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalPolicyController.getSecretApprovalPolicyOfBoard +); + +router.post( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalPolicyController.createSecretApprovalPolicy +); + +router.patch( + "/:id", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalPolicyController.updateSecretApprovalPolicy +); + +router.delete( + "/:id", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalPolicyController.deleteSecretApprovalPolicy +); + +export default router; diff --git a/backend/src/ee/routes/v1/secretApprovalRequest.ts b/backend/src/ee/routes/v1/secretApprovalRequest.ts new file mode 100644 index 000000000..e78c67c96 --- /dev/null +++ b/backend/src/ee/routes/v1/secretApprovalRequest.ts @@ -0,0 +1,55 @@ +import express from "express"; +const router = express.Router(); +import { requireAuth } from "../../../middleware"; +import { secretApprovalRequestController } from "../../controllers/v1"; +import { AuthMode } from "../../../variables"; + +router.get( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalRequestController.getSecretApprovalRequests +); + +router.get( + "/count", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalRequestController.getSecretApprovalRequestCount +); + +router.get( + "/:id", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalRequestController.getSecretApprovalRequestDetails +); + +router.post( + "/:id/merge", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalRequestController.mergeSecretApprovalRequest +); + +router.post( + "/:id/review", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalRequestController.updateSecretApprovalReviewStatus +); + +router.post( + "/:id/status", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretApprovalRequestController.updateSecretApprovalRequestStatus +); + +export default router; diff --git a/backend/src/ee/routes/v1/sso.ts b/backend/src/ee/routes/v1/sso.ts index 564073aec..24f0d36a1 100644 --- a/backend/src/ee/routes/v1/sso.ts +++ b/backend/src/ee/routes/v1/sso.ts @@ -6,58 +6,23 @@ import { ssoController } from "../../controllers/v1"; import { authLimiter } from "../../../helpers/rateLimiter"; import { AuthMode } from "../../../variables"; -router.get("/redirect/google", authLimiter, (req, res, next) => { - passport.authenticate("google", { - scope: ["profile", "email"], - session: false, - ...(req.query.callback_port - ? { - state: req.query.callback_port as string - } - : {}) - })(req, res, next); -}); - router.get( - "/google", - passport.authenticate("google", { - failureRedirect: "/login/provider/error", - session: false - }), - ssoController.redirectSSO -); - -router.get("/redirect/github", authLimiter, (req, res, next) => { - passport.authenticate("github", { - session: false, - ...(req.query.callback_port - ? { - state: req.query.callback_port as string - } - : {}) - })(req, res, next); -}); - -router.get( - "/github", + "/redirect/saml2/:ssoIdentifier", authLimiter, - passport.authenticate("github", { - failureRedirect: "/login/provider/error", - session: false - }), - ssoController.redirectSSO + (req, res, next) => { + const options = { + failureRedirect: "/", + additionalParams: { + RelayState: JSON.stringify({ + spInitiated: true, + callbackPort: req.query.callback_port ?? "" + }) + }, + }; + passport.authenticate("saml", options)(req, res, next); + } ); -router.get("/redirect/saml2/:ssoIdentifier", authLimiter, (req, res, next) => { - const options = { - failureRedirect: "/", - additionalParams: { - RelayState: req.query.callback_port ?? "" - } - }; - passport.authenticate("saml", options)(req, res, next); -}); - router.post( "/saml2/:ssoIdentifier", passport.authenticate("saml", { diff --git a/backend/src/ee/routes/v3/index.ts b/backend/src/ee/routes/v3/index.ts new file mode 100644 index 000000000..7f75a2755 --- /dev/null +++ b/backend/src/ee/routes/v3/index.ts @@ -0,0 +1,5 @@ +import serviceTokenData from "./serviceTokenData"; + +export { + serviceTokenData +} \ No newline at end of file diff --git a/backend/src/ee/routes/v3/serviceTokenData.ts b/backend/src/ee/routes/v3/serviceTokenData.ts new file mode 100644 index 000000000..2f421c7ec --- /dev/null +++ b/backend/src/ee/routes/v3/serviceTokenData.ts @@ -0,0 +1,39 @@ +import express from "express"; +const router = express.Router(); +import { requireAuth } from "../../../middleware"; +import { AuthMode } from "../../../variables"; +import { serviceTokenDataController } from "../../controllers/v3"; + +router.get( + "/me/key", + requireAuth({ + acceptedAuthModes: [AuthMode.SERVICE_TOKEN_V3] + }), + serviceTokenDataController.getServiceTokenDataKey +); + +router.post( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + serviceTokenDataController.createServiceTokenData +); + +router.patch( + "/:serviceTokenDataId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + serviceTokenDataController.updateServiceTokenData +); + +router.delete( + "/:serviceTokenDataId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + serviceTokenDataController.deleteServiceTokenData +); + +export default router; \ No newline at end of file diff --git a/backend/src/ee/services/EELicenseService.ts b/backend/src/ee/services/EELicenseService.ts index 52d95c9d5..f403f55d6 100644 --- a/backend/src/ee/services/EELicenseService.ts +++ b/backend/src/ee/services/EELicenseService.ts @@ -1,12 +1,12 @@ import { Types } from "mongoose"; import * as Sentry from "@sentry/node"; import NodeCache from "node-cache"; -import { +import { getLicenseKey, getLicenseServerKey, getLicenseServerUrl, } from "../../config"; -import { +import { licenseKeyRequest, licenseServerKeyRequest, refreshLicenseKeyToken, @@ -37,6 +37,7 @@ interface FeatureSet { status: "incomplete" | "incomplete_expired" | "trialing" | "active" | "past_due" | "canceled" | "unpaid" | null; trial_end: number | null; has_used_trial: boolean; + secretApproval: boolean; } /** @@ -46,7 +47,7 @@ interface FeatureSet { * - Self-hosted enterprise: Fetch and update global feature set */ class EELicenseService { - + private readonly _isLicenseValid: boolean; // TODO: deprecate public instanceType: "self-hosted" | "enterprise-self-hosted" | "cloud" = "self-hosted"; @@ -64,7 +65,7 @@ class EELicenseService { secretVersioning: true, pitRecovery: false, ipAllowlisting: false, - rbac: true, + rbac: false, customRateLimits: false, customAlerts: false, auditLogs: false, @@ -72,18 +73,19 @@ class EELicenseService { samlSSO: false, status: null, trial_end: null, - has_used_trial: true + has_used_trial: true, + secretApproval: false } public localFeatureSet: NodeCache; - + constructor() { this._isLicenseValid = true; this.localFeatureSet = new NodeCache({ stdTTL: 60, }); } - + public async getPlan(organizationId: Types.ObjectId, workspaceId?: Types.ObjectId): Promise { try { if (this.instanceType === "cloud") { @@ -96,7 +98,7 @@ class EELicenseService { if (!organization) throw OrganizationNotFoundError(); let url = `${await getLicenseServerUrl()}/api/license-server/v1/customers/${organization.customerId}/cloud-plan`; - + if (workspaceId) { url += `?workspaceId=${workspaceId}`; } @@ -114,14 +116,14 @@ class EELicenseService { return this.globalFeatureSet; } - + public async refreshPlan(organizationId: Types.ObjectId, workspaceId?: Types.ObjectId) { if (this.instanceType === "cloud") { this.localFeatureSet.del(`${organizationId.toString()}-${workspaceId?.toString() ?? ""}`); await this.getPlan(organizationId, workspaceId); } } - + public async delPlan(organizationId: Types.ObjectId) { if (this.instanceType === "cloud") { this.localFeatureSet.del(`${organizationId.toString()}-`); @@ -136,23 +138,23 @@ class EELicenseService { if (licenseServerKey) { // license server key is present -> validate it const token = await refreshLicenseServerKeyToken() - + if (token) { this.instanceType = "cloud"; } - + return; } - + if (licenseKey) { // license key is present -> validate it const token = await refreshLicenseKeyToken(); - + if (token) { const { data: { currentPlan } } = await licenseKeyRequest.get( `${await getLicenseServerUrl()}/api/license/v1/plan` ); - + this.globalFeatureSet = currentPlan; this.instanceType = "enterprise-self-hosted"; } diff --git a/backend/src/ee/services/GithubSecretScanning/GithubSecretScanningService.ts b/backend/src/ee/services/GithubSecretScanning/GithubSecretScanningService.ts index e9226b762..e0a45215c 100644 --- a/backend/src/ee/services/GithubSecretScanning/GithubSecretScanningService.ts +++ b/backend/src/ee/services/GithubSecretScanning/GithubSecretScanningService.ts @@ -1,6 +1,8 @@ import { Probot } from "probot"; -import GitRisks from "../../models/gitRisks"; -import GitAppOrganizationInstallation from "../../models/gitAppOrganizationInstallation"; +import { + GitAppOrganizationInstallation, + GitRisks +} from "../../models"; import { scanGithubPushEventForSecretLeaks } from "../../../queues/secret-scanning/githubScanPushEvent"; export default async (app: Probot) => { app.on("installation.deleted", async (context) => { diff --git a/backend/src/ee/services/ProjectRoleService.ts b/backend/src/ee/services/ProjectRoleService.ts index 51f4a26f1..867edb7e5 100644 --- a/backend/src/ee/services/ProjectRoleService.ts +++ b/backend/src/ee/services/ProjectRoleService.ts @@ -49,7 +49,8 @@ export enum ProjectPermissionSub { IpAllowList = "ip-allowlist", Workspace = "workspace", Secrets = "secrets", - SecretRollback = "secret-rollback" + SecretRollback = "secret-rollback", + SecretApproval = "secret-approval" } type SubjectFields = { @@ -72,6 +73,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] + | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] @@ -85,6 +87,11 @@ const buildAdminPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); @@ -154,6 +161,8 @@ const buildMemberPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); @@ -203,6 +212,7 @@ const buildViewerPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); diff --git a/backend/src/ee/services/SecretApprovalService.ts b/backend/src/ee/services/SecretApprovalService.ts new file mode 100644 index 000000000..58e2a9a20 --- /dev/null +++ b/backend/src/ee/services/SecretApprovalService.ts @@ -0,0 +1,656 @@ +import picomatch from "picomatch"; +import { Types } from "mongoose"; +import { + containsGlobPatterns, + generateSecretBlindIndexWithSaltHelper, + getSecretBlindIndexSaltHelper +} from "../../helpers/secrets"; +import { Folder, ISecret, Secret } from "../../models"; +import { ISecretApprovalPolicy, SecretApprovalPolicy } from "../models/secretApprovalPolicy"; +import { + CommitType, + ISecretApprovalRequest, + ISecretApprovalSecChange, + ISecretCommits, + SecretApprovalRequest +} from "../models/secretApprovalRequest"; +import { BadRequestError } from "../../utils/errors"; +import { getFolderByPath } from "../../services/FolderService"; +import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_UTF8, SECRET_SHARED } from "../../variables"; +import TelemetryService from "../../services/TelemetryService"; +import { EEAuditLogService, EESecretService } from "../services"; +import { EventType, SecretVersion } from "../models"; +import { AuthData } from "../../interfaces/middleware"; + +// if glob pattern score is 1, if not exist score is 0 and if its not both then its exact path meaning score 2 +const getPolicyScore = (policy: ISecretApprovalPolicy) => + policy.secretPath ? (containsGlobPatterns(policy.secretPath) ? 1 : 2) : 0; + +// this will fetch the policy that gets priority for an environment and secret path +export const getSecretPolicyOfBoard = async ( + workspaceId: string, + environment: string, + secretPath: string +) => { + const policies = await SecretApprovalPolicy.find({ workspace: workspaceId, environment }); + if (!policies) return; + // this will filter policies either without scoped to secret path or the one that matches with secret path + const policiesFilteredByPath = policies.filter( + ({ secretPath: policyPath }) => + !policyPath || picomatch.isMatch(secretPath, policyPath, { strictSlashes: false }) + ); + // now sort by priority. exact secret path gets first match followed by glob followed by just env scoped + // if that is tie get by first createdAt + const policiesByPriority = policiesFilteredByPath.sort( + (a, b) => getPolicyScore(b) - getPolicyScore(a) + ); + const finalPolicy = policiesByPriority.shift(); + return finalPolicy; +}; + +const getLatestSecretVersion = async (secretIds: Types.ObjectId[]) => { + const latestSecretVersions = await SecretVersion.aggregate([ + { + $match: { + secret: { + $in: secretIds + }, + type: SECRET_SHARED + } + }, + { + $sort: { version: -1 } + }, + { + $group: { + _id: "$secret", + version: { $max: "$version" }, + versionId: { $max: "$_id" }, // id of latest secret versionId + secret: { $first: "$$ROOT" } + } + } + ]).exec(); + // reduced with secret id and latest version as document + return latestSecretVersions.reduce( + (prev, curr) => ({ ...prev, [curr._id.toString()]: curr.secret }), + {} + ); +}; + +type TApprovalCreateSecret = Omit & { + secretName: string; +}; +type TApprovalUpdateSecret = Partial> & { + secretName: string; + newSecretName?: string; +}; + +type TGenerateSecretApprovalRequestArg = { + workspaceId: string; + environment: string; + secretPath: string; + policy: ISecretApprovalPolicy; + data: { + [CommitType.CREATE]?: TApprovalCreateSecret[]; + [CommitType.UPDATE]?: TApprovalUpdateSecret[]; + [CommitType.DELETE]?: { secretName: string }[]; + }; + commiterMembershipId: string; + authData: AuthData; +}; + +export const generateSecretApprovalRequest = async ({ + workspaceId, + environment, + secretPath, + policy, + data, + commiterMembershipId, + authData +}: TGenerateSecretApprovalRequestArg) => { + // calculate folder id from secret path + let folderId = "root"; + const rootFolder = await Folder.findOne({ workspace: workspaceId, environment }); + if (!rootFolder && secretPath !== "/") throw BadRequestError({ message: "Folder not found" }); + if (rootFolder) { + const folder = getFolderByPath(rootFolder.nodes, secretPath); + if (!folder) throw BadRequestError({ message: "Folder not found" }); + folderId = folder.id; + } + + // generate secret blindIndexes + const salt = await getSecretBlindIndexSaltHelper({ + workspaceId: new Types.ObjectId(workspaceId) + }); + const commits: ISecretApprovalRequest["commits"] = []; + + // ----- + // for created secret approval change + const createdSecret = data[CommitType.CREATE]; + if (createdSecret && createdSecret?.length) { + // validation checks whether secret exists for creation + const secretBlindIndexes = await Promise.all( + createdSecret.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[createdSecret[i].secretName] = curr; + return prev; + }, {}) + ); + // check created secret exists + const exists = await Secret.exists({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + createdSecret.map(({ secretName }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: SECRET_SHARED + })) + ) + .exec(); + if (exists) throw BadRequestError({ message: "Secrets already exist" }); + commits.push( + ...createdSecret.map((el) => ({ + op: CommitType.CREATE as const, + newVersion: { + ...el, + version: 0, + _id: new Types.ObjectId(), + secretBlindIndex: secretBlindIndexes[el.secretName] + } + })) + ); + } + + // ---- + // updated secrets approval change + const updatedSecret = data[CommitType.UPDATE]; + if (updatedSecret && updatedSecret?.length) { + // validation checks whether secret doesn't exists for update + const secretBlindIndexes = await Promise.all( + updatedSecret.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[updatedSecret[i].secretName] = curr; + return prev; + }, {}) + ); + // check update secret exists + const oldSecrets = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment, + type: SECRET_SHARED, + secretBlindIndex: { + $in: updatedSecret.map(({ secretName }) => secretBlindIndexes[secretName]) + } + }) + .select("+secretBlindIndex") + .lean() + .exec(); + if (oldSecrets.length !== updatedSecret.length) + throw BadRequestError({ message: "Secrets already exist" }); + + // finally check updating blindindex exist + const nameUpdatedSecrets = updatedSecret.filter(({ newSecretName }) => Boolean(newSecretName)); + const newSecretBlindIndexes = await Promise.all( + nameUpdatedSecrets.map(({ newSecretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName: newSecretName as string, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[nameUpdatedSecrets[i].secretName] = curr; + return prev; + }, {}) + ); + const doesAnySecretExistWithNewIndex = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment, + secretBlindIndex: { $in: Object.values(newSecretBlindIndexes) } + }); + if (doesAnySecretExistWithNewIndex.length) + throw BadRequestError({ message: "Secret with new name already exist" }); + + const oldSecretsGroupById = oldSecrets.reduce>( + (prev, curr) => ({ ...prev, [curr?.secretBlindIndex || ""]: curr }), + {} + ); + const latestSecretVersions = await getLatestSecretVersion( + updatedSecret.map((el) => oldSecretsGroupById[secretBlindIndexes[el.secretName]]._id) + ); + + commits.push( + ...updatedSecret.map((el) => { + const secretId = oldSecretsGroupById[secretBlindIndexes[el.secretName]]._id; + return { + op: CommitType.UPDATE as const, + secret: secretId, + secretVersion: latestSecretVersions[secretId.toString()]._id, + newVersion: { + ...el, + secretBlindIndex: newSecretBlindIndexes?.[el.secretName], + _id: new Types.ObjectId(), + version: oldSecretsGroupById[secretBlindIndexes[el.secretName]].version || 1 + } + }; + }) + ); + } + + // ----- + // deleted secrets + const deletedSecrets = data[CommitType.DELETE]; + if (deletedSecrets && deletedSecrets.length) { + const secretBlindIndexes = await Promise.all( + deletedSecrets.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[deletedSecrets[i].secretName] = curr; + return prev; + }, {}) + ); + + const secretsToDelete = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment, + type: SECRET_SHARED, + secretBlindIndex: { + $in: deletedSecrets.map(({ secretName }) => secretBlindIndexes[secretName]) + } + }) + .select({ secretBlindIndex: 1, _id: 1 }) + .lean() + .exec(); + if (secretsToDelete.length !== deletedSecrets.length) + throw BadRequestError({ message: "Deleted secrets not found" }); + + const oldSecretsGroupById = secretsToDelete.reduce>( + (prev, curr) => ({ ...prev, [curr?.secretBlindIndex || ""]: curr }), + {} + ); + const latestSecretVersions = await getLatestSecretVersion( + deletedSecrets.map((el) => oldSecretsGroupById[secretBlindIndexes[el.secretName]]._id) + ); + + commits.push( + ...deletedSecrets.map((el) => { + const secretId = oldSecretsGroupById[secretBlindIndexes[el.secretName]]._id; + return { + op: CommitType.DELETE as const, + secret: secretId, + secretVersion: latestSecretVersions[secretId.toString()] + }; + }) + ); + } + + const secretApprovalRequest = new SecretApprovalRequest({ + workspace: workspaceId, + environment, + folderId, + policy, + commits, + committer: commiterMembershipId + }); + await secretApprovalRequest.save(); + + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: commiterMembershipId, + secretApprovalRequestId: secretApprovalRequest._id.toString(), + secretApprovalRequestSlug: secretApprovalRequest.slug + } + }, + { + workspaceId: secretApprovalRequest.workspace + } + ); + + return secretApprovalRequest; +}; + +// validation for a merge conditions happen in another function in controller +export const performSecretApprovalRequestMerge = async ( + id: string, + authData: AuthData, + userMembershipId: string +) => { + const secretApprovalRequest = await SecretApprovalRequest.findById(id) + .populate<{ commits: ISecretCommits }>({ + path: "commits.secret", + select: "+secretBlindIndex", + populate: { + path: "tags" + } + }) + .select("+commits.newVersion.secretBlindIndex"); + if (!secretApprovalRequest) throw BadRequestError({ message: "Approval request not found" }); + + const workspaceId = secretApprovalRequest.workspace; + const environment = secretApprovalRequest.environment; + const folderId = secretApprovalRequest.folderId; + const postHogClient = await TelemetryService.getPostHogClient(); + const conflicts: Array<{ secretId: string; op: CommitType }> = []; + + const secretCreationCommits = secretApprovalRequest.commits.filter( + ({ op }) => op === CommitType.CREATE + ) as Array<{ op: CommitType.CREATE; newVersion: ISecretApprovalSecChange }>; + if (secretCreationCommits.length) { + // the created secrets already exist thus creation conflict ones + const conflictedSecrets = await Secret.find({ + workspace: workspaceId, + environment, + folder: folderId, + secretBlindIndex: { + $in: secretCreationCommits.map(({ newVersion }) => newVersion.secretBlindIndex) + } + }) + .select("+secretBlindIndex") + .lean(); + const conflictGroupByBlindIndex = conflictedSecrets.reduce>( + (prev, curr) => ({ ...prev, [curr.secretBlindIndex || ""]: true }), + {} + ); + const nonConflictSecrets = secretCreationCommits.filter( + ({ newVersion }) => !conflictGroupByBlindIndex[newVersion.secretBlindIndex || ""] + ); + secretCreationCommits + .filter(({ newVersion }) => conflictGroupByBlindIndex[newVersion.secretBlindIndex || ""]) + .forEach((el) => { + conflicts.push({ op: CommitType.CREATE, secretId: el.newVersion._id.toString() }); + }); + + // create secret + const newlyCreatedSecrets: ISecret[] = await Secret.insertMany( + nonConflictSecrets.map( + ({ + newVersion: { + secretKeyIV, + secretKeyTag, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretKeyCiphertext, + secretValueCiphertext, + secretCommentCiphertext, + skipMultilineEncoding, + secretBlindIndex, + algorithm, + keyEncoding, + tags + } + }) => ({ + version: 1, + workspace: new Types.ObjectId(workspaceId), + environment, + type: SECRET_SHARED, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + folder: folderId, + algorithm: algorithm || ALGORITHM_AES_256_GCM, + keyEncoding: keyEncoding || ENCODING_SCHEME_UTF8, + tags, + skipMultilineEncoding, + secretBlindIndex + }) + ) + ); + + await EESecretService.addSecretVersions({ + secretVersions: newlyCreatedSecrets.map( + (secret) => + new SecretVersion({ + secret: secret._id, + version: secret.version, + workspace: secret.workspace, + type: secret.type, + folder: folderId, + tags: secret.tags, + skipMultilineEncoding: secret?.skipMultilineEncoding, + environment: secret.environment, + isDeleted: false, + secretBlindIndex: secret.secretBlindIndex, + secretKeyCiphertext: secret.secretKeyCiphertext, + secretKeyIV: secret.secretKeyIV, + secretKeyTag: secret.secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + }) + ) + }); + } + + const secretUpdationCommits = secretApprovalRequest.commits.filter( + ({ op }) => op === CommitType.UPDATE + ) as Array<{ + op: CommitType.UPDATE; + newVersion: Partial> & { _id: Types.ObjectId }; + secret: ISecret; + }>; + if (secretUpdationCommits.length) { + const conflictedByNewBlindIndex = await Secret.find({ + workspace: workspaceId, + environment, + folder: folderId, + secretBlindIndex: { + $in: secretUpdationCommits + .map(({ newVersion }) => newVersion?.secretBlindIndex) + .filter(Boolean) + } + }) + .select("+secretBlindIndex") + .lean(); + const conflictGroupByBlindIndex = conflictedByNewBlindIndex.reduce>( + (prev, curr) => (curr?.secretBlindIndex ? { ...prev, [curr.secretBlindIndex]: true } : prev), + {} + ); + secretUpdationCommits + .filter( + ({ newVersion, secret }) => + (newVersion.secretBlindIndex && conflictGroupByBlindIndex[newVersion.secretBlindIndex]) || + !secret + ) + .forEach((el) => { + conflicts.push({ op: CommitType.UPDATE, secretId: el.newVersion._id.toString() }); + }); + + const nonConflictSecrets = secretUpdationCommits.filter( + ({ newVersion, secret }) => + Boolean(secret) && + (newVersion?.secretBlindIndex + ? !conflictGroupByBlindIndex[newVersion.secretBlindIndex] + : true) + ); + await Secret.bulkWrite( + // id and version are stripped off + nonConflictSecrets.map( + ({ + newVersion: { + secretKeyIV, + secretKeyTag, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretKeyCiphertext, + secretValueCiphertext, + secretCommentCiphertext, + skipMultilineEncoding, + secretBlindIndex, + tags + }, + secret + }) => ({ + updateOne: { + filter: { + workspace: new Types.ObjectId(workspaceId), + environment, + folder: folderId, + secretBlindIndex: secret.secretBlindIndex, + type: SECRET_SHARED + }, + update: { + $inc: { + version: 1 + }, + secretKeyIV, + secretKeyTag, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretKeyCiphertext, + secretValueCiphertext, + secretCommentCiphertext, + skipMultilineEncoding, + secretBlindIndex, + tags, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + } + } + }) + ) + ); + + await EESecretService.addSecretVersions({ + secretVersions: nonConflictSecrets.map(({ newVersion, secret }) => { + return new SecretVersion({ + secret: secret._id, + version: secret.version + 1, + workspace: workspaceId, + type: SECRET_SHARED, + folder: folderId, + environment, + isDeleted: false, + secretBlindIndex: newVersion?.secretBlindIndex ?? secret.secretBlindIndex, + secretKeyCiphertext: newVersion?.secretKeyCiphertext ?? secret.secretKeyCiphertext, + secretKeyIV: newVersion?.secretKeyIV ?? secret.secretKeyCiphertext, + secretKeyTag: newVersion?.secretKeyTag ?? secret.secretKeyTag, + secretValueCiphertext: newVersion?.secretValueCiphertext ?? secret.secretValueCiphertext, + secretValueIV: newVersion?.secretValueIV ?? secret.secretValueIV, + secretValueTag: newVersion?.secretValueTag ?? secret.secretValueTag, + tags: newVersion?.tags ?? secret.tags, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, + skipMultilineEncoding: newVersion?.skipMultilineEncoding ?? secret.skipMultilineEncoding + }); + }) + }); + } + + const secretDeletionCommits = secretApprovalRequest.commits.filter( + ({ op }) => op === CommitType.DELETE + ) as Array<{ + op: CommitType.DELETE; + secret: ISecret; + }>; + if (secretDeletionCommits.length) { + await Secret.deleteMany({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + secretDeletionCommits.map(({ secret: { secretBlindIndex } }) => ({ + secretBlindIndex, + type: { $in: ["shared", "personal"] } + })) + ) + .exec(); + + await EESecretService.markDeletedSecretVersions({ + secretIds: secretDeletionCommits.map(({ secret }) => secret._id) + }); + } + + const updatedSecretApproval = await SecretApprovalRequest.findByIdAndUpdate( + id, + { + conflicts, + hasMerged: true, + status: "close", + statusChangeBy: userMembershipId + }, + { new: true } + ); + + if (postHogClient) { + if (postHogClient) { + postHogClient.capture({ + event: "secrets merged", + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: secretApprovalRequest.commits.length, + environment, + workspaceId, + folderId, + channel: authData.userAgentType, + userAgent: authData.userAgent + } + }); + } + } + + await EESecretService.takeSecretSnapshot({ + workspaceId, + environment, + folderId + }); + + // question to team where to keep secretKey + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.SECRET_APPROVAL_MERGED, + metadata: { + mergedBy: userMembershipId, + secretApprovalRequestId: id, + secretApprovalRequestSlug: secretApprovalRequest.slug + } + }, + { + workspaceId + } + ); + + return updatedSecretApproval; +}; diff --git a/backend/src/ee/validation/secretApproval.ts b/backend/src/ee/validation/secretApproval.ts new file mode 100644 index 000000000..999820e48 --- /dev/null +++ b/backend/src/ee/validation/secretApproval.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +export const GetSecretApprovalRuleList = z.object({ + query: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const GetSecretApprovalPolicyOfABoard = z.object({ + query: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim() + }) +}); + +export const CreateSecretApprovalRule = z.object({ + body: z + .object({ + workspaceId: z.string(), + name: z.string().optional(), + environment: z.string(), + secretPath: z.string().optional().nullable(), + approvers: z.string().array().min(1), + approvals: z.number().min(1).default(1) + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }) +}); + +export const UpdateSecretApprovalRule = z.object({ + params: z.object({ + id: z.string() + }), + body: z + .object({ + name: z.string().optional(), + approvers: z.string().array().min(1), + approvals: z.number().min(1).default(1), + secretPath: z.string().optional().nullable() + }) + .refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." + }) +}); + +export const DeleteSecretApprovalRule = z.object({ + params: z.object({ + id: z.string() + }) +}); diff --git a/backend/src/ee/validation/secretApprovalRequest.ts b/backend/src/ee/validation/secretApprovalRequest.ts new file mode 100644 index 000000000..07aff586c --- /dev/null +++ b/backend/src/ee/validation/secretApprovalRequest.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import { ApprovalStatus } from "../models/secretApprovalRequest"; + +export const getSecretApprovalRequests = z.object({ + query: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim().optional(), + committer: z.string().trim().optional(), + status: z.enum(["open", "close"]).optional(), + limit: z.coerce.number().default(20), + offset: z.coerce.number().default(0) + }) +}); + +export const getSecretApprovalRequestCount = z.object({ + query: z.object({ + workspaceId: z.string().trim() + }) +}); + +export const getSecretApprovalRequestDetails = z.object({ + params: z.object({ + id: z.string().trim() + }) +}); + +export const updateSecretApprovalReviewStatus = z.object({ + body: z.object({ + status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]) + }), + params: z.object({ + id: z.string().trim() + }) +}); + +export const mergeSecretApprovalRequest = z.object({ + params: z.object({ + id: z.string().trim() + }) +}); + +export const updateSecretApprovalRequestStatus = z.object({ + params: z.object({ + id: z.string().trim() + }), + body: z.object({ + status: z.enum(["open", "close"]) + }) +}); diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index 74094cba6..2a8a14d8f 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -7,6 +7,7 @@ import { ITokenVersion, IUser, ServiceTokenData, + ServiceTokenDataV3, TokenVersion, User, } from "../models"; @@ -18,17 +19,18 @@ import { UnauthorizedRequestError, } from "../utils/errors"; import { + getAuthSecret, getJwtAuthLifetime, - getJwtAuthSecret, - getJwtProviderAuthSecret, getJwtRefreshLifetime, - getJwtRefreshSecret, + getJwtServiceTokenSecret } from "../config"; import { - AuthMode + AuthMode, + AuthTokenType } from "../variables"; import { ServiceTokenAuthData, + ServiceTokenV3AuthData, UserAuthData } from "../interfaces/middleware"; @@ -47,6 +49,7 @@ export const validateAuthMode = ({ headers: { [key: string]: string | string[] | undefined }, acceptedAuthModes: AuthMode[] }) => { + const apiKey = headers["x-api-key"]; const authHeader = headers["authorization"]; @@ -65,6 +68,7 @@ export const validateAuthMode = ({ if (typeof authHeader === "string") { // case: treat request authentication type as via Authorization header (i.e. either JWT or service token) const [tokenType, tokenValue] = <[string, string]>authHeader.split(" ", 2) ?? [null, null] + if (tokenType === null) throw BadRequestError({ message: "Missing Authorization Header in the request header." }); if (tokenType.toLowerCase() !== "bearer") @@ -72,15 +76,21 @@ export const validateAuthMode = ({ if (tokenValue === null) throw BadRequestError({ message: "Missing Authorization Body in the request header." }); - switch (tokenValue.split(".", 1)[0]) { + const parts = tokenValue.split("."); + + switch (parts[0]) { case "st": authMode = AuthMode.SERVICE_TOKEN; + authTokenValue = tokenValue; + break; + case "stv3": + authMode = AuthMode.SERVICE_TOKEN_V3; + authTokenValue = parts.slice(1).join("."); break; default: authMode = AuthMode.JWT; + authTokenValue = tokenValue; } - - authTokenValue = tokenValue; } if (!authMode || !authTokenValue) throw BadRequestError({ message: "Missing valid Authorization or X-API-KEY in request header." }); @@ -107,9 +117,11 @@ export const getAuthUserPayload = async ({ authTokenValue: string; }): Promise => { const decodedToken = ( - jwt.verify(authTokenValue, await getJwtAuthSecret()) + jwt.verify(authTokenValue, await getAuthSecret()) ); + if (decodedToken.authTokenType !== AuthTokenType.ACCESS_TOKEN) throw UnauthorizedRequestError(); + const user = await User.findOne({ _id: new Types.ObjectId(decodedToken.userId), }).select("+publicKey +accessVersion"); @@ -146,11 +158,6 @@ export const getAuthUserPayload = async ({ userAgent: req.headers["user-agent"] ?? "", userAgentType: getUserAgentType(req.headers["user-agent"]) } - - // return ({ - // user, - // tokenVersionId: tokenVersion._id, // what to do with this? // move this out - // }); } /** @@ -211,8 +218,73 @@ export const getAuthSTDPayload = async ({ userAgent: req.headers["user-agent"] ?? "", userAgentType: getUserAgentType(req.headers["user-agent"]) } +} - // return serviceTokenDataToReturn; +/** + * Return service token data V3 payload corresponding to service token [authTokenValue] + * @param {Object} obj + * @param {String} obj.authTokenValue - service token value + * @returns {ServiceTokenData} serviceTokenData - service token data + */ + export const getAuthSTDV3Payload = async ({ + req, + authTokenValue, +}: { + req: Request, + authTokenValue: string; +}): Promise => { + const decodedToken = ( + jwt.verify(authTokenValue, await getJwtServiceTokenSecret()) + ); + + const serviceTokenData = await ServiceTokenDataV3.findOneAndUpdate( + { + _id: new Types.ObjectId(decodedToken._id), + isActive: true + }, + { + lastUsed: new Date(), + $inc: { usageCount: 1 } + }, + { + new: true + } + ); + + if (!serviceTokenData) { + throw UnauthorizedRequestError({ + message: "Failed to authenticate" + }); + } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { + // case: service token expired + await ServiceTokenDataV3.findByIdAndUpdate( + serviceTokenData._id, + { + isActive: false + }, + { + new: true + } + ); + + throw UnauthorizedRequestError({ + message: "Failed to authenticate", + }); + } + + return { + actor: { + type: ActorType.SERVICE_V3, + metadata: { + serviceId: serviceTokenData._id.toString(), + name: serviceTokenData.name + } + }, + authPayload: serviceTokenData, + ipAddress: req.realIP, + userAgent: req.headers["user-agent"] ?? "", + userAgentType: getUserAgentType(req.headers["user-agent"]) + } } /** @@ -326,22 +398,24 @@ export const issueAuthTokens = async ({ // issue tokens const token = createToken({ payload: { + authTokenType: AuthTokenType.ACCESS_TOKEN, userId, tokenVersionId: tokenVersion._id.toString(), accessVersion: tokenVersion.accessVersion, }, expiresIn: await getJwtAuthLifetime(), - secret: await getJwtAuthSecret(), + secret: await getAuthSecret(), }); const refreshToken = createToken({ payload: { + authTokenType: AuthTokenType.REFRESH_TOKEN, userId, tokenVersionId: tokenVersion._id.toString(), refreshVersion: tokenVersion.refreshVersion, }, expiresIn: await getJwtRefreshLifetime(), - secret: await getJwtRefreshSecret(), + secret: await getAuthSecret(), }); return { @@ -373,7 +447,7 @@ export const clearTokens = async (tokenVersionId: Types.ObjectId): Promise * bearer/auth, refresh, and temporary signup tokens * @param {Object} obj * @param {Object} obj.payload - payload of (JWT) token - * @param {String} obj.secret - (JWT) secret such as [JWT_AUTH_SECRET] + * @param {String} obj.secret - (JWT) secret such as [AUTH_SECRET] * @param {String} obj.expiresIn - string describing time span such as '10h' or '7d' */ export const createToken = ({ @@ -382,11 +456,15 @@ export const createToken = ({ secret, }: { payload: any; - expiresIn: string | number; + expiresIn?: string | number; secret: string; }) => { return jwt.sign(payload, secret, { - expiresIn, + ...( + (expiresIn !== undefined && expiresIn !== null) + ? { expiresIn } + : {} + ) }); }; @@ -397,13 +475,16 @@ export const validateProviderAuthToken = async ({ email: string; providerAuthToken?: string; }) => { + if (!providerAuthToken) { throw new Error("Invalid authentication request."); } const decodedToken = ( - jwt.verify(providerAuthToken, await getJwtProviderAuthSecret()) + jwt.verify(providerAuthToken, await getAuthSecret()) ); + + if (decodedToken.authTokenType !== AuthTokenType.PROVIDER_TOKEN) throw UnauthorizedRequestError(); if (decodedToken.email !== email) { throw new Error("Invalid authentication credentials.") diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts index b9b7aac51..275ca0988 100644 --- a/backend/src/helpers/bot.ts +++ b/backend/src/helpers/bot.ts @@ -103,7 +103,10 @@ export const getSecretsBotHelper = async ({ environment: string; secretPath: string; }) => { - const content: Record = {}; + const content: Record< + string, + { value: string; comment?: string; skipMultilineEncoding?: boolean } + > = {}; const key = await getKey({ workspaceId: workspaceId }); let folderId = "root"; @@ -165,6 +168,8 @@ export const getSecretsBotHelper = async ({ }); content[secretKey].comment = commentValue; } + + content[secretKey].skipMultilineEncoding = secret.skipMultilineEncoding; }); }); @@ -194,6 +199,8 @@ export const getSecretsBotHelper = async ({ }); content[secretKey].comment = commentValue; } + + content[secretKey].skipMultilineEncoding = secret.skipMultilineEncoding; }); await expandSecrets(workspaceId.toString(), key, content); diff --git a/backend/src/helpers/database.ts b/backend/src/helpers/database.ts index 4780be346..611e941f5 100644 --- a/backend/src/helpers/database.ts +++ b/backend/src/helpers/database.ts @@ -14,7 +14,7 @@ export const initDatabaseHelper = async ({ }) => { try { await mongoose.connect(mongoURL); - + // allow empty strings to pass the required validator mongoose.Schema.Types.String.checkRequired(v => typeof v === "string"); @@ -31,14 +31,10 @@ export const initDatabaseHelper = async ({ * Close database conection */ export const closeDatabaseHelper = async () => { - return Promise.all([ - new Promise((resolve) => { - if (mongoose.connection && mongoose.connection.readyState == 1) { - mongoose.connection.close() - .then(() => resolve("Database connection closed")); - } else { - resolve("Database connection already closed"); - } - }), - ]); -} \ No newline at end of file + if (mongoose.connection && mongoose.connection.readyState === 1) { + await mongoose.connection.close(); + return "Database connection closed"; + } else { + return "Database connection already closed"; + } +}; \ No newline at end of file diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 803c92aca..6b94d5916 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -15,6 +15,7 @@ import { IntegrationAuthMetadata } from "../models/integrationAuth/types"; interface Update { workspace: string; integration: string; + url?: string; teamId?: string; accountId?: string; metadata?: IntegrationAuthMetadata @@ -63,6 +64,10 @@ export const handleOAuthExchangeHelper = async ({ workspace: workspaceId, integration }; + + if (res.url) { + update.url = res.url; + } switch (integration) { case INTEGRATION_VERCEL: @@ -160,7 +165,7 @@ export const getIntegrationAuthAccessHelper = async ({ let accessId; let accessToken; const integrationAuth = await IntegrationAuth.findById(integrationAuthId).select( - "workspace integration +accessCiphertext +accessIV +accessTag +accessExpiresAt +refreshCiphertext +refreshIV +refreshTag +accessIdCiphertext +accessIdIV +accessIdTag metadata" + "workspace integration +accessCiphertext +accessIV +accessTag +accessExpiresAt +refreshCiphertext +refreshIV +refreshTag +accessIdCiphertext +accessIdIV +accessIdTag metadata teamId url" ); if (!integrationAuth) diff --git a/backend/src/helpers/organization.ts b/backend/src/helpers/organization.ts index 1ba5c7abc..2e5ee6ebb 100644 --- a/backend/src/helpers/organization.ts +++ b/backend/src/helpers/organization.ts @@ -1,5 +1,43 @@ -import { Types } from "mongoose"; -import { MembershipOrg, Organization } from "../models"; +import mongoose, { Types, mongo } from "mongoose"; +import { + Bot, + BotKey, + BotOrg, + Folder, + IncidentContactOrg, + Integration, + IntegrationAuth, + Key, + Membership, + MembershipOrg, + Organization, + Secret, + SecretBlindIndexData, + SecretImport, + ServiceToken, + ServiceTokenData, + ServiceTokenDataV3, + ServiceTokenDataV3Key, + Tag, + Webhook, + Workspace +} from "../models"; +import { + Action, + AuditLog, + FolderVersion, + GitAppInstallationSession, + GitAppOrganizationInstallation, + GitRisks, + Log, + Role, + SSOConfig, + SecretApprovalPolicy, + SecretApprovalRequest, + SecretSnapshot, + SecretVersion, + TrustedIP +} from "../ee/models"; import { ACCEPTED, } from "../variables"; @@ -17,6 +55,7 @@ import { import { createBotOrg } from "./botOrg"; +import { InternalServerError, ResourceNotFoundError } from "../utils/errors"; /** * Create an organization with name [name] @@ -65,6 +104,320 @@ export const createOrganization = async ({ return organization; }; +/** + * Delete organization with id [organizationId] + * @param {Object} obj + * @param {Types.ObjectId} obj.organizationId - id of organization to delete + * @returns + */ +export const deleteOrganization = async ({ + organizationId, + existingSession +}: { + organizationId: Types.ObjectId; + existingSession?: mongo.ClientSession; +}) => { + + let session; + + if (existingSession) { + session = existingSession; + } else { + session = await mongoose.startSession(); + session.startTransaction(); + } + + try { + const organization = await Organization.findByIdAndDelete( + organizationId, + { + session + } + ); + + if (!organization) throw ResourceNotFoundError(); + + await MembershipOrg.deleteMany({ + organization: organization._id + }, { + session + }); + + await BotOrg.deleteMany({ + organization: organization._id + }, { + session + }); + + await SSOConfig.deleteMany({ + organization: organization._id + }, { + session + }); + + await Role.deleteMany({ + organization: organization._id + }, { + session + }); + + await IncidentContactOrg.deleteMany({ + organization: organization._id + }, { + session + }); + + await GitRisks.deleteMany({ + organization: organization._id + }, { + session + }); + + await GitAppInstallationSession.deleteMany({ + organization: organization._id + }, { + session + }); + + await GitAppOrganizationInstallation.deleteMany({ + organization: organization._id + }, { + session + }); + + const workspaceIds = await Workspace.distinct("_id", { + organization: organization._id + }); + + await Workspace.deleteMany({ + organization: organization._id + }, { + session + }); + + await Membership.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await Key.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await Bot.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await BotKey.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await SecretBlindIndexData.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await Secret.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await SecretVersion.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await SecretSnapshot.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await SecretImport.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await Folder.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await FolderVersion.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await Webhook.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await TrustedIP.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await Tag.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await IntegrationAuth.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await Integration.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await ServiceToken.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await ServiceTokenData.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await ServiceTokenDataV3.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await ServiceTokenDataV3Key.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await AuditLog.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await Log.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await Action.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await SecretApprovalPolicy.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + await SecretApprovalRequest.deleteMany({ + workspace: { + $in: workspaceIds + } + }, { + session + }); + + if (organization.customerId) { + // delete from stripe here + await licenseServerKeyRequest.delete( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${organization.customerId}` + ); + } + + return organization; + } catch (err) { + if (!existingSession) { + await session.abortTransaction(); + } + throw InternalServerError({ + message: "Failed to delete organization" + }); + } finally { + if (!existingSession) { + await session.commitTransaction(); + session.endSession(); + } + } +} + /** * Update organization subscription quantity to reflect number of members in * the organization. diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index 3799dc1e7..82a98d1e9 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -1,20 +1,25 @@ import { Types } from "mongoose"; import { + CreateSecretBatchParams, CreateSecretParams, + DeleteSecretBatchParams, DeleteSecretParams, GetSecretParams, GetSecretsParams, + UpdateSecretBatchParams, UpdateSecretParams } from "../interfaces/services/SecretService"; import { Folder, ISecret, IServiceTokenData, + IServiceTokenDataV3, Secret, SecretBlindIndexData, ServiceTokenData, TFolderRootSchema } from "../models"; +import { Permission } from "../models/serviceTokenDataV3"; import { EventType, SecretVersion } from "../ee/models"; import { BadRequestError, @@ -48,7 +53,51 @@ import { getAuthDataPayloadIdObj, getAuthDataPayloadUserObj } from "../utils/aut import { getFolderByPath, getFolderIdFromServiceToken } from "../services/FolderService"; import picomatch from "picomatch"; import path from "path"; +import { getAnImportedSecret } from "../services/SecretImportService"; +/** + * Validate scope for service token v3 + * @param authPayload + * @param environment + * @param secretPath + * @returns + */ +export const isValidScopeV3 = ({ + authPayload, + environment, + secretPath, + requiredPermissions +}: { + authPayload: IServiceTokenDataV3; + environment: string; + secretPath: string; + requiredPermissions: Permission[]; +}) => { + const { scopes } = authPayload; + + const validScope = scopes.find( + (scope) => + picomatch.isMatch(secretPath, scope.secretPath, { strictSlashes: false }) && + scope.environment === environment + ); + + if ( + validScope && + !requiredPermissions.every((permission) => validScope.permissions.includes(permission)) + ) { + return false; + } + + return Boolean(validScope); +}; + +/** + * Validate scope for service token v2 + * @param authPayload + * @param environment + * @param secretPath + * @returns + */ export const isValidScope = ( authPayload: IServiceTokenData, environment: string, @@ -70,6 +119,8 @@ export function containsGlobPatterns(secretPath: string) { return globChars.some((char) => normalizedPath.includes(char)); } +const ERR_FOLDER_NOT_FOUND = BadRequestError({ message: "Folder not found" }); + /** * Returns an object containing secret [secret] but with its value, key, comment decrypted. * @@ -329,7 +380,8 @@ export const createSecretHelper = async ({ secretCommentIV, secretCommentTag, secretPath = "/", - metadata + metadata, + skipMultilineEncoding }: CreateSecretParams) => { const secretBlindIndex = await generateSecretBlindIndexHelper({ secretName, @@ -393,6 +445,7 @@ export const createSecretHelper = async ({ secretCommentCiphertext, secretCommentIV, secretCommentTag, + skipMultilineEncoding, folder: folderId, algorithm: ALGORITHM_AES_256_GCM, keyEncoding: ENCODING_SCHEME_UTF8, @@ -415,6 +468,7 @@ export const createSecretHelper = async ({ secretValueCiphertext, secretValueIV, secretValueTag, + skipMultilineEncoding, algorithm: ALGORITHM_AES_256_GCM, keyEncoding: ENCODING_SCHEME_UTF8 }); @@ -622,13 +676,14 @@ export const getSecretHelper = async ({ environment, type, authData, - secretPath = "/" + secretPath = "/", + include_imports = true }: GetSecretParams) => { const secretBlindIndex = await generateSecretBlindIndexHelper({ secretName, workspaceId: new Types.ObjectId(workspaceId) }); - let secret: ISecret | null = null; + let secret: ISecret | null | undefined = null; // if using service token filter towards the folderId by secretpath const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); @@ -655,6 +710,11 @@ export const getSecretHelper = async ({ }).lean(); } + if (!secret && include_imports) { + // if still no secret found search in imported secret and retreive + secret = await getAnImportedSecret(secretName, workspaceId.toString(), environment, folderId); + } + if (!secret) throw SecretNotFoundError(); // (EE) create (audit) log @@ -730,27 +790,70 @@ export const getSecretHelper = async ({ export const updateSecretHelper = async ({ secretName, workspaceId, + secretId, environment, type, authData, + newSecretName, + secretKeyTag, + secretKeyCiphertext, + secretKeyIV, secretValueCiphertext, secretValueIV, secretValueTag, - secretPath + secretPath, + tags, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + skipMultilineEncoding }: UpdateSecretParams) => { - const secretBlindIndex = await generateSecretBlindIndexHelper({ - secretName, + // get secret blind index salt + const salt = await getSecretBlindIndexSaltHelper({ workspaceId: new Types.ObjectId(workspaceId) }); + let oldSecretBlindIndex = await generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }); + + if (secretId) { + const secret = await Secret.findOne({ + workspace: workspaceId, + environment, + _id: secretId + }).select("secretBlindIndex"); + if (secret && secret.secretBlindIndex) oldSecretBlindIndex = secret.secretBlindIndex; + } + let secret: ISecret | null = null; const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); + let newSecretNameBlindIndex = undefined; + if (newSecretName) { + newSecretNameBlindIndex = await generateSecretBlindIndexWithSaltHelper({ + secretName: newSecretName, + salt + }); + const doesSecretAlreadyExist = await Secret.exists({ + secretBlindIndex: newSecretNameBlindIndex, + workspace: new Types.ObjectId(workspaceId), + environment, + folder: folderId, + type + }); + + if (doesSecretAlreadyExist) { + throw BadRequestError({ message: "Secret with the provided name already exist" }); + } + } + if (type === SECRET_SHARED) { // case: update shared secret secret = await Secret.findOneAndUpdate( { - secretBlindIndex, + secretBlindIndex: oldSecretBlindIndex, workspace: new Types.ObjectId(workspaceId), environment, folder: folderId, @@ -760,6 +863,15 @@ export const updateSecretHelper = async ({ secretValueCiphertext, secretValueIV, secretValueTag, + secretCommentIV, + secretCommentTag, + secretCommentCiphertext, + skipMultilineEncoding, + secretBlindIndex: newSecretNameBlindIndex, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + tags, $inc: { version: 1 } }, { @@ -771,7 +883,7 @@ export const updateSecretHelper = async ({ secret = await Secret.findOneAndUpdate( { - secretBlindIndex, + secretBlindIndex: oldSecretBlindIndex, workspace: new Types.ObjectId(workspaceId), environment, type, @@ -782,6 +894,12 @@ export const updateSecretHelper = async ({ secretValueCiphertext, secretValueIV, secretValueTag, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + tags, + skipMultilineEncoding, + secretBlindIndex: newSecretNameBlindIndex, $inc: { version: 1 } }, { @@ -798,16 +916,18 @@ export const updateSecretHelper = async ({ workspace: secret.workspace, folder: folderId, type, + tags, ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), environment: secret.environment, isDeleted: false, - secretBlindIndex, + secretBlindIndex: newSecretName ? newSecretNameBlindIndex : oldSecretBlindIndex, secretKeyCiphertext: secret.secretKeyCiphertext, secretKeyIV: secret.secretKeyIV, secretKeyTag: secret.secretKeyTag, secretValueCiphertext, secretValueIV, secretValueTag, + skipMultilineEncoding, algorithm: ALGORITHM_AES_256_GCM, keyEncoding: ENCODING_SCHEME_UTF8 }); @@ -896,12 +1016,22 @@ export const deleteSecretHelper = async ({ environment, type, authData, - secretPath = "/" + secretPath = "/", + // used for update corner case and blindIndex goes wrong way + secretId }: DeleteSecretParams) => { - const secretBlindIndex = await generateSecretBlindIndexHelper({ + let secretBlindIndex = await generateSecretBlindIndexHelper({ secretName, workspaceId: new Types.ObjectId(workspaceId) }); + if (secretId) { + const secret = await Secret.findOne({ + workspace: workspaceId, + environment, + _id: secretId + }).select("secretBlindIndex"); + if (secret && secret.secretBlindIndex) secretBlindIndex = secret.secretBlindIndex; + } const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); @@ -1091,6 +1221,7 @@ const recursivelyExpandSecret = async ( let interpolatedValue = interpolatedSec[key]; if (!interpolatedValue) { + // eslint-disable-next-line no-console console.error(`Couldn't find referenced value - ${key}`); return ""; } @@ -1140,7 +1271,7 @@ const formatMultiValueEnv = (val?: string) => { export const expandSecrets = async ( workspaceId: string, rootEncKey: string, - secrets: Record + secrets: Record ) => { const expandedSec: Record = {}; const interpolatedSec: Record = {}; @@ -1158,7 +1289,10 @@ export const expandSecrets = async ( for (const key of Object.keys(secrets)) { if (expandedSec?.[key]) { - secrets[key].value = formatMultiValueEnv(expandedSec[key]); + // should not do multi line encoding if user has set it to skip + secrets[key].value = secrets[key].skipMultilineEncoding + ? expandedSec[key] + : formatMultiValueEnv(expandedSec[key]); continue; } @@ -1173,8 +1307,522 @@ export const expandSecrets = async ( key ); - secrets[key].value = formatMultiValueEnv(expandedVal); + secrets[key].value = secrets[key].skipMultilineEncoding + ? expandedVal + : formatMultiValueEnv(expandedVal); } return secrets; }; + +export const createSecretBatchHelper = async ({ + secrets, + workspaceId, + authData, + secretPath, + environment +}: CreateSecretBatchParams) => { + let folderId = "root"; + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }); + + if (!folders && secretPath !== "/") throw ERR_FOLDER_NOT_FOUND; + if (folders) { + const folder = getFolderByPath(folders.nodes, secretPath); + if (!folder) throw ERR_FOLDER_NOT_FOUND; + folderId = folder.id; + } + + // get secret blind index salt + const salt = await getSecretBlindIndexSaltHelper({ + workspaceId: new Types.ObjectId(workspaceId) + }); + + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + secrets.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[secrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = secrets[i].secretName; + return prev; + }, {}) + ); + + const exists = await Secret.exists({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + secrets.map(({ secretName, type }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + })) + ) + .exec(); + + if (exists) + throw BadRequestError({ + message: "Failed to create secret that already exists" + }); + + // create secret + const newlyCreatedSecrets: ISecret[] = await Secret.insertMany( + secrets.map( + ({ + type, + secretName, + secretKeyIV, + metadata, + secretKeyTag, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretKeyCiphertext, + secretValueCiphertext, + secretCommentCiphertext, + skipMultilineEncoding + }) => ({ + version: 1, + workspace: new Types.ObjectId(workspaceId), + environment, + type, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + folder: folderId, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, + metadata, + skipMultilineEncoding, + secretBlindIndex: secretBlindIndexes[secretName], + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + }) + ) + ); + + await EESecretService.addSecretVersions({ + secretVersions: newlyCreatedSecrets.map( + (secret) => + new SecretVersion({ + secret: secret._id, + version: secret.version, + workspace: secret.workspace, + type: secret.type, + folder: folderId, + skipMultilineEncoding: secret?.skipMultilineEncoding, + ...(secret.type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), + environment: secret.environment, + isDeleted: false, + secretBlindIndex: secret.secretBlindIndex, + secretKeyCiphertext: secret.secretKeyCiphertext, + secretKeyIV: secret.secretKeyIV, + secretKeyTag: secret.secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + }) + ) + }); + + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.CREATE_SECRETS, + metadata: { + environment, + secretPath, + secrets: newlyCreatedSecrets.map(({ secretBlindIndex, version, _id }) => ({ + secretId: _id.toString(), + secretKey: secretBlindIndexToKey[secretBlindIndex || ""], + secretVersion: version + })) + } + }, + { + workspaceId + } + ); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId, + environment, + folderId + }); + + const postHogClient = await TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: "secrets added", + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: 1, + environment, + workspaceId, + folderId, + channel: authData.userAgentType, + userAgent: authData.userAgent + } + }); + } + + return newlyCreatedSecrets; +}; + +export const updateSecretBatchHelper = async ({ + workspaceId, + environment, + authData, + secretPath, + secrets +}: UpdateSecretBatchParams) => { + let folderId = "root"; + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }); + + if (!folders && secretPath !== "/") throw ERR_FOLDER_NOT_FOUND; + if (folders) { + const folder = getFolderByPath(folders.nodes, secretPath); + if (!folder) throw ERR_FOLDER_NOT_FOUND; + folderId = folder.id; + } + + // get secret blind index salt + const salt = await getSecretBlindIndexSaltHelper({ + workspaceId: new Types.ObjectId(workspaceId) + }); + + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + secrets.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[secrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = secrets[i].secretName; + return prev; + }, {}) + ); + + const secretsToBeUpdated = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .select("+secretBlindIndex") + .or( + secrets.map(({ secretName, type }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + })) + ) + .lean(); + + if (secretsToBeUpdated.length !== secrets.length) + throw BadRequestError({ message: "Some secrets not found" }); + + await Secret.bulkWrite( + secrets.map( + ({ + type, + secretName, + tags, + secretValueIV, + secretValueTag, + secretCommentIV, + secretCommentTag, + secretValueCiphertext, + secretCommentCiphertext, + skipMultilineEncoding + }) => ({ + updateOne: { + filter: { + workspace: new Types.ObjectId(workspaceId), + environment, + folder: folderId, + secretBlindIndex: secretBlindIndexes[secretName], + type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + }, + update: { + $inc: { + version: 1 + }, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, + tags, + skipMultilineEncoding + } + } + }) + ) + ); + + const secretsGroupedByBlindIndex = secretsToBeUpdated.reduce>( + (prev, curr) => { + if (curr.secretBlindIndex) prev[curr.secretBlindIndex] = curr; + return prev; + }, + {} + ); + + await EESecretService.addSecretVersions({ + secretVersions: secrets.map((secret) => { + const { + _id, + version, + workspace, + type, + secretBlindIndex, + secretKeyIV, + secretKeyTag, + secretKeyCiphertext, + skipMultilineEncoding + } = secretsGroupedByBlindIndex[secretBlindIndexes[secret.secretName]]; + + return new SecretVersion({ + secret: _id, + version: version + 1, + workspace: workspace, + type, + folder: folderId, + ...(secret.type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), + environment, + isDeleted: false, + secretBlindIndex: secretBlindIndex, + secretKeyCiphertext: secretKeyCiphertext, + secretKeyIV: secretKeyIV, + secretKeyTag: secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, + skipMultilineEncoding + }); + }) + }); + + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.UPDATE_SECRETS, + metadata: { + environment, + secretPath, + secrets: secretsToBeUpdated.map(({ _id, version, secretBlindIndex }) => ({ + secretId: _id.toString(), + secretKey: secretBlindIndexToKey[secretBlindIndex || ""], + secretVersion: version + 1 + })) + } + }, + { + workspaceId + } + ); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId, + environment, + folderId + }); + + const postHogClient = await TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: "secrets modified", + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: 1, + environment, + workspaceId, + folderId, + channel: authData.userAgentType, + userAgent: authData.userAgent + } + }); + } + + return; +}; + +export const deleteSecretBatchHelper = async ({ + workspaceId, + environment, + authData, + secretPath = "/", + secrets +}: DeleteSecretBatchParams) => { + let folderId = "root"; + const folders = await Folder.findOne({ + workspace: workspaceId, + environment + }); + + if (!folders && secretPath !== "/") throw ERR_FOLDER_NOT_FOUND; + if (folders) { + const folder = getFolderByPath(folders.nodes, secretPath); + if (!folder) throw ERR_FOLDER_NOT_FOUND; + folderId = folder.id; + } + + // get secret blind index salt + const salt = await getSecretBlindIndexSaltHelper({ + workspaceId: new Types.ObjectId(workspaceId) + }); + + const secretBlindIndexToKey: Record = {}; // used at audit log point + const secretBlindIndexes = await Promise.all( + secrets.map(({ secretName }) => + generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }) + ) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + prev[secrets[i].secretName] = curr; + secretBlindIndexToKey[curr] = secrets[i].secretName; + return prev; + }, {}) + ); + + const deletedSecrets = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + secrets.map(({ secretName, type }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: type === "shared" ? { $in: ["shared", "personal"] } : type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + })) + ) + .select({ secretBlindIndexes: 1 }) + .lean() + .exec(); + + await Secret.deleteMany({ + workspace: new Types.ObjectId(workspaceId), + folder: folderId, + environment + }) + .or( + secrets.map(({ secretName, type }) => ({ + secretBlindIndex: secretBlindIndexes[secretName], + type: type === "shared" ? { $in: ["shared", "personal"] } : type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + })) + ) + .exec(); + + await EESecretService.markDeletedSecretVersions({ + secretIds: deletedSecrets.map((secret) => secret._id) + }); + + const action = await EELogService.createAction({ + name: ACTION_DELETE_SECRETS, + ...getAuthDataPayloadIdObj(authData), + workspaceId, + secretIds: deletedSecrets.map((secret) => secret._id) + }); + + action && + (await EELogService.createLog({ + ...getAuthDataPayloadIdObj(authData), + workspaceId, + actions: [action], + channel: authData.userAgentType, + ipAddress: authData.ipAddress + })); + + await EEAuditLogService.createAuditLog( + authData, + { + type: EventType.DELETE_SECRETS, + metadata: { + environment, + secretPath, + secrets: deletedSecrets.map(({ _id, version, secretBlindIndex }) => ({ + secretId: _id.toString(), + secretKey: secretBlindIndexToKey[secretBlindIndex || ""], + secretVersion: version + })) + } + }, + { + workspaceId + } + ); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId, + environment, + folderId + }); + + const postHogClient = await TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: "secrets deleted", + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: secrets.length, + environment, + workspaceId, + folderId, + channel: authData.userAgentType, + userAgent: authData.userAgent + } + }); + } + + return { + secrets: deletedSecrets + }; +}; diff --git a/backend/src/helpers/user.ts b/backend/src/helpers/user.ts index 728c7d089..b78f471f1 100644 --- a/backend/src/helpers/user.ts +++ b/backend/src/helpers/user.ts @@ -1,5 +1,27 @@ -import { IUser, User } from "../models"; +import mongoose, { Types, mongo } from "mongoose"; +import { + APIKeyData, + BackupPrivateKey, + IUser, + Key, + Membership, + MembershipOrg, + TokenVersion, + User, + UserAction +} from "../models"; +import { + Action, + Log +} from "../ee/models"; import { sendMail } from "./nodemailer"; +import { + InternalServerError, + ResourceNotFoundError +} from "../utils/errors"; +import { ADMIN } from "../variables"; +import { deleteOrganization } from "../helpers/organization"; +import { deleteWorkspace } from "../helpers/workspace"; /** * Initialize a user under email [email] @@ -134,3 +156,207 @@ export const checkUserDevice = async ({ }); } }; + +/** + * Check that if we delete user with id [userId] then + * there won't be any admin-less organizations or projects + * @param {Object} obj + * @param {String} obj.userId - id of user to check deletion conditions for + */ +const checkDeleteUserConditions = async ({ + userId +}: { + userId: Types.ObjectId; +}) => { + const memberships = await Membership.find({ + user: userId + }); + + const membershipOrgs = await MembershipOrg.find({ + user: userId + }); + + // delete organizations where user is only member + for await (const membershipOrg of membershipOrgs) { + const orgMemberCount = await MembershipOrg.countDocuments({ + organization: membershipOrg.organization, + }); + + const otherOrgAdminCount = await MembershipOrg.countDocuments({ + organization: membershipOrg.organization, + user: { $ne: userId }, + role: ADMIN + }); + + if (orgMemberCount > 1 && otherOrgAdminCount === 0) { + throw InternalServerError({ + message: "Failed to delete account because an org would be admin-less" + }); + } + } + + // delete workspaces where user is only member + for await (const membership of memberships) { + const workspaceMemberCount = await Membership.countDocuments({ + workspace: membership.workspace + }); + + const otherWorkspaceAdminCount = await Membership.countDocuments({ + workspace: membership.workspace, + user: { $ne: userId }, + role: ADMIN + }); + + if (workspaceMemberCount > 1 && otherWorkspaceAdminCount === 0) { + throw InternalServerError({ + message: "Failed to delete account because a workspace would be admin-less" + }); + } + } +} + +/** + * Delete account with id [userId] + * @param {Object} obj + * @param {Types.ObjectId} obj.userId - id of user to delete + * @returns {User} user - deleted user + */ +export const deleteUser = async ({ + userId, + existingSession +}: { + userId: Types.ObjectId; + existingSession?: mongo.ClientSession; +}) => { + + let session; + + if (existingSession) { + session = existingSession; + } else { + session = await mongoose.startSession(); + session.startTransaction(); + } + + try { + const user = await User.findByIdAndDelete(userId, { + session + }); + + if (!user) throw ResourceNotFoundError(); + + await checkDeleteUserConditions({ + userId: user._id + }); + + await UserAction.deleteMany({ + user: user._id + }, { + session + }); + + await BackupPrivateKey.deleteMany({ + user: user._id + }, { + session + }); + + await APIKeyData.deleteMany({ + user: user._id + }, { + session + }); + + await Action.deleteMany({ + user: user._id + }, { + session + }); + + await Log.deleteMany({ + user: user._id + }, { + session + }); + + await TokenVersion.deleteMany({ + user: user._id + }); + + await Key.deleteMany({ + receiver: user._id + }, { + session + }); + + const membershipOrgs = await MembershipOrg.find({ + user: userId + }, null, { + session + }); + + // delete organizations where user is only member + for await (const membershipOrg of membershipOrgs) { + const memberCount = await MembershipOrg.countDocuments({ + organization: membershipOrg.organization + }); + + if (memberCount === 1) { + // organization only has 1 member (the current user) + + await deleteOrganization({ + organizationId: membershipOrg.organization, + existingSession: session + }); + } + } + + const memberships = await Membership.find({ + user: userId + }, null, { + session + }); + + // delete workspaces where user is only member + for await (const membership of memberships) { + const memberCount = await Membership.countDocuments({ + workspace: membership.workspace + }); + + if (memberCount === 1) { + // workspace only has 1 member (the current user) -> delete workspace + + await deleteWorkspace({ + workspaceId: membership.workspace, + existingSession: session + }); + } + } + + await MembershipOrg.deleteMany({ + user: userId + }, { + session + }); + + await Membership.deleteMany({ + user: userId + }, { + session + }); + + return user; + } catch (err) { + if (!existingSession) { + await session.abortTransaction(); + } + throw InternalServerError({ + message: "Failed to delete account" + }) + } finally { + if (!existingSession) { + await session.commitTransaction(); + session.endSession(); + } + } +} \ No newline at end of file diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts index b65a4543b..71d945e78 100644 --- a/backend/src/helpers/workspace.ts +++ b/backend/src/helpers/workspace.ts @@ -1,18 +1,42 @@ -import { Types } from "mongoose"; +import mongoose, { Types, mongo } from "mongoose"; import { Bot, + BotKey, + Folder, + Integration, + IntegrationAuth, Key, Membership, Secret, - Workspace, + SecretBlindIndexData, + SecretImport, + ServiceToken, + ServiceTokenData, + ServiceTokenDataV3, + ServiceTokenDataV3Key, + Tag, + Webhook, + Workspace } from "../models"; import { + Action, + AuditLog, + FolderVersion, IPType, + Log, + SecretApprovalPolicy, + SecretApprovalRequest, + SecretSnapshot, + SecretVersion, TrustedIP } from "../ee/models"; import { createBot } from "../helpers/bot"; import { EELicenseService } from "../ee/services"; import { SecretService } from "../services"; +import { + InternalServerError, + ResourceNotFoundError +} from "../utils/errors"; /** * Create a workspace with name [name] in organization with id [organizationId] @@ -77,18 +101,190 @@ export const createWorkspace = async ({ * @param {Object} obj * @param {String} obj.id - id of workspace to delete */ -export const deleteWorkspace = async ({ id }: { id: string }) => { - await Workspace.deleteOne({ _id: id }); - await Bot.deleteOne({ - workspace: id, - }); - await Membership.deleteMany({ - workspace: id, - }); - await Secret.deleteMany({ - workspace: id, - }); - await Key.deleteMany({ - workspace: id, - }); +export const deleteWorkspace = async ({ + workspaceId, + existingSession +}: { + workspaceId: Types.ObjectId; + existingSession?: mongo.ClientSession; +}) => { + + let session; + + if (existingSession) { + session = existingSession; + } else { + session = await mongoose.startSession(); + session.startTransaction(); + } + + try { + const workspace = await Workspace.findByIdAndDelete(workspaceId, { session }); + + if (!workspace) throw ResourceNotFoundError(); + + await Membership.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await Key.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await Bot.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await BotKey.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await SecretBlindIndexData.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await Secret.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await SecretVersion.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await SecretSnapshot.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await SecretImport.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await Folder.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await FolderVersion.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await Webhook.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await TrustedIP.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await Tag.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await IntegrationAuth.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await Integration.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await ServiceToken.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await ServiceTokenData.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await ServiceTokenDataV3.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await ServiceTokenDataV3Key.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await AuditLog.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await Log.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await Action.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await SecretApprovalPolicy.deleteMany({ + workspace: workspace._id + }, { + session + }); + + await SecretApprovalRequest.deleteMany({ + workspace: workspace._id + }, { + session + }); + + return workspace; + } catch (err) { + if (!existingSession) { + await session.abortTransaction(); + } + throw InternalServerError({ + message: "Failed to delete organization" + }); + } finally { + if (!existingSession) { + await session.commitTransaction(); + session.endSession(); + } + } }; diff --git a/backend/src/index.ts b/backend/src/index.ts index aa4440a55..08572d981 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -25,8 +25,11 @@ import { users as eeUsersRouter, workspace as eeWorkspaceRouter, roles as v1RoleRouter, + secretApprovalPolicy as v1SecretApprovalPolicy, + secretApprovalRequest as v1SecretApprovalRequest, secretScanning as v1SecretScanningRouter } from "./ee/routes/v1"; +import { serviceTokenData as v3ServiceTokenDataRouter } from "./ee/routes/v3"; import { auth as v1AuthRouter, bot as v1BotRouter, @@ -38,6 +41,7 @@ import { membership as v1MembershipRouter, organization as v1OrganizationRouter, password as v1PasswordRouter, + sso as v1SSORouter, secretImps as v1SecretImpsRouter, secret as v1SecretRouter, secretsFolder as v1SecretsFolder, @@ -54,7 +58,6 @@ import { organizations as v2OrganizationsRouter, secret as v2SecretRouter, // begin to phase out secrets as v2SecretsRouter, - serviceAccounts as v2ServiceAccountsRouter, serviceTokenData as v2ServiceTokenDataRouter, signup as v2SignupRouter, tags as v2TagsRouter, @@ -78,12 +81,15 @@ import { getSecretScanningPrivateKey, getSecretScanningWebhookProxy, getSecretScanningWebhookSecret, - getSiteURL + getSiteURL, } from "./config"; import { setup } from "./utils/setup"; import { syncSecretsToThirdPartyServices } from "./queues/integrations/syncSecretsToThirdPartyServices"; import { githubPushEventSecretScan } from "./queues/secret-scanning/githubScanPushEvent"; const SmeeClient = require("smee-client"); // eslint-disable-line +import path from "path"; + +let handler: null | any = null; const main = async () => { await setup(); @@ -144,6 +150,27 @@ const main = async () => { next(); }); + if ((await getNodeEnv()) === "production" && process.env.STANDALONE_BUILD === "true") { + const nextJsBuildPath = path.join(__dirname, "../frontend-build"); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-var-requires + const conf = require("../frontend-build/.next/required-server-files.json").config; + const NextServer = + // eslint-disable-next-line @typescript-eslint/no-var-requires + require("../frontend-build/node_modules/next/dist/server/next-server").default; + const nextApp = new NextServer({ + dev: false, + dir: nextJsBuildPath, + port: await getPort(), + conf, + hostname: "local", + customServer: false + }); + + handler = nextApp.getRequestHandler(); + } + // (EE) routes app.use("/api/v1/secret", eeSecretRouter); app.use("/api/v1/secret-snapshot", eeSecretSnapshotRouter); @@ -153,6 +180,7 @@ const main = async () => { app.use("/api/v1/organizations", eeOrganizationsRouter); app.use("/api/v1/sso", eeSSORouter); app.use("/api/v1/cloud-products", eeCloudProductsRouter); + app.use("/api/v3/service-token", v3ServiceTokenDataRouter); // v1 routes app.use("/api/v1/signup", v1SignupRouter); @@ -176,6 +204,9 @@ const main = async () => { app.use("/api/v1/webhooks", v1WebhooksRouter); app.use("/api/v1/secret-imports", v1SecretImpsRouter); app.use("/api/v1/roles", v1RoleRouter); + app.use("/api/v1/secret-approvals", v1SecretApprovalPolicy); + app.use("/api/v1/sso", v1SSORouter); + app.use("/api/v1/secret-approval-requests", v1SecretApprovalRequest); // v2 routes (improvements) app.use("/api/v2/signup", v2SignupRouter); @@ -188,7 +219,7 @@ const main = async () => { app.use("/api/v2/secret", v2SecretRouter); // deprecate app.use("/api/v2/secrets", v2SecretsRouter); // note: in the process of moving to v3/secrets app.use("/api/v2/service-token", v2ServiceTokenDataRouter); - app.use("/api/v2/service-accounts", v2ServiceAccountsRouter); // new + // app.use("/api/v2/service-accounts", v2ServiceAccountsRouter); // new // v3 routes (experimental) app.use("/api/v3/auth", v3AuthRouter); @@ -202,6 +233,12 @@ const main = async () => { // server status app.use("/api", healthCheck); + if (handler) { + app.all("*", (req, res) => { + return handler(req, res); + }); + } + //* Handle unrouted requests and respond with proper error message as well as status code app.use((req, res, next) => { if (res.headersSent) return next(); @@ -221,10 +258,24 @@ const main = async () => { // await createTestUserForDevelopment(); setUpHealthEndpoint(server); - server.on("close", async () => { + const serverCleanup = async () => { await DatabaseService.closeDatabase(); syncSecretsToThirdPartyServices.close(); githubPushEventSecretScan.close(); + + process.exit(0); + }; + + process.on("SIGINT", function () { + server.close(async () => { + await serverCleanup(); + }); + }); + + process.on("SIGTERM", function () { + server.close(async () => { + await serverCleanup(); + }); }); return server; diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index a97a9db09..37382e79e 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -423,6 +423,7 @@ const exchangeCodeGitlab = async ({ accessToken: res.access_token, refreshToken: res.refresh_token, accessExpiresAt, + url }; }; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index cb45a9d95..fe5885c9e 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -40,6 +40,8 @@ import { INTEGRATION_NETLIFY_API_URL, INTEGRATION_NORTHFLANK, INTEGRATION_NORTHFLANK_API_URL, + INTEGRATION_QOVERY, + INTEGRATION_QOVERY_API_URL, INTEGRATION_RAILWAY, INTEGRATION_RAILWAY_API_URL, INTEGRATION_RENDER, @@ -63,10 +65,10 @@ import sodium from "libsodium-wrappers"; import { standardRequest } from "../config/request"; const getSecretKeyValuePair = ( - secrets: Record + secrets: Record ) => - Object.keys(secrets).reduce>((prev, key) => { - if (secrets[key]) prev[key] = secrets[key]?.value || ""; + Object.keys(secrets).reduce>((prev, key) => { + prev[key] = secrets?.[key] === null ? null : secrets?.[key]?.value; return prev; }, {}); @@ -85,13 +87,15 @@ const syncSecrets = async ({ integrationAuth, secrets, accessId, - accessToken + accessToken, + appendices }: { integration: IIntegration; integrationAuth: IIntegrationAuth; secrets: Record; accessId: string | null; accessToken: string; + appendices?: { prefix: string, suffix: string }; }) => { switch (integration.integration) { case INTEGRATION_GCP_SECRET_MANAGER: @@ -151,7 +155,8 @@ const syncSecrets = async ({ await syncSecretsGitHub({ integration, secrets, - accessToken + accessToken, + appendices }); break; case INTEGRATION_GITLAB: @@ -214,6 +219,14 @@ const syncSecrets = async ({ break; case INTEGRATION_CHECKLY: await syncSecretsCheckly({ + integration, + secrets, + accessToken, + appendices + }); + break; + case INTEGRATION_QOVERY: + await syncSecretsQovery({ integration, secrets, accessToken @@ -316,40 +329,42 @@ const syncSecretsGCPSecretManager = async ({ name: string; createTime: string; } - + interface GCPSMListSecretsRes { secrets?: GCPSecret[]; totalSize?: number; nextPageToken?: string; } - + let gcpSecrets: GCPSecret[] = []; - + const pageSize = 100; let pageToken: string | undefined; let hasMorePages = true; - const filterParam = integration.metadata.secretGCPLabel - ? `?filter=labels.${integration.metadata.secretGCPLabel.labelName}=${integration.metadata.secretGCPLabel.labelValue}` + const filterParam = integration.metadata.secretGCPLabel + ? `?filter=labels.${integration.metadata.secretGCPLabel.labelName}=${integration.metadata.secretGCPLabel.labelValue}` : ""; - + while (hasMorePages) { const params = new URLSearchParams({ pageSize: String(pageSize), ...(pageToken ? { pageToken } : {}) }); - const res: GCPSMListSecretsRes = (await standardRequest.get( - `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets${filterParam}`, - { - params, - headers: { - "Authorization": `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" + const res: GCPSMListSecretsRes = ( + await standardRequest.get( + `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets${filterParam}`, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } } - } - )).data; - + ) + ).data; + if (res.secrets) { const filteredSecrets = res.secrets?.filter((gcpSecret) => { const arr = gcpSecret.name.split("/"); @@ -357,54 +372,58 @@ const syncSecretsGCPSecretManager = async ({ let isValid = true; - if (integration.metadata.secretPrefix && !key.startsWith(integration.metadata.secretPrefix)) { + if ( + integration.metadata.secretPrefix && + !key.startsWith(integration.metadata.secretPrefix) + ) { isValid = false; } if (integration.metadata.secretSuffix && !key.endsWith(integration.metadata.secretSuffix)) { isValid = false; } - + return isValid; }); gcpSecrets = gcpSecrets.concat(filteredSecrets); } - + if (!res.nextPageToken) { hasMorePages = false; } - + pageToken = res.nextPageToken; } - - const res: { [key: string]: string; } = {}; - + + const res: { [key: string]: string } = {}; + interface GCPLatestSecretVersionAccess { name: string; payload: { data: string; - } + }; } - + for await (const gcpSecret of gcpSecrets) { const arr = gcpSecret.name.split("/"); const key = arr[arr.length - 1]; - const secretLatest: GCPLatestSecretVersionAccess = (await standardRequest.get( - `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}/versions/latest:access`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" + const secretLatest: GCPLatestSecretVersionAccess = ( + await standardRequest.get( + `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}/versions/latest:access`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } } - } - )).data; + ) + ).data; - res[key] = Buffer.from(secretLatest.payload.data, "base64").toString("utf-8"); } - + for await (const key of Object.keys(secrets)) { if (!(key in res)) { // case: create secret @@ -414,11 +433,14 @@ const syncSecretsGCPSecretManager = async ({ replication: { automatic: {} }, - ...(integration.metadata.secretGCPLabel ? { - labels: { - [integration.metadata.secretGCPLabel.labelName]: integration.metadata.secretGCPLabel.labelValue - } - } : {}) + ...(integration.metadata.secretGCPLabel + ? { + labels: { + [integration.metadata.secretGCPLabel.labelName]: + integration.metadata.secretGCPLabel.labelValue + } + } + : {}) }, { params: { @@ -430,7 +452,7 @@ const syncSecretsGCPSecretManager = async ({ } } ); - + await standardRequest.post( `${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}:addVersion`, { @@ -447,7 +469,7 @@ const syncSecretsGCPSecretManager = async ({ ); } } - + for await (const key of Object.keys(res)) { if (!(key in secrets)) { // case: delete secret @@ -480,7 +502,7 @@ const syncSecretsGCPSecretManager = async ({ } } } -} +}; /** * Sync/push [secrets] to Azure Key Vault with vault URI [integration.app] @@ -720,15 +742,12 @@ const syncSecretsAWSParameterStore = async ({ } = {}; if (parameterList) { - awsParameterStoreSecretsObj = parameterList.reduce( - (obj: any, secret: any) => { - return ({ - ...obj, - [secret.Name.substring(integration.path.length)]: secret - }); - }, - {} - ); + awsParameterStoreSecretsObj = parameterList.reduce((obj: any, secret: any) => { + return { + ...obj, + [secret.Name.substring(integration.path.length)]: secret + }; + }, {}); } // Identify secrets to create @@ -941,7 +960,11 @@ const syncSecretsVercel = async ({ ? { teamId: integrationAuth.teamId } - : {}) + : {}), + ...(integration?.path + ? { + gitBranch: integration?.path + } : {}) }; const vercelSecrets: VercelSecret[] = ( @@ -960,7 +983,7 @@ const syncSecretsVercel = async ({ if ( integration.targetEnvironment === "preview" && - integration.path && + secret.gitBranch && integration.path !== secret.gitBranch ) { // case: secret on preview environment does not have same target git branch @@ -969,7 +992,7 @@ const syncSecretsVercel = async ({ return true; }); - + const res: { [key: string]: VercelSecret } = {}; for await (const vercelSecret of vercelSecrets) { @@ -1323,11 +1346,13 @@ const syncSecretsNetlify = async ({ const syncSecretsGitHub = async ({ integration, secrets, - accessToken + accessToken, + appendices }: { integration: IIntegration; secrets: Record; accessToken: string; + appendices?: { prefix: string, suffix: string }; }) => { interface GitHubRepoKey { key_id: string; @@ -1357,7 +1382,7 @@ const syncSecretsGitHub = async ({ ).data; // Get local copy of decrypted secrets. We cannot decrypt them as we dont have access to GH private key - const encryptedSecrets: GitHubSecretRes = ( + let encryptedSecrets: GitHubSecretRes = ( await octokit.request("GET /repos/{owner}/{repo}/actions/secrets", { owner: integration.owner, repo: integration.app @@ -1370,6 +1395,15 @@ const syncSecretsGitHub = async ({ {} ); + encryptedSecrets = Object.keys(encryptedSecrets).reduce((result: { + [key: string]: GitHubSecret; + }, key) => { + if ((appendices?.prefix !== undefined ? key.startsWith(appendices?.prefix) : true) && (appendices?.suffix !== undefined ? key.endsWith(appendices?.suffix) : true)) { + result[key] = encryptedSecrets[key]; + } + return result; + }, {}); + Object.keys(encryptedSecrets).map(async (key) => { if (!(key in secrets)) { await octokit.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { @@ -1856,8 +1890,10 @@ const syncSecretsGitLab = async ({ value: string; environment_scope: string; } - - const gitLabApiUrl = integrationAuth.url ? `${integrationAuth.url}/api` : INTEGRATION_GITLAB_API_URL; + + const gitLabApiUrl = integrationAuth.url + ? `${integrationAuth.url}/api` + : INTEGRATION_GITLAB_API_URL; const getAllEnvVariables = async (integrationAppId: string, accessToken: string) => { const headers = { @@ -1867,7 +1903,9 @@ const syncSecretsGitLab = async ({ }; let allEnvVariables: GitLabSecret[] = []; - let url: string | null = `${gitLabApiUrl}/v4/projects/${integrationAppId}/variables?per_page=100`; + let url: + | string + | null = `${gitLabApiUrl}/v4/projects/${integrationAppId}/variables?per_page=100`; while (url) { const response: any = await standardRequest.get(url, { headers }); @@ -1888,23 +1926,27 @@ const syncSecretsGitLab = async ({ const allEnvVariables = await getAllEnvVariables(integration?.appId, accessToken); const getSecretsRes: GitLabSecret[] = allEnvVariables - .filter( - (secret: GitLabSecret) => secret.environment_scope === integration.targetEnvironment - ) + .filter((secret: GitLabSecret) => secret.environment_scope === integration.targetEnvironment) .filter((gitLabSecret) => { let isValid = true; - if (integration.metadata.secretPrefix && !gitLabSecret.key.startsWith(integration.metadata.secretPrefix)) { + if ( + integration.metadata.secretPrefix && + !gitLabSecret.key.startsWith(integration.metadata.secretPrefix) + ) { isValid = false; } - if (integration.metadata.secretSuffix && !gitLabSecret.key.endsWith(integration.metadata.secretSuffix)) { + if ( + integration.metadata.secretSuffix && + !gitLabSecret.key.endsWith(integration.metadata.secretSuffix) + ) { isValid = false; } - + return isValid; }); - + for await (const key of Object.keys(secrets)) { const existingSecret = getSecretsRes.find((s: any) => s.key == key); if (!existingSecret) { @@ -2047,13 +2089,15 @@ const syncSecretsSupabase = async ({ const syncSecretsCheckly = async ({ integration, secrets, - accessToken + accessToken, + appendices }: { integration: IIntegration; secrets: Record; accessToken: string; + appendices?: { prefix: string, suffix: string }; }) => { - const getSecretsRes = ( + let getSecretsRes = ( await standardRequest.get(`${INTEGRATION_CHECKLY_API_URL}/v1/variables`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -2069,6 +2113,15 @@ const syncSecretsCheckly = async ({ {} ); + getSecretsRes = Object.keys(getSecretsRes).reduce((result: { + [key: string]: string; + }, key) => { + if ((appendices?.prefix !== undefined ? key.startsWith(appendices?.prefix) : true) && (appendices?.suffix !== undefined ? key.endsWith(appendices?.suffix) : true)) { + result[key] = getSecretsRes[key]; + } + return result; + }, {}); + // add secrets for await (const key of Object.keys(secrets)) { if (!(key in getSecretsRes)) { @@ -2126,6 +2179,97 @@ const syncSecretsCheckly = async ({ } }; +/** + * Sync/push [secrets] to Qovery app + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Qovery integration + */ +const syncSecretsQovery = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: Record; + accessToken: string; +}) => { + + const getSecretsRes = ( + await standardRequest.get(`${INTEGRATION_QOVERY_API_URL}/${integration.scope}/${integration.appId}/environmentVariable`, { + headers: { + Authorization: `Token ${accessToken}`, + "Accept-Encoding": "application/json" + } + }) + ).data.results.reduce( + (obj: any, secret: any) => ({ + ...obj, + [secret.key]: {"id": secret.id, "value": secret.value} + }), + {} + ); + + // add secrets + for await (const key of Object.keys(secrets)) { + if (!(key in getSecretsRes)) { + // case: secret does not exist in qovery + // -> add secret + await standardRequest.post( + `${INTEGRATION_QOVERY_API_URL}/${integration.scope}/${integration.appId}/environmentVariable`, + { + key, + value: secrets[key].value + }, + { + headers: { + Authorization: `Token ${accessToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + } + ); + } else { + // case: secret exists in qovery + // -> update/set secret + + if (secrets[key].value !== getSecretsRes[key].value) { + await standardRequest.put( + `${INTEGRATION_QOVERY_API_URL}/${integration.scope}/${integration.appId}/environmentVariable/${getSecretsRes[key].id}`, + { + key, + value: secrets[key].value + }, + { + headers: { + Authorization: `Token ${accessToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } + } + } + + // This one is dangerous because there might be a lot of qovery-specific secrets + + // for await (const key of Object.keys(getSecretsRes)) { + // if (!(key in secrets)) { + // console.log(3) + // // delete secret + // await standardRequest.delete(`${INTEGRATION_QOVERY_API_URL}/application/${integration.appId}/environmentVariable/${getSecretsRes[key].id}`, { + // headers: { + // Authorization: `Token ${accessToken}`, + // Accept: "application/json", + // "X-Qovery-Account": integration.appId + // } + // }); + // } + // } +}; + /** * Sync/push [secrets] to Terraform Cloud project with id [integration.appId] * @param {Object} obj @@ -2267,41 +2411,43 @@ const syncSecretsTeamCity = async ({ if (integration.targetEnvironment && integration.targetEnvironmentId) { // case: sync to specific build-config in TeamCity project - const res = (await standardRequest.get( - `${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - }, - } - )) - .data - .property - .filter((parameter) => !parameter.inherited) - .reduce((obj: any, secret: TeamCitySecret) => { - const secretName = secret.name.replace(/^env\./, ""); - return { - ...obj, - [secretName]: secret.value - }; - }, {}); - + const res = ( + await standardRequest.get( + `${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ) + ).data.property + .filter((parameter) => !parameter.inherited) + .reduce((obj: any, secret: TeamCitySecret) => { + const secretName = secret.name.replace(/^env\./, ""); + return { + ...obj, + [secretName]: secret.value + }; + }, {}); + for await (const key of Object.keys(secrets)) { if (!(key in res) || (key in res && secrets[key].value !== res[key])) { // case: secret does not exist in TeamCity or secret value has changed // -> create/update secret - await standardRequest.post(`${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`, - { - name:`env.${key}`, - value: secrets[key].value - }, - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", + await standardRequest.post( + `${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`, + { + name: `env.${key}`, + value: secrets[key].value }, - }); + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); } } @@ -2930,4 +3076,4 @@ const syncSecretsNorthflank = async ({ ); }; -export { syncSecrets }; \ No newline at end of file +export { syncSecrets }; diff --git a/backend/src/interfaces/middleware/index.ts b/backend/src/interfaces/middleware/index.ts index e8dd1b91b..5146d25cd 100644 --- a/backend/src/interfaces/middleware/index.ts +++ b/backend/src/interfaces/middleware/index.ts @@ -1,10 +1,12 @@ import { Types } from "mongoose"; import { IServiceTokenData, + IServiceTokenDataV3, IUser, } from "../../models"; import { ServiceActor, + ServiceActorV3, UserActor, UserAgentType } from "../../ee/models"; @@ -21,6 +23,11 @@ export interface UserAuthData extends BaseAuthData { authPayload: IUser; } +export interface ServiceTokenV3AuthData extends BaseAuthData { + actor: ServiceActorV3; + authPayload: IServiceTokenDataV3; +} + export interface ServiceTokenAuthData extends BaseAuthData { actor: ServiceActor; authPayload: IServiceTokenData; @@ -28,4 +35,5 @@ export interface ServiceTokenAuthData extends BaseAuthData { export type AuthData = | UserAuthData + | ServiceTokenV3AuthData | ServiceTokenAuthData; \ No newline at end of file diff --git a/backend/src/interfaces/services/SecretService/index.ts b/backend/src/interfaces/services/SecretService/index.ts index 9ea82ce94..a2d9c5bb5 100644 --- a/backend/src/interfaces/services/SecretService/index.ts +++ b/backend/src/interfaces/services/SecretService/index.ts @@ -16,10 +16,11 @@ export interface CreateSecretParams { secretCommentCiphertext?: string; secretCommentIV?: string; secretCommentTag?: string; + skipMultilineEncoding?: boolean; secretPath: string; metadata?: { source?: string; - } + }; } export interface GetSecretsParams { @@ -37,10 +38,16 @@ export interface GetSecretParams { environment: string; type?: "shared" | "personal"; authData: AuthData; + include_imports?: boolean; } export interface UpdateSecretParams { secretName: string; + newSecretName?: string; + secretId?: string; + secretKeyCiphertext?: string; + secretKeyIV?: string; + secretKeyTag?: string; workspaceId: Types.ObjectId; environment: string; type: "shared" | "personal"; @@ -49,13 +56,73 @@ export interface UpdateSecretParams { secretValueIV: string; secretValueTag: string; secretPath: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + tags?: string[]; } export interface DeleteSecretParams { secretName: string; + secretId?: string; workspaceId: Types.ObjectId; environment: string; type: "shared" | "personal"; authData: AuthData; secretPath: string; } + +export interface CreateSecretBatchParams { + workspaceId: Types.ObjectId; + environment: string; + authData: AuthData; + secretPath: string; + secrets: Array<{ + secretName: string; + type: "shared" | "personal"; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + metadata?: { + source?: string; + }; + }>; +} + +export interface UpdateSecretBatchParams { + workspaceId: Types.ObjectId; + environment: string; + authData: AuthData; + secretPath: string; + secrets: Array<{ + secretName: string; + type: "shared" | "personal"; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; + skipMultilineEncoding?: boolean; + tags?: string[]; + }>; +} + +export interface DeleteSecretBatchParams { + workspaceId: Types.ObjectId; + environment: string; + authData: AuthData; + secretPath: string; + secrets: Array<{ + secretName: string; + type: "shared" | "personal"; + }>; +} diff --git a/backend/src/middleware/requestErrorHandler.ts b/backend/src/middleware/requestErrorHandler.ts index 2869a0f94..01b37bf3d 100644 --- a/backend/src/middleware/requestErrorHandler.ts +++ b/backend/src/middleware/requestErrorHandler.ts @@ -3,8 +3,8 @@ import { ErrorRequestHandler } from "express"; import { TokenExpiredError } from "jsonwebtoken"; import { InternalServerError, UnauthorizedRequestError } from "../utils/errors"; import { getLogger } from "../utils/logger"; -import RequestError, { LogLevel } from "../utils/requestError"; -import { getNodeEnv } from "../config"; +import RequestError from "../utils/requestError"; +import { ForbiddenError } from "@casl/ability"; export const requestErrorHandler: ErrorRequestHandler = async ( error: RequestError | Error, @@ -14,41 +14,37 @@ export const requestErrorHandler: ErrorRequestHandler = async ( ) => { if (res.headersSent) return next(); - if (await getNodeEnv() !== "production") { - /* eslint-disable no-console */ - console.error(error); - } - - //TODO: Find better way to type check for error. In current setting you need to cast type to get the functions and variables from RequestError - if (error instanceof TokenExpiredError) { - error = UnauthorizedRequestError({ stack: error.stack, message: "Token expired" }); - } else if (!(error instanceof RequestError)) { - error = InternalServerError({ - context: { exception: error.message }, - stack: error.stack, - }); + const logAndCaptureException = async (error: RequestError) => { (await getLogger("backend-main")).log( (error).levelName.toLowerCase(), - (error).message + `${error.stack}\n${error.message}` ); - } - //* Set Sentry user identification if req.user is populated - if (req.user !== undefined && req.user !== null) { - Sentry.setUser({ email: (req.user as any).email }); - } - //* Only sent error to Sentry if LogLevel is one of the following level 'ERROR', 'EMERGENCY' or 'CRITICAL' - //* with this we will eliminate false-positive errors like 'BadRequestError', 'UnauthorizedRequestError' and so on - if ( - [LogLevel.ERROR, LogLevel.EMERGENCY, LogLevel.CRITICAL].includes( - (error).level - ) - ) { + //* Set Sentry user identification if req.user is populated + if (req.user !== undefined && req.user !== null) { + Sentry.setUser({ email: (req.user as any).email }); + } + Sentry.captureException(error); + }; + + if (error instanceof RequestError) { + if (error instanceof TokenExpiredError) { + error = UnauthorizedRequestError({ stack: error.stack, message: "Token expired" }); + } + await logAndCaptureException((error)); + } else { + if (error instanceof ForbiddenError) { + error = UnauthorizedRequestError({ context: { exception: error.message }, stack: error.stack }) + } else { + error = InternalServerError({ context: { exception: error.message }, stack: error.stack }); + } + + await logAndCaptureException((error)); } - res - .status((error).statusCode) - .json((error).format(req)); + delete (error).stacktrace // remove stack trace from being sent to client + res.status((error).statusCode).json(error); + next(); }; diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index e256f6665..aa74b0dc7 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -3,6 +3,7 @@ import { NextFunction, Request, Response } from "express"; import { getAuthAPIKeyPayload, getAuthSTDPayload, + getAuthSTDV3Payload, getAuthUserPayload, validateAuthMode, } from "../helpers/auth"; @@ -49,6 +50,12 @@ const requireAuth = ({ }); req.serviceTokenData = authData.authPayload; break; + case AuthMode.SERVICE_TOKEN_V3: + authData = await getAuthSTDV3Payload({ + req, + authTokenValue + }); + break; case AuthMode.API_KEY: authData = await getAuthAPIKeyPayload({ req, @@ -61,9 +68,7 @@ const requireAuth = ({ req, authTokenValue }); - // authPayload = authUserPayload.user; req.user = authData.authPayload; - // req.tokenVersionId = authUserPayload.tokenVersionId; // TODO break; } diff --git a/backend/src/middleware/requireMfaAuth.ts b/backend/src/middleware/requireMfaAuth.ts index 7a7f7db4b..9c5313b05 100644 --- a/backend/src/middleware/requireMfaAuth.ts +++ b/backend/src/middleware/requireMfaAuth.ts @@ -2,7 +2,8 @@ import jwt from "jsonwebtoken"; import { NextFunction, Request, Response } from "express"; import { User } from "../models"; import { BadRequestError, UnauthorizedRequestError } from "../utils/errors"; -import { getJwtMfaSecret } from "../config"; +import { getAuthSecret } from "../config"; +import { AuthTokenType } from "../variables"; declare module "jsonwebtoken" { export interface UserIDJwtPayload extends jwt.JwtPayload { @@ -26,8 +27,10 @@ const requireMfaAuth = async ( if(AUTH_TOKEN_VALUE === null) return next(BadRequestError({message: "Missing Authorization Body in the request header"})) const decodedToken = ( - jwt.verify(AUTH_TOKEN_VALUE, await getJwtMfaSecret()) + jwt.verify(AUTH_TOKEN_VALUE, await getAuthSecret()) ); + + if (decodedToken.authTokenType !== AuthTokenType.MFA_TOKEN) throw UnauthorizedRequestError(); const user = await User.findOne({ _id: decodedToken.userId, diff --git a/backend/src/middleware/requireSignupAuth.ts b/backend/src/middleware/requireSignupAuth.ts index 3c5c48d12..510cb3d03 100644 --- a/backend/src/middleware/requireSignupAuth.ts +++ b/backend/src/middleware/requireSignupAuth.ts @@ -2,7 +2,8 @@ import jwt from "jsonwebtoken"; import { NextFunction, Request, Response } from "express"; import { User } from "../models"; import { BadRequestError, UnauthorizedRequestError } from "../utils/errors"; -import { getJwtSignupSecret } from "../config"; +import { getAuthSecret } from "../config"; +import { AuthTokenType } from "../variables"; declare module "jsonwebtoken" { export interface UserIDJwtPayload extends jwt.JwtPayload { @@ -27,8 +28,10 @@ const requireSignupAuth = async ( if(AUTH_TOKEN_VALUE === null) return next(BadRequestError({message: "Missing Authorization Body in the request header"})) const decodedToken = ( - jwt.verify(AUTH_TOKEN_VALUE, await getJwtSignupSecret()) + jwt.verify(AUTH_TOKEN_VALUE, await getAuthSecret()) ); + + if (decodedToken.authTokenType !== AuthTokenType.SIGNUP_TOKEN) throw UnauthorizedRequestError(); const user = await User.findOne({ _id: decodedToken.userId, diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 7a431592b..99fc9c4f1 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -14,17 +14,19 @@ export * from "./tag"; export * from "./folder"; export * from "./secretImports"; export * from "./secretBlindIndexData"; -export * from "./serviceToken"; -export * from "./serviceAccount"; -export * from "./serviceAccountKey"; -export * from "./serviceAccountOrganizationPermission"; -export * from "./serviceAccountWorkspacePermission"; +export * from "./serviceToken"; // TODO: deprecate +export * from "./serviceAccount"; // TODO: deprecate +export * from "./serviceAccountKey"; // TODO: deprecate +export * from "./serviceAccountOrganizationPermission"; // TODO: deprecate +export * from "./serviceAccountWorkspacePermission"; // TODO: deprecate export * from "./tokenData"; export * from "./user"; export * from "./userAction"; export * from "./workspace"; -export * from "./serviceTokenData"; +export * from "./serviceTokenData"; // TODO: deprecate export * from "./apiKeyData"; export * from "./loginSRPDetail"; export * from "./tokenVersion"; -export * from "./webhooks"; \ No newline at end of file +export * from "./webhooks"; +export * from "./serviceTokenDataV3"; +export * from "./serviceTokenDataV3Key"; diff --git a/backend/src/models/integration/integration.ts b/backend/src/models/integration/integration.ts index 9bc378f28..7b9957393 100644 --- a/backend/src/models/integration/integration.ts +++ b/backend/src/models/integration/integration.ts @@ -18,6 +18,7 @@ import { INTEGRATION_LARAVELFORGE, INTEGRATION_NETLIFY, INTEGRATION_NORTHFLANK, + INTEGRATION_QOVERY, INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, @@ -45,6 +46,7 @@ export interface IIntegration { targetServiceId: string; path: string; region: string; + scope: string; secretPath: string; integration: | "azure-key-vault" @@ -63,6 +65,7 @@ export interface IIntegration { | "travisci" | "supabase" | "checkly" + | "qovery" | "terraform-cloud" | "teamcity" | "hashicorp-vault" @@ -119,11 +122,13 @@ const integrationSchema = new Schema( }, targetService: { // railway-specific service + // qovery-specific project type: String, default: null, }, targetServiceId: { // railway-specific service + // qovery specific project type: String, default: null, }, @@ -143,6 +148,11 @@ const integrationSchema = new Schema( type: String, default: null, }, + scope: { + // qovery-specific scope + type: String, + default: null + }, integration: { type: String, enum: [ @@ -162,6 +172,7 @@ const integrationSchema = new Schema( INTEGRATION_TRAVISCI, INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, + INTEGRATION_QOVERY, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TEAMCITY, INTEGRATION_HASHICORP_VAULT, diff --git a/backend/src/models/integration/types.ts b/backend/src/models/integration/types.ts index 0c0b998e6..5c4387bba 100644 --- a/backend/src/models/integration/types.ts +++ b/backend/src/models/integration/types.ts @@ -1,6 +1,3 @@ - -// TODO: in the future separate metadata -// into distinct types by integration export type Metadata = { secretPrefix?: string; secretSuffix?: string; diff --git a/backend/src/models/integrationAuth/integrationAuth.ts b/backend/src/models/integrationAuth/integrationAuth.ts index 299ca9abe..312ee09d7 100644 --- a/backend/src/models/integrationAuth/integrationAuth.ts +++ b/backend/src/models/integrationAuth/integrationAuth.ts @@ -52,6 +52,7 @@ import { | "aws-parameter-store" | "aws-secret-manager" | "checkly" + | "qovery" | "cloudflare-pages" | "codefresh" | "digital-ocean-app-platform" diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index b7d7c266d..8592e518a 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -4,7 +4,7 @@ import { ENCODING_SCHEME_BASE64, ENCODING_SCHEME_UTF8, SECRET_PERSONAL, - SECRET_SHARED, + SECRET_SHARED } from "../variables"; export interface ISecret { @@ -12,7 +12,7 @@ export interface ISecret { version: number; workspace: Types.ObjectId; type: string; - user: Types.ObjectId; + user?: Types.ObjectId; environment: string; secretBlindIndex?: string; secretKeyCiphertext: string; @@ -27,13 +27,14 @@ export interface ISecret { secretCommentIV?: string; secretCommentTag?: string; secretCommentHash?: string; + skipMultilineEncoding?: boolean; algorithm: "aes-256-gcm"; keyEncoding: "utf8" | "base64"; tags?: string[]; folder?: string; metadata?: { [key: string]: string; - } + }; } const secretSchema = new Schema( @@ -41,108 +42,112 @@ const secretSchema = new Schema( version: { type: Number, required: true, - default: 1, + default: 1 }, workspace: { type: Schema.Types.ObjectId, ref: "Workspace", - required: true, + required: true }, type: { type: String, enum: [SECRET_SHARED, SECRET_PERSONAL], - required: true, + required: true }, user: { // user associated with the personal secret type: Schema.Types.ObjectId, - ref: "User", + ref: "User" }, tags: { ref: "Tag", type: [Schema.Types.ObjectId], - default: [], + default: [] }, environment: { type: String, - required: true, + required: true }, secretBlindIndex: { type: String, - select: false, + select: false }, secretKeyCiphertext: { type: String, - required: true, + required: true }, secretKeyIV: { type: String, // symmetric - required: true, + required: true }, secretKeyTag: { type: String, // symmetric - required: true, + required: true }, secretKeyHash: { - type: String, + type: String }, secretValueCiphertext: { type: String, - required: true, + required: true }, secretValueIV: { type: String, // symmetric - required: true, + required: true }, secretValueTag: { type: String, // symmetric - required: true, + required: true }, secretValueHash: { - type: String, + type: String }, secretCommentCiphertext: { type: String, - required: false, + required: false }, secretCommentIV: { type: String, // symmetric - required: false, + required: false }, secretCommentTag: { type: String, // symmetric - required: false, + required: false }, secretCommentHash: { type: String, - required: false, + required: false + }, + skipMultilineEncoding: { + type: Boolean, + required: false }, algorithm: { // the encryption algorithm used type: String, enum: [ALGORITHM_AES_256_GCM], required: true, - default: ALGORITHM_AES_256_GCM, + default: ALGORITHM_AES_256_GCM }, keyEncoding: { type: String, enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64], required: true, - default: ENCODING_SCHEME_UTF8, + default: ENCODING_SCHEME_UTF8 }, folder: { type: String, - default: "root", + default: "root" }, metadata: { type: Schema.Types.Mixed } }, { - timestamps: true, + timestamps: true } ); secretSchema.index({ tags: 1 }, { background: true }); -export const Secret = model("Secret", secretSchema); \ No newline at end of file +export const Secret = model("Secret", secretSchema); diff --git a/backend/src/models/secretApprovalRequest.ts b/backend/src/models/secretApprovalRequest.ts deleted file mode 100644 index 910ca9135..000000000 --- a/backend/src/models/secretApprovalRequest.ts +++ /dev/null @@ -1,81 +0,0 @@ -import mongoose, { Schema, model } from "mongoose"; -import { ISecret, Secret } from "./secret"; - -interface ISecretApprovalRequest { - secret: mongoose.Types.ObjectId; - requestedChanges: ISecret; - requestedBy: mongoose.Types.ObjectId; - approvers: IApprover[]; - status: ApprovalStatus; - timestamp: Date; - requestType: RequestType; - requestId: string; -} - -interface IApprover { - userId: mongoose.Types.ObjectId; - status: ApprovalStatus; -} - -export enum ApprovalStatus { - PENDING = "pending", - APPROVED = "approved", - REJECTED = "rejected" -} - -export enum RequestType { - UPDATE = "update", - DELETE = "delete", - CREATE = "create" -} - -const approverSchema = new mongoose.Schema({ - user: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - required: true, - }, - status: { - type: String, - enum: [ApprovalStatus], - default: ApprovalStatus.PENDING, - }, -}); - -const secretApprovalRequestSchema = new Schema( - { - secret: { - type: mongoose.Schema.Types.ObjectId, - ref: "Secret", - }, - requestedChanges: Secret, - requestedBy: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - }, - approvers: [approverSchema], - status: { - type: String, - enum: ApprovalStatus, - default: ApprovalStatus.PENDING, - }, - timestamp: { - type: Date, - default: Date.now, - }, - requestType: { - type: String, - enum: RequestType, - required: true, - }, - requestId: { - type: String, - required: false, - }, - }, - { - timestamps: true, - } -); - -export const SecretApprovalRequest = model("SecretApprovalRequest", secretApprovalRequestSchema); \ No newline at end of file diff --git a/backend/src/models/secretBlindIndexData.ts b/backend/src/models/secretBlindIndexData.ts index 5d0cd976a..da397d2c1 100644 --- a/backend/src/models/secretBlindIndexData.ts +++ b/backend/src/models/secretBlindIndexData.ts @@ -1,5 +1,5 @@ import { Document, Schema, Types, model } from "mongoose"; -import { +import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_BASE64, ENCODING_SCHEME_UTF8, @@ -53,4 +53,6 @@ const secretBlindIndexDataSchema = new Schema( } ); +secretBlindIndexDataSchema.index({ workspace: 1 }); + export const SecretBlindIndexData = model("SecretBlindIndexData", secretBlindIndexDataSchema); \ No newline at end of file diff --git a/backend/src/models/serviceToken.ts b/backend/src/models/serviceToken.ts index 4734a50e2..0e943b177 100644 --- a/backend/src/models/serviceToken.ts +++ b/backend/src/models/serviceToken.ts @@ -1,3 +1,4 @@ +// TODO: deprecate import { Schema, Types, model } from "mongoose"; export interface IServiceToken { _id: Types.ObjectId; diff --git a/backend/src/models/serviceTokenData.ts b/backend/src/models/serviceTokenData.ts index ea7d00eaa..735131703 100644 --- a/backend/src/models/serviceTokenData.ts +++ b/backend/src/models/serviceTokenData.ts @@ -1,3 +1,4 @@ +// TODO: deprecate import { Document, Schema, Types, model } from "mongoose"; export interface IServiceTokenData extends Document { diff --git a/backend/src/models/serviceTokenDataV3.ts b/backend/src/models/serviceTokenDataV3.ts new file mode 100644 index 000000000..a9758422e --- /dev/null +++ b/backend/src/models/serviceTokenDataV3.ts @@ -0,0 +1,129 @@ +import { Document, Schema, Types, model } from "mongoose"; +import { IPType } from "../ee/models"; + +export enum Permission { + READ = "read", + WRITE = "write" +} + +export interface IServiceTokenV3Scope { + environment: string; + secretPath: string; + permissions: Permission[]; +} + +export interface IServiceTokenV3TrustedIp { + ipAddress: string; + type: IPType; + prefix: number; +} + +export interface IServiceTokenDataV3 extends Document { + _id: Types.ObjectId; + name: string; + workspace: Types.ObjectId; + user: Types.ObjectId; + publicKey: string; + isActive: boolean; + lastUsed?: Date; + usageCount: number; + expiresAt?: Date; + scopes: Array; + trustedIps: Array; +} + +const serviceTokenDataV3Schema = new Schema( + { + name: { + type: String, + required: true + }, + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true + }, + user: { + type: Schema.Types.ObjectId, + ref: "User", + required: true + }, + publicKey: { + type: String, + required: true + }, + isActive: { + type: Boolean, + required: true + }, + lastUsed: { + type: Date, + required: false + }, + usageCount: { + type: Number, + default: 0, + required: true + }, + expiresAt: { + type: Date, + required: false, + expires: 0 + }, + scopes: { + type: [ + { + environment: { + type: String, + required: true + }, + secretPath: { + type: String, + default: "/", + required: true + }, + permissions: { + type: [String], + enum: [Permission.READ, Permission.WRITE], + default: [Permission.READ], + required: true + } + } + ], + required: true + }, + trustedIps: { + type: [ + { + ipAddress: { + type: String, + required: true + }, + type: { + type: String, + enum: [ + IPType.IPV4, + IPType.IPV6 + ], + required: true + }, + prefix: { + type: Number, + required: false + } + } + ], + default: [{ + ipAddress: "0.0.0.0", + type: IPType.IPV4.toString(), + prefix: 0 + }], + required: true + } + }, + { + timestamps: true + } +); + +export const ServiceTokenDataV3 = model("ServiceTokenDataV3", serviceTokenDataV3Schema); \ No newline at end of file diff --git a/backend/src/models/serviceTokenDataV3Key.ts b/backend/src/models/serviceTokenDataV3Key.ts new file mode 100644 index 000000000..7a69abc1e --- /dev/null +++ b/backend/src/models/serviceTokenDataV3Key.ts @@ -0,0 +1,43 @@ +import { Document, Schema, Types, model } from "mongoose"; + +export interface IServiceTokenDataV3Key extends Document { + _id: Types.ObjectId; + encryptedKey: string; + nonce: string; + sender: Types.ObjectId; + serviceTokenData: Types.ObjectId; + workspace: Types.ObjectId; +} + +const serviceTokenDataV3KeySchema = new Schema( + { + encryptedKey: { + type: String, + required: true + }, + nonce: { + type: String, + required: true + }, + sender: { + type: Schema.Types.ObjectId, + ref: "User", + required: true + }, + serviceTokenData: { + type: Schema.Types.ObjectId, + ref: "ServiceTokenDataV3", + required: true, + }, + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true, + } + }, + { + timestamps: true + } +); + +export const ServiceTokenDataV3Key = model("ServiceTokenDataV3Key", serviceTokenDataV3KeySchema); \ No newline at end of file diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index c85c0936b..911776a63 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -4,6 +4,7 @@ export enum AuthMethod { EMAIL = "email", GOOGLE = "google", GITHUB = "github", + GITLAB = "gitlab", OKTA_SAML = "okta-saml", AZURE_SAML = "azure-saml", JUMPCLOUD_SAML = "jumpcloud-saml", diff --git a/backend/src/queues/integrations/syncSecretsToThirdPartyServices.ts b/backend/src/queues/integrations/syncSecretsToThirdPartyServices.ts index 7b6819b8c..490b31c10 100644 --- a/backend/src/queues/integrations/syncSecretsToThirdPartyServices.ts +++ b/backend/src/queues/integrations/syncSecretsToThirdPartyServices.ts @@ -40,9 +40,9 @@ syncSecretsToThirdPartyServices.process(async (job: Job) => { const prefix = (integration.metadata?.secretPrefix || ""); const suffix = (integration.metadata?.secretSuffix || ""); const newKey = prefix + key + suffix; - + suffixedSecrets[newKey] = secrets[key]; - } + } } const integrationAuth = await IntegrationAuth.findById(integration.integrationAuth); @@ -60,13 +60,14 @@ syncSecretsToThirdPartyServices.process(async (job: Job) => { integrationAuth, secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets, accessId: access.accessId === undefined ? null : access.accessId, - accessToken: access.accessToken + accessToken: access.accessToken, + appendices: { prefix: integration.metadata?.secretPrefix || "", suffix: integration.metadata?.secretSuffix || "" } }); } }) syncSecretsToThirdPartyServices.on("error", (error) => { - console.log("QUEUE ERROR:", error) // eslint-disable-line + // console.log("QUEUE ERROR:", error) // eslint-disable-line }) export const syncSecretsToActiveIntegrationsQueue = (jobDetails: TSyncSecretsToThirdPartyServices) => { diff --git a/backend/src/queues/secret-scanning/githubScanFullRepository.ts b/backend/src/queues/secret-scanning/githubScanFullRepository.ts index eeb6aaf1e..ece43bd97 100644 --- a/backend/src/queues/secret-scanning/githubScanFullRepository.ts +++ b/backend/src/queues/secret-scanning/githubScanFullRepository.ts @@ -2,7 +2,7 @@ import Queue, { Job } from "bull"; import { ProbotOctokit } from "probot" import TelemetryService from "../../services/TelemetryService"; import { sendMail } from "../../helpers"; -import GitRisks from "../../ee/models/gitRisks"; +import { GitRisks } from "../../ee/models"; import { MembershipOrg, User } from "../../models"; import { ADMIN } from "../../variables"; import { convertKeysToLowercase, scanFullRepoContentAndGetFindings } from "../../ee/services/GithubSecretScanning/helper"; @@ -13,7 +13,7 @@ export const githubFullRepositorySecretScan = new Queue("github-full-repository- type TScanPushEventQueueDetails = { organizationId: string, - installationId: number, + installationId: string, repository: { id: number, fullName: string, @@ -30,7 +30,8 @@ githubFullRepositorySecretScan.process(async (job: Job, done: Queue.DoneCallback installationId: installationId }, }); - const findings: SecretMatch[] = await scanFullRepoContentAndGetFindings(octokit, installationId, repository.fullName) + + const findings: SecretMatch[] = await scanFullRepoContentAndGetFindings(octokit, installationId as any, repository.fullName) for (const finding of findings) { await GitRisks.findOneAndUpdate({ fingerprint: finding.Fingerprint }, { diff --git a/backend/src/queues/secret-scanning/githubScanPushEvent.ts b/backend/src/queues/secret-scanning/githubScanPushEvent.ts index 09261e235..af2d88f1f 100644 --- a/backend/src/queues/secret-scanning/githubScanPushEvent.ts +++ b/backend/src/queues/secret-scanning/githubScanPushEvent.ts @@ -3,7 +3,7 @@ import { ProbotOctokit } from "probot" import { Commit } from "@octokit/webhooks-types"; import TelemetryService from "../../services/TelemetryService"; import { sendMail } from "../../helpers"; -import GitRisks from "../../ee/models/gitRisks"; +import { GitRisks } from "../../ee/models"; import { MembershipOrg, User } from "../../models"; import { ADMIN } from "../../variables"; import { convertKeysToLowercase, scanContentAndGetFindings } from "../../ee/services/GithubSecretScanning/helper"; diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 46e74d58c..2d7bf27ef 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -11,6 +11,7 @@ import key from "./key"; import inviteOrg from "./inviteOrg"; import secret from "./secret"; import serviceToken from "./serviceToken"; +import sso from "./sso"; import password from "./password"; import integration from "./integration"; import integrationAuth from "./integrationAuth"; @@ -37,5 +38,6 @@ export { integrationAuth, secretsFolder, webhooks, - secretImps + secretImps, + sso }; diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index cfb27ee71..e28874788 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -60,6 +60,54 @@ router.get( integrationAuthController.getIntegrationAuthVercelBranches ); +router.get( + "/:integrationAuthId/qovery/orgs", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryOrgs +); + +router.get( + "/:integrationAuthId/qovery/projects", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryProjects +); + +router.get( + "/:integrationAuthId/qovery/environments", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryEnvironments +); + +router.get( + "/:integrationAuthId/qovery/apps", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryApps +); + +router.get( + "/:integrationAuthId/qovery/containers", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryContainers +); + +router.get( + "/:integrationAuthId/qovery/jobs", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + integrationAuthController.getIntegrationAuthQoveryJobs +); + router.get( "/:integrationAuthId/railway/environments", requireAuth({ diff --git a/backend/src/routes/v1/organization.ts b/backend/src/routes/v1/organization.ts index 011d5fed6..3dfe2689b 100644 --- a/backend/src/routes/v1/organization.ts +++ b/backend/src/routes/v1/organization.ts @@ -13,15 +13,6 @@ router.get( organizationController.getOrganizations ); -router.post( - // not used on frontend - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - organizationController.createOrganization -); - router.get( "/:organizationId", requireAuth({ diff --git a/backend/src/routes/v1/secretImps.ts b/backend/src/routes/v1/secretImps.ts index 1d2696d60..5dba7fc9d 100644 --- a/backend/src/routes/v1/secretImps.ts +++ b/backend/src/routes/v1/secretImps.ts @@ -7,7 +7,7 @@ import { AuthMode } from "../../variables"; router.post( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.API_KEY] }), secretImpsController.createSecretImp ); @@ -15,7 +15,7 @@ router.post( router.put( "/:id", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.API_KEY] }), secretImpsController.updateSecretImport ); @@ -23,7 +23,7 @@ router.put( router.delete( "/:id", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.API_KEY] }), secretImpsController.deleteSecretImport ); @@ -31,7 +31,7 @@ router.delete( router.get( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.API_KEY] }), secretImpsController.getSecretImports ); @@ -39,7 +39,7 @@ router.get( router.get( "/secrets", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.API_KEY] }), secretImpsController.getAllSecretsFromImport ); diff --git a/backend/src/routes/v1/secretsFolder.ts b/backend/src/routes/v1/secretsFolder.ts index 00ca2acea..ed296373c 100644 --- a/backend/src/routes/v1/secretsFolder.ts +++ b/backend/src/routes/v1/secretsFolder.ts @@ -12,23 +12,23 @@ import { AuthMode } from "../../variables"; router.post( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.API_KEY] }), createFolder ); router.patch( - "/:folderId", + "/:folderName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.API_KEY] }), updateFolderById ); router.delete( - "/:folderId", + "/:folderName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.API_KEY] }), deleteFolder ); @@ -36,7 +36,7 @@ router.delete( router.get( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.API_KEY] }), getFolders ); diff --git a/backend/src/routes/v1/sso.ts b/backend/src/routes/v1/sso.ts new file mode 100644 index 000000000..b06ba9986 --- /dev/null +++ b/backend/src/routes/v1/sso.ts @@ -0,0 +1,72 @@ +import express from "express"; +const router = express.Router(); +import passport from "passport"; +import { authLimiter } from "../../helpers/rateLimiter"; +import { ssoController } from "../../ee/controllers/v1"; + +router.get("/redirect/google", authLimiter, (req, res, next) => { + passport.authenticate("google", { + scope: ["profile", "email"], + session: false, + ...(req.query.callback_port + ? { + state: req.query.callback_port as string + } + : {}) + })(req, res, next); +}); + +router.get( + "/google", + passport.authenticate("google", { + failureRedirect: "/login/provider/error", + session: false + }), + ssoController.redirectSSO +); + +router.get("/redirect/github", authLimiter, (req, res, next) => { + passport.authenticate("github", { + session: false, + ...(req.query.callback_port + ? { + state: req.query.callback_port as string + } + : {}) + })(req, res, next); +}); + +router.get( + "/github", + authLimiter, + passport.authenticate("github", { + failureRedirect: "/login/provider/error", + session: false + }), + ssoController.redirectSSO +); + +router.get( + "/redirect/gitlab", + authLimiter, + (req, res, next) => { + passport.authenticate("gitlab", { + session: false, + ...(req.query.callback_port ? { + state: req.query.callback_port as string + } : {}) + })(req, res, next); + } +); + +router.get( + "/gitlab", + authLimiter, + passport.authenticate("gitlab", { + failureRedirect: "/login/provider/error", + session: false + }), + ssoController.redirectSSO +); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/v2/organizations.ts b/backend/src/routes/v2/organizations.ts index d1387bd42..537449273 100644 --- a/backend/src/routes/v2/organizations.ts +++ b/backend/src/routes/v2/organizations.ts @@ -54,4 +54,20 @@ router.get( organizationsController.getOrganizationServiceAccounts ); +router.post( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.createOrganization +); + +router.delete( + "/:organizationId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + organizationsController.deleteOrganizationById +); + export default router; diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index c093be21c..cc7a7ec0d 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -4,14 +4,6 @@ import { requireAuth } from "../../middleware"; import { usersController } from "../../controllers/v2"; import { AuthMode } from "../../variables"; -router.get( - "/me", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] - }), - usersController.getMe -); - router.patch( "/me/mfa", requireAuth({ @@ -29,11 +21,11 @@ router.patch( ); router.put( - "/me/auth-methods", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] - }), - usersController.updateAuthMethods + "/me/auth-methods", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + }), + usersController.updateAuthMethods, ); router.get( @@ -84,4 +76,20 @@ router.delete( usersController.deleteMySessions ); +router.get( + "/me", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + usersController.getMe +); + +router.delete( + "/me", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + usersController.deleteMe +); + export default router; diff --git a/backend/src/routes/v3/index.ts b/backend/src/routes/v3/index.ts index f4fcfe55b..1a95439ab 100644 --- a/backend/src/routes/v3/index.ts +++ b/backend/src/routes/v3/index.ts @@ -7,5 +7,5 @@ export { auth, secrets, signup, - workspaces, + workspaces } diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index 1765c2e33..960094f87 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -1,19 +1,13 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - requireBlindIndicesEnabled, - requireE2EEOff -} from "../../middleware"; +import { requireAuth, requireBlindIndicesEnabled, requireE2EEOff } from "../../middleware"; import { secretsController } from "../../controllers/v3"; -import { - AuthMode -} from "../../variables"; +import { AuthMode } from "../../variables"; router.get( "/raw", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), secretsController.getSecretsRaw ); @@ -21,7 +15,7 @@ router.get( router.get( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -35,7 +29,7 @@ router.get( router.post( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -49,7 +43,7 @@ router.post( router.patch( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -63,7 +57,7 @@ router.patch( router.delete( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -77,7 +71,7 @@ router.delete( router.get( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -85,10 +79,44 @@ router.get( secretsController.getSecrets ); +// akhilmhdh: dont put batch router below the individual operation as those have arbitory name as params +router.post( + "/batch", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + }), + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" + }), + secretsController.createSecretByNameBatch +); + +router.patch( + "/batch", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + }), + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" + }), + secretsController.updateSecretByNameBatch +); + +router.delete( + "/batch", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + }), + requireBlindIndicesEnabled({ + locationWorkspaceId: "body" + }), + secretsController.deleteSecretByNameBatch +); + router.post( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -99,7 +127,7 @@ router.post( router.get( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -110,7 +138,7 @@ router.get( router.patch( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -121,7 +149,7 @@ router.patch( router.delete( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" diff --git a/backend/src/routes/v3/workspaces.ts b/backend/src/routes/v3/workspaces.ts index 834d54cd1..dcae733fc 100644 --- a/backend/src/routes/v3/workspaces.ts +++ b/backend/src/routes/v3/workspaces.ts @@ -34,4 +34,12 @@ router.post( // -- +router.get( + "/:workspaceId/service-token", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspacesController.getWorkspaceServiceTokenData +); + export default router; diff --git a/backend/src/services/FolderService.ts b/backend/src/services/FolderService.ts index cb4ef4286..65aa5b0f3 100644 --- a/backend/src/services/FolderService.ts +++ b/backend/src/services/FolderService.ts @@ -5,7 +5,7 @@ import path from "path"; type TAppendFolderDTO = { folderName: string; - parentFolderId?: string; + directory: string; }; type TRenameFolderDTO = { @@ -50,9 +50,8 @@ export const folderBfsTraversal = async ( // bfs and then append to the folder const appendChild = (folders: TFolderSchema, folderName: string) => { const folder = folders.children.find(({ name }) => name === folderName); - if (folder) { - throw new Error("Folder already exists"); - } + if (folder) return { folder, hasCreated: false }; + const id = generateFolderId(); folders.version += 1; folders.children.push({ @@ -61,24 +60,32 @@ const appendChild = (folders: TFolderSchema, folderName: string) => { children: [], version: 1 }); - return { id, name: folderName }; + // last element that is the new one + return { folder: folders.children[folders.children.length - 1], hasCreated: true }; }; // root of append child wrapper export const appendFolder = ( folders: TFolderSchema, - { folderName, parentFolderId }: TAppendFolderDTO -) => { - const isRoot = !parentFolderId; + { folderName, directory }: TAppendFolderDTO +): { parent: TFolderSchema; child: TFolderSchema; hasCreated?: boolean } => { + if (directory === "/") { + const newFolder = appendChild(folders, folderName); + return { parent: folders, child: newFolder.folder, hasCreated: newFolder.hasCreated }; + } - if (isRoot) { - return appendChild(folders, folderName); + const segments = directory.split("/").filter(Boolean); + const segment = segments.shift(); + if (segment) { + const nestedFolders = appendChild(folders, segment); + return appendFolder(nestedFolders.folder, { + folderName, + directory: path.join("/", ...segments) + }); } - const folder = searchByFolderId(folders, parentFolderId); - if (!folder) { - throw new Error("Parent Folder not found"); - } - return appendChild(folder, folderName); + + const newFolder = appendChild(folders, folderName); + return { parent: folders, child: newFolder.folder, hasCreated: newFolder.hasCreated }; }; export const renameFolder = ( diff --git a/backend/src/services/SecretImportService.ts b/backend/src/services/SecretImportService.ts index b432976fc..ffe4df9c6 100644 --- a/backend/src/services/SecretImportService.ts +++ b/backend/src/services/SecretImportService.ts @@ -1,9 +1,61 @@ import { Types } from "mongoose"; +import { generateSecretBlindIndexHelper } from "../helpers"; import { Folder, ISecret, Secret, SecretImport } from "../models"; import { getFolderByPath } from "./FolderService"; type TSecretImportFid = { environment: string; folderId: string; secretPath: string }; +export const getAnImportedSecret = async ( + secretName: string, + workspaceId: string, + environment: string, + folderId = "root" +) => { + const secretBlindIndex = await generateSecretBlindIndexHelper({ + secretName, + workspaceId: new Types.ObjectId(workspaceId) + }); + + const secImports = await SecretImport.findOne({ + workspace: workspaceId, + environment, + folderId + }); + if (!secImports) return; + if (secImports.imports.length === 0) return; + const folders = await Folder.find({ + workspace: workspaceId, + environment: { $in: secImports.imports.map((el) => el.environment) } + }); + + const importedSecByFid: TSecretImportFid[] = []; + secImports.imports.forEach((el) => { + const folder = folders.find((fl) => fl.environment === el.environment); + if (folder) { + const secPathFolder = getFolderByPath(folder.nodes, el.secretPath); + if (secPathFolder) + importedSecByFid.push({ + environment: el.environment, + folderId: secPathFolder.id, + secretPath: el.secretPath + }); + } else { + if (el.secretPath === "/") { + // this happens when importing with a fresh env without any folders + importedSecByFid.push({ environment: el.environment, folderId: "root", secretPath: "/" }); + } + } + }); + if (importedSecByFid.length === 0) return; + + const secret = await Secret.findOne({ + workspace: workspaceId, + secretBlindIndex + }).or(importedSecByFid.map(({ environment, folderId }) => ({ environment, folder: folderId }))).lean() + + return secret; +}; + export const getAllImportedSecrets = async ( workspaceId: string, environment: string, @@ -56,14 +108,6 @@ export const getAllImportedSecrets = async ( type: "shared" } }, - { - $lookup: { - from: "tags", // note this is the name of the collection in the database, not the Mongoose model name - localField: "tags", - foreignField: "_id", - as: "tags" - } - }, { $group: { _id: { diff --git a/backend/src/services/SecretService.ts b/backend/src/services/SecretService.ts index ef372bddc..109fe1507 100644 --- a/backend/src/services/SecretService.ts +++ b/backend/src/services/SecretService.ts @@ -1,21 +1,27 @@ import { Types } from "mongoose"; import { - CreateSecretParams, - DeleteSecretParams, - GetSecretParams, - GetSecretsParams, - UpdateSecretParams, + CreateSecretBatchParams, + CreateSecretParams, + DeleteSecretBatchParams, + DeleteSecretParams, + GetSecretParams, + GetSecretsParams, + UpdateSecretBatchParams, + UpdateSecretParams } from "../interfaces/services/SecretService"; -import { - createSecretBlindIndexDataHelper, - createSecretHelper, - deleteSecretHelper, - generateSecretBlindIndexHelper, - generateSecretBlindIndexWithSaltHelper, - getSecretBlindIndexSaltHelper, - getSecretHelper, - getSecretsHelper, - updateSecretHelper, +import { + createSecretBatchHelper, + createSecretBlindIndexDataHelper, + createSecretHelper, + deleteSecretBatchHelper, + deleteSecretHelper, + generateSecretBlindIndexHelper, + generateSecretBlindIndexWithSaltHelper, + getSecretBlindIndexSaltHelper, + getSecretHelper, + getSecretsHelper, + updateSecretBatchHelper, + updateSecretHelper } from "../helpers/secrets"; class SecretService { @@ -26,13 +32,9 @@ class SecretService { * @param {Buffer} obj.salt - 16-byte random salt * @param {Types.ObjectId} obj.workspaceId */ - static async createSecretBlindIndexData({ - workspaceId, - }: { - workspaceId: Types.ObjectId; - }) { + static async createSecretBlindIndexData({ workspaceId }: { workspaceId: Types.ObjectId }) { return await createSecretBlindIndexDataHelper({ - workspaceId, + workspaceId }); } @@ -42,13 +44,9 @@ class SecretService { * @param {Types.ObjectId} obj.workspaceId - id of workspace to get salt for * @returns */ - static async getSecretBlindIndexSalt({ - workspaceId, - }: { - workspaceId: Types.ObjectId; - }) { + static async getSecretBlindIndexSalt({ workspaceId }: { workspaceId: Types.ObjectId }) { return await getSecretBlindIndexSaltHelper({ - workspaceId, + workspaceId }); } @@ -61,14 +59,14 @@ class SecretService { */ static async generateSecretBlindIndexWithSalt({ secretName, - salt, + salt }: { secretName: string; salt: string; }) { return await generateSecretBlindIndexWithSaltHelper({ secretName, - salt, + salt }); } @@ -81,14 +79,14 @@ class SecretService { */ static async generateSecretBlindIndex({ secretName, - workspaceId, + workspaceId }: { secretName: string; workspaceId: Types.ObjectId; }) { return await generateSecretBlindIndexHelper({ secretName, - workspaceId, + workspaceId }); } @@ -163,6 +161,18 @@ class SecretService { static async deleteSecret(deleteSecretParams: DeleteSecretParams) { return await deleteSecretHelper(deleteSecretParams); } + + static async createSecretBatch(createSecretParams: CreateSecretBatchParams) { + return await createSecretBatchHelper(createSecretParams); + } + + static async updateSecretBatch(updateSecretParams: UpdateSecretBatchParams) { + return await updateSecretBatchHelper(updateSecretParams); + } + + static async deleteSecretBatch(deleteSecretParams: DeleteSecretBatchParams) { + return await deleteSecretBatchHelper(deleteSecretParams); + } } export default SecretService; diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index f7a1a504c..c1c0e6ac5 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -8,27 +8,34 @@ import { Organization, ServiceAccount, ServiceTokenData, + ServiceTokenDataV3, User } from "../models"; import { createToken } from "../helpers/auth"; import { + getAuthSecret, getClientIdGitHubLogin, + getClientIdGitLabLogin, getClientIdGoogleLogin, getClientSecretGitHubLogin, + getClientSecretGitLabLogin, getClientSecretGoogleLogin, getJwtProviderAuthLifetime, - getJwtProviderAuthSecret, + getSiteURL, + getUrlGitLabLogin } from "../config"; import { getSSOConfigHelper } from "../ee/helpers/organizations"; import { InternalServerError, OrganizationNotFoundError } from "./errors"; -import { ACCEPTED, INVITED, MEMBER } from "../variables"; -import { getSiteURL } from "../config"; +import { ACCEPTED, AuthTokenType, INTEGRATION_GITHUB_API_URL, INVITED, MEMBER } from "../variables"; +import { standardRequest } from "../config/request"; // eslint-disable-next-line @typescript-eslint/no-var-requires const GoogleStrategy = require("passport-google-oauth20").Strategy; // eslint-disable-next-line @typescript-eslint/no-var-requires const GitHubStrategy = require("passport-github").Strategy; // eslint-disable-next-line @typescript-eslint/no-var-requires +const GitLabStrategy = require("passport-gitlab2").Strategy; +// eslint-disable-next-line @typescript-eslint/no-var-requires const { MultiSamlStrategy } = require("@node-saml/passport-saml"); /** @@ -48,6 +55,10 @@ const getAuthDataPayloadIdObj = (authData: AuthData) => { if (authData.authPayload instanceof ServiceTokenData) { return { serviceTokenDataId: authData.authPayload._id }; } + + if (authData.authPayload instanceof ServiceTokenDataV3) { + return { serviceTokenDataId: authData.authPayload._id }; + } }; /** @@ -56,7 +67,6 @@ const getAuthDataPayloadIdObj = (authData: AuthData) => { * @returns */ const getAuthDataPayloadUserObj = (authData: AuthData) => { - if (authData.authPayload instanceof User) { return { user: authData.authPayload._id }; } @@ -66,7 +76,11 @@ const getAuthDataPayloadUserObj = (authData: AuthData) => { } if (authData.authPayload instanceof ServiceTokenData) { - return { user: authData.authPayload.user };0 + return { user: authData.authPayload.user }; + } + + if (authData.authPayload instanceof ServiceTokenDataV3) { + return { user: authData.authPayload.user }; } } @@ -75,6 +89,9 @@ const initializePassport = async () => { const clientSecretGoogleLogin = await getClientSecretGoogleLogin(); const clientIdGitHubLogin = await getClientIdGitHubLogin(); const clientSecretGitHubLogin = await getClientSecretGitHubLogin(); + const urlGitLab = await getUrlGitLabLogin(); + const clientIdGitLabLogin = await getClientIdGitLabLogin(); + const clientSecretGitLabLogin = await getClientSecretGitLabLogin(); if (clientIdGoogleLogin && clientSecretGoogleLogin) { passport.use(new GoogleStrategy({ @@ -114,6 +131,7 @@ const initializePassport = async () => { const isUserCompleted = !!user.publicKey; const providerAuthToken = createToken({ payload: { + authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user._id.toString(), email: user.email, firstName: user.firstName, @@ -126,7 +144,7 @@ const initializePassport = async () => { } : {}) }, expiresIn: await getJwtProviderAuthLifetime(), - secret: await getJwtProviderAuthSecret(), + secret: await getAuthSecret(), }); req.isUserCompleted = isUserCompleted; @@ -143,10 +161,28 @@ const initializePassport = async () => { passReqToCallback: true, clientID: clientIdGitHubLogin, clientSecret: clientSecretGitHubLogin, - callbackURL: "/api/v1/sso/github" + callbackURL: "/api/v1/sso/github", + scope: ["user:email"] }, async (req : express.Request, accessToken : any, refreshToken : any, profile : any, done : any) => { - const email = profile.emails[0].value; + interface GitHubEmail { + email: string; + primary: boolean; + verified: boolean; + visibility: null | string; + } + + const { data }: { data: GitHubEmail[] } = await standardRequest.get( + `${INTEGRATION_GITHUB_API_URL}/user/emails`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + const primaryEmail = data.filter((gitHubEmail: GitHubEmail) => gitHubEmail.primary)[0]; + const email = primaryEmail.email; let user = await User.findOne({ email @@ -169,6 +205,7 @@ const initializePassport = async () => { const isUserCompleted = !!user.publicKey; const providerAuthToken = createToken({ payload: { + authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user._id.toString(), email: user.email, firstName: user.firstName, @@ -181,7 +218,62 @@ const initializePassport = async () => { } : {}) }, expiresIn: await getJwtProviderAuthLifetime(), - secret: await getJwtProviderAuthSecret(), + secret: await getAuthSecret(), + }); + + req.isUserCompleted = isUserCompleted; + req.providerAuthToken = providerAuthToken; + return done(null, profile); + } + )); + } + + if (urlGitLab && clientIdGitLabLogin && clientSecretGitLabLogin) { + passport.use(new GitLabStrategy({ + passReqToCallback: true, + clientID: clientIdGitLabLogin, + clientSecret: clientSecretGitLabLogin, + callbackURL: "/api/v1/sso/gitlab", + baseURL: urlGitLab + }, + async (req : express.Request, accessToken : any, refreshToken : any, profile : any, done : any) => { + const email = profile.emails[0].value; + + let user = await User.findOne({ + email + }).select("+publicKey"); + + if (!user) { + user = await new User({ + email: email, + authMethods: [AuthMethod.GITLAB], + firstName: profile.displayName, + lastName: "" + }).save(); + } + + let isLinkingRequired = false; + if (!user.authMethods.includes(AuthMethod.GITLAB)) { + isLinkingRequired = true; + } + + const isUserCompleted = !!user.publicKey; + const providerAuthToken = createToken({ + payload: { + authTokenType: AuthTokenType.PROVIDER_TOKEN, + userId: user._id.toString(), + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + authMethod: AuthMethod.GITLAB, + isUserCompleted, + isLinkingRequired, + ...(req.query.state ? { + callbackPort: req.query.state as string + } : {}) + }, + expiresIn: await getJwtProviderAuthLifetime(), + secret: await getAuthSecret(), }); req.isUserCompleted = isUserCompleted; @@ -202,8 +294,7 @@ const initializePassport = async () => { }); interface ISAMLConfig { - path: string; - callbackURL: string; + callbackUrl: string; entryPoint: string; issuer: string; cert: string; @@ -212,8 +303,7 @@ const initializePassport = async () => { } const samlConfig: ISAMLConfig = ({ - path: `${await getSiteURL()}/api/v1/sso/saml2/${ssoIdentifier}`, - callbackURL: `${await getSiteURL()}/api/v1/sso/saml2${ssoIdentifier}`, + callbackUrl: `${await getSiteURL()}/api/v1/sso/saml2/${ssoIdentifier}`, entryPoint: ssoConfig.entryPoint, issuer: ssoConfig.issuer, cert: ssoConfig.cert, @@ -224,6 +314,12 @@ const initializePassport = async () => { samlConfig.wantAuthnResponseSigned = false; } + if (ssoConfig.authProvider.toString() === AuthMethod.AZURE_SAML.toString()) { + if (req.body.RelayState && JSON.parse(req.body.RelayState).spInitiated) { + samlConfig.audience = `spn:${ssoConfig.issuer}`; + } + } + req.ssoConfig = ssoConfig; done(null, samlConfig); @@ -308,6 +404,7 @@ const initializePassport = async () => { const isUserCompleted = !!user.publicKey; const providerAuthToken = createToken({ payload: { + authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user._id.toString(), email: user.email, firstName, @@ -316,11 +413,11 @@ const initializePassport = async () => { authMethod: req.ssoConfig.authProvider, isUserCompleted, ...(req.body.RelayState ? { - callbackPort: req.body.RelayState as string + callbackPort: JSON.parse(req.body.RelayState).callbackPort as string } : {}) }, expiresIn: await getJwtProviderAuthLifetime(), - secret: await getJwtProviderAuthSecret(), + secret: await getAuthSecret(), }); req.isUserCompleted = isUserCompleted; diff --git a/backend/src/utils/ip/ip.ts b/backend/src/utils/ip/ip.ts index ac3b17149..bc314fa9a 100644 --- a/backend/src/utils/ip/ip.ts +++ b/backend/src/utils/ip/ip.ts @@ -1,6 +1,6 @@ import net from "net"; import { IPType } from "../../ee/models"; -import { InternalServerError } from "../errors"; +import { InternalServerError, UnauthorizedRequestError } from "../errors"; /** * Return details of IP [ip]: @@ -98,4 +98,39 @@ export const isValidIpOrCidr = (ip: string): boolean => { } return false; -} \ No newline at end of file +} + +/** + * Validates the IP address [ipAddress] against the trusted IPs [trustedIps]. + * @param {Object} obj + * @param {String} obj.ipAddress - IP address to check + * @param {Object[]} obj.trustedIps - IPs to trust in blocklist + */ +export const checkIPAgainstBlocklist = ({ + ipAddress, + trustedIps +}: { + ipAddress: string; + trustedIps: { + ipAddress: string; + type: IPType; + prefix: number; + }[] +}) => { + const blockList = new net.BlockList(); + + for (const trustedIp of trustedIps) { + if (trustedIp.prefix !== undefined) { + blockList.addSubnet(trustedIp.ipAddress, trustedIp.prefix, trustedIp.type); + } else { + blockList.addAddress(trustedIp.ipAddress, trustedIp.type); + } + } + + const { type } = extractIPDetails(ipAddress); + const check = blockList.check(ipAddress, type); + + if (!check) throw UnauthorizedRequestError({ + message: "Failed to authenticate" + }); +} diff --git a/backend/src/utils/requestError.ts b/backend/src/utils/requestError.ts index 0c4e093b2..73d29dbb8 100644 --- a/backend/src/utils/requestError.ts +++ b/backend/src/utils/requestError.ts @@ -12,6 +12,27 @@ export enum LogLevel { EMERGENCY = 600, } +export const mapToWinstonLogLevel = (customLogLevel: LogLevel): string => { + switch (customLogLevel) { + case LogLevel.DEBUG: + return "debug"; + case LogLevel.INFO: + return "info"; + case LogLevel.NOTICE: + return "notice"; + case LogLevel.WARNING: + return "warn"; + case LogLevel.ERROR: + return "error"; + case LogLevel.CRITICAL: + return "crit"; + case LogLevel.ALERT: + return "alert"; + case LogLevel.EMERGENCY: + return "emerg"; + } +} + export type RequestErrorContext = { logLevel?: LogLevel, statusCode: number, @@ -87,7 +108,8 @@ export default class RequestError extends Error{ }, this.context) //* Omit sensitive information from context that can leak internal workings of this program if user is not developer - if(!(await getVerboseErrorOutput())){ + const verboseErrorOutput = await getVerboseErrorOutput(); + if (verboseErrorOutput !== undefined) { _context = this._omit(_context, [ "stacktrace", "exception", @@ -110,4 +132,4 @@ export default class RequestError extends Error{ return formatObject } -} \ No newline at end of file +} diff --git a/backend/src/utils/setup/backfillData.ts b/backend/src/utils/setup/backfillData.ts index b420761a7..3339d8927 100644 --- a/backend/src/utils/setup/backfillData.ts +++ b/backend/src/utils/setup/backfillData.ts @@ -4,7 +4,14 @@ import { Types } from "mongoose"; import { encryptSymmetric128BitHexKeyUTF8 } from "../crypto"; import { EESecretService } from "../../ee/services"; import { redisClient } from "../../services/RedisService" -import { IPType, ISecretVersion, SecretSnapshot, SecretVersion, TrustedIP } from "../../ee/models"; +import { + IPType, + ISecretVersion, + Role, + SecretSnapshot, + SecretVersion, + TrustedIP +} from "../../ee/models"; import { AuthMethod, BackupPrivateKey, @@ -34,14 +41,12 @@ import { MEMBER, OWNER } from "../../variables"; - import { InternalServerError } from "../errors"; import { ProjectPermissionActions, ProjectPermissionSub, memberProjectPermissions } from "../../ee/services/ProjectRoleService"; -import Role from "../../ee/models/role"; /** * Backfill secrets to ensure that they're all versioned and have @@ -819,3 +824,18 @@ export const backfillPermission = async () => { console.info("Could not acquire lock for script [backfillPermission], skipping"); } }; + +export const migrateRoleFromOwnerToAdmin = async () => { + await MembershipOrg.updateMany( + { + role: OWNER + }, + { + $set: { + role: ADMIN + } + } + ); + + console.info("Backfill: Finished converting owner role to member"); +} \ No newline at end of file diff --git a/backend/src/utils/setup/index.ts b/backend/src/utils/setup/index.ts index c4e06ff2c..6625bff7b 100644 --- a/backend/src/utils/setup/index.ts +++ b/backend/src/utils/setup/index.ts @@ -18,7 +18,8 @@ import { backfillServiceToken, backfillServiceTokenMultiScope, backfillTrustedIps, - backfillUserAuthMethods + backfillUserAuthMethods, + migrateRoleFromOwnerToAdmin } from "./backfillData"; import { reencryptBotOrgKeys, @@ -54,9 +55,6 @@ export const setup = async () => { // initializing global feature set await EELicenseService.initGlobalFeatureSet(); - // initializing the database connection - await DatabaseService.initDatabase(await getMongoURL()); - await initializePassport(); // re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY @@ -85,6 +83,7 @@ export const setup = async () => { await backfillTrustedIps(); await backfillUserAuthMethods(); // await backfillPermission(); + await migrateRoleFromOwnerToAdmin() // re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY // to base64 256-bit ROOT_ENCRYPTION_KEY diff --git a/backend/src/validation/folders.ts b/backend/src/validation/folders.ts index ecfadea1c..deda48b03 100644 --- a/backend/src/validation/folders.ts +++ b/backend/src/validation/folders.ts @@ -5,28 +5,30 @@ export const CreateFolderV1 = z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), folderName: z.string().trim(), - parentFolderId: z.string().trim().optional() + directory: z.string().trim().default("/") }) }); export const UpdateFolderV1 = z.object({ params: z.object({ - folderId: z.string().trim() + folderName: z.string().trim() }), body: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - name: z.string().trim() + name: z.string().trim(), + directory: z.string().trim().default("/") }) }); export const DeleteFolderV1 = z.object({ params: z.object({ - folderId: z.string().trim() + folderName: z.string().trim() }), body: z.object({ workspaceId: z.string().trim(), - environment: z.string().trim() + environment: z.string().trim(), + directory: z.string().trim().default("/") }) }); @@ -34,7 +36,6 @@ export const GetFoldersV1 = z.object({ query: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - parentFolderId: z.string().trim().optional(), - parentFolderPath: z.string().trim().optional() + directory: z.string().trim().default("/") }) }); diff --git a/backend/src/validation/index.ts b/backend/src/validation/index.ts index 409b563c6..a823f8095 100644 --- a/backend/src/validation/index.ts +++ b/backend/src/validation/index.ts @@ -9,3 +9,4 @@ export * from "./organization"; export * from "./secrets"; export * from "./serviceAccount"; export * from "./serviceTokenData"; +export * from "./serviceTokenDataV3"; diff --git a/backend/src/validation/integration.ts b/backend/src/validation/integration.ts index 7795b0084..98dadb924 100644 --- a/backend/src/validation/integration.ts +++ b/backend/src/validation/integration.ts @@ -58,6 +58,10 @@ export const validateClientForIntegration = async ({ throw UnauthorizedRequestError({ message: "Failed service token authorization for integration" }); + case ActorType.SERVICE_V3: + throw UnauthorizedRequestError({ + message: "Failed service token authorization for integration" + }); } }; @@ -76,13 +80,14 @@ export const CreateIntegrationV1 = z.object({ owner: z.string().trim().optional(), path: z.string().trim().optional(), region: z.string().trim().optional(), + scope: z.string().trim().optional(), metadata: z.object({ secretPrefix: z.string().optional(), secretSuffix: z.string().optional(), secretGCPLabel: z.object({ labelName: z.string(), labelValue: z.string() - }).optional() + }).optional(), }).optional() }) }); diff --git a/backend/src/validation/integrationAuth.ts b/backend/src/validation/integrationAuth.ts index cf3f936a9..65eee4477 100644 --- a/backend/src/validation/integrationAuth.ts +++ b/backend/src/validation/integrationAuth.ts @@ -58,6 +58,10 @@ const validateClientForIntegrationAuth = async ({ throw UnauthorizedRequestError({ message: "Failed service token authorization for integration authorization" }); + case ActorType.SERVICE_V3: + throw UnauthorizedRequestError({ + message: "Failed service token authorization for integration authorization" + }); } }; @@ -113,6 +117,39 @@ export const GetIntegrationAuthVercelBranchesV1 = z.object({ }) }); +export const GetIntegrationAuthQoveryOrgsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }) +}); + +export const GetIntegrationAuthQoveryProjectsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + orgId: z.string().trim() + }) +}); + +export const GetIntegrationAuthQoveryEnvironmentsV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + projectId: z.string().trim() + }) +}); + +export const GetIntegrationAuthQoveryScopesV1 = z.object({ + params: z.object({ + integrationAuthId: z.string().trim() + }), + query: z.object({ + environmentId: z.string().trim() + }) +}); + export const GetIntegrationAuthRailwayEnvironmentsV1 = z.object({ params: z.object({ integrationAuthId: z.string().trim() @@ -153,9 +190,11 @@ export const DeleteIntegrationAuthV1 = z.object({ }); export const GetIntegrationAuthTeamCityBuildConfigsV1 = z.object({ - params:z.object({ - appId:z.string().trim(), + params: z.object({ integrationAuthId:z.string().trim() + }), + query: z.object({ + appId:z.string().trim() }) }) diff --git a/backend/src/validation/organization.ts b/backend/src/validation/organization.ts index 9aa13cbb6..bddf76cb5 100644 --- a/backend/src/validation/organization.ts +++ b/backend/src/validation/organization.ts @@ -46,6 +46,10 @@ export const validateClientForOrganization = async ({ throw UnauthorizedRequestError({ message: "Failed service token authorization for organization" }); + case ActorType.SERVICE_V3: + throw UnauthorizedRequestError({ + message: "Failed service token authorization for organization" + }); } }; @@ -132,12 +136,6 @@ export const GetOrgLicencesv1 = z.object({ params: z.object({ organizationId: z.string().trim() }) }); -export const CreateOrgv1 = z.object({ - body: z.object({ - organizationName: z.string().trim() - }) -}); - export const GetOrgv1 = z.object({ params: z.object({ organizationId: z.string().trim() @@ -205,3 +203,13 @@ export const VerfiyUserToOrganizationV1 = z.object({ code: z.string().trim() }) }); + +export const CreateOrgv2 = z.object({ + body: z.object({ + name: z.string().trim() + }) +}); + +export const DeleteOrgv2 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); \ No newline at end of file diff --git a/backend/src/validation/secretImports.ts b/backend/src/validation/secretImports.ts index 92867a67b..a899a8346 100644 --- a/backend/src/validation/secretImports.ts +++ b/backend/src/validation/secretImports.ts @@ -4,7 +4,7 @@ export const CreateSecretImportV1 = z.object({ body: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - folderId: z.string().trim().default("root"), + directory: z.string().trim().default("/"), secretImport: z.object({ environment: z.string().trim(), secretPath: z.string().trim() @@ -40,7 +40,7 @@ export const GetSecretImportsV1 = z.object({ query: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - folderId: z.string().trim().default("root") + directory: z.string().trim().default("/") }) }); @@ -48,6 +48,6 @@ export const GetAllSecretsFromImportV1 = z.object({ query: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - folderId: z.string().trim().default("root") + directory: z.string().trim().default("/") }) }); diff --git a/backend/src/validation/secretScanning.ts b/backend/src/validation/secretScanning.ts index e16c67c5c..2a883f8f8 100644 --- a/backend/src/validation/secretScanning.ts +++ b/backend/src/validation/secretScanning.ts @@ -6,7 +6,7 @@ export const CreateInstalLSessionv1 = z.object({ export const LinkInstallationToOrgv1 = z.object({ body: z.object({ - installationId: z.number(), + installationId: z.string(), sessionId: z.string().trim() }) }); diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index 28e7f469d..174ffa791 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -244,7 +244,11 @@ export const GetSecretByNameRawV3 = z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), secretPath: z.string().trim().default("/"), - type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).optional() + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).optional(), + include_imports: z + .enum(["true", "false"]) + .default("true") + .transform((value) => value === "true") }) }); @@ -253,8 +257,11 @@ export const CreateSecretRawV3 = z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), secretPath: z.string().trim().default("/"), - secretValue: z.string().trim(), - secretComment: z.string().trim(), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())), + secretComment: z.string().trim().optional().default(""), + skipMultilineEncoding: z.boolean().optional(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]) }), params: z.object({ @@ -269,8 +276,11 @@ export const UpdateSecretByNameRawV3 = z.object({ body: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - secretValue: z.string().trim(), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())), secretPath: z.string().trim().default("/"), + skipMultilineEncoding: z.boolean().optional(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).default(SECRET_SHARED) }) }); @@ -305,7 +315,11 @@ export const GetSecretByNameV3 = z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), secretPath: z.string().trim().default("/"), - type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).optional() + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).optional(), + include_imports: z + .enum(["true", "false"]) + .default("true") + .transform((value) => value === "true") }), params: z.object({ secretName: z.string().trim() @@ -327,7 +341,8 @@ export const CreateSecretV3 = z.object({ secretCommentCiphertext: z.string().trim().optional(), secretCommentIV: z.string().trim().optional(), secretCommentTag: z.string().trim().optional(), - metadata: z.record(z.string()).optional() + metadata: z.record(z.string()).optional(), + skipMultilineEncoding: z.boolean().optional() }), params: z.object({ secretName: z.string().trim() @@ -338,11 +353,22 @@ export const UpdateSecretByNameV3 = z.object({ body: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), + secretId: z.string().trim().optional(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), secretPath: z.string().trim().default("/"), secretValueCiphertext: z.string().trim(), secretValueIV: z.string().trim(), - secretValueTag: z.string().trim() + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + tags: z.string().array().optional(), + skipMultilineEncoding: z.boolean().optional(), + // to update secret name + secretName: z.string().trim().optional(), + secretKeyIV: z.string().trim().optional(), + secretKeyTag: z.string().trim().optional(), + secretKeyCiphertext: z.string().trim().optional() }), params: z.object({ secretName: z.string() @@ -354,9 +380,74 @@ export const DeleteSecretByNameV3 = z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), - secretPath: z.string().trim().default("/") + secretPath: z.string().trim().default("/"), + secretId: z.string().trim().optional() }), params: z.object({ secretName: z.string() }) }); + +export const CreateSecretByNameBatchV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secrets: z + .object({ + secretName: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), + secretKeyCiphertext: z.string().trim(), + secretKeyIV: z.string().trim(), + secretKeyTag: z.string().trim(), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + metadata: z.record(z.string()).optional(), + skipMultilineEncoding: z.boolean().optional() + }) + .array() + .min(1) + }) +}); + +export const UpdateSecretByNameBatchV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secrets: z + .object({ + secretName: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), + secretValueCiphertext: z.string().trim(), + secretValueIV: z.string().trim(), + secretValueTag: z.string().trim(), + secretCommentCiphertext: z.string().trim().optional(), + secretCommentIV: z.string().trim().optional(), + secretCommentTag: z.string().trim().optional(), + skipMultilineEncoding: z.boolean().optional(), + tags: z.string().array().optional() + }) + .array() + .min(1) + }) +}); + +export const DeleteSecretByNameBatchV3 = z.object({ + body: z.object({ + workspaceId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + secrets: z + .object({ + secretName: z.string().trim(), + type: z.enum([SECRET_SHARED, SECRET_PERSONAL]) + }) + .array() + .min(1) + }) +}); diff --git a/backend/src/validation/serviceTokenDataV3.ts b/backend/src/validation/serviceTokenDataV3.ts new file mode 100644 index 000000000..976f1a1e5 --- /dev/null +++ b/backend/src/validation/serviceTokenDataV3.ts @@ -0,0 +1,119 @@ +import { Types } from "mongoose"; +import { IServiceTokenDataV3 } from "../models"; +import { Permission } from "../models/serviceTokenDataV3"; +import { z } from "zod"; +import { UnauthorizedRequestError } from "../utils/errors"; +import { isValidScopeV3 } from "../helpers"; +import { AuthData } from "../interfaces/middleware"; +import { checkIPAgainstBlocklist } from "../utils/ip"; + +/** + * Validate that service token (client) can access workspace + * with id [workspaceId] and its environment [environment] with required permissions + * [requiredPermissions] + * @param {Object} obj + * @param {ServiceTokenData} obj.serviceTokenData - service token client + * @param {Types.ObjectId} obj.workspaceId - id of workspace to validate against + * @param {String} environment - (optional) environment in workspace to validate against + * @param {String[]} acceptedPermissions - accepted permissions as part of the endpoint + */ + export const validateServiceTokenDataV3ClientForWorkspace = async ({ + authData, + serviceTokenData, + workspaceId, + environment, + secretPath = "/", + requiredPermissions +}: { + authData: AuthData; + serviceTokenData: IServiceTokenDataV3; + workspaceId: Types.ObjectId; + environment?: string; + secretPath?: string; + requiredPermissions: Permission[]; +}) => { + + // validate ST V3 IP address + checkIPAgainstBlocklist({ + ipAddress: authData.ipAddress, + trustedIps: serviceTokenData.trustedIps + }); + + if (!serviceTokenData.workspace.equals(workspaceId)) { + // case: invalid workspaceId passed + throw UnauthorizedRequestError({ + message: "Failed service token authorization for the given workspace" + }); + } + + if (environment) { + const isValid = isValidScopeV3({ + authPayload: serviceTokenData, + environment, + secretPath, + requiredPermissions + }); + + if (!isValid) throw UnauthorizedRequestError({ + message: "Failed service token authorization for the given workspace" + }); + } +}; + +export const CreateServiceTokenV3 = z.object({ + body: z.object({ + name: z.string().trim(), + workspaceId: z.string().trim(), + publicKey: z.string().trim(), + scopes: z + .object({ + permissions: z.enum(["read", "write"]).array(), + environment: z.string().trim(), + secretPath: z.string().trim() + }) + .array() + .min(1), + trustedIps: z + .object({ + ipAddress: z.string().trim(), + }) + .array() + .min(1), + expiresIn: z.number().optional(), + encryptedKey: z.string().trim(), + nonce: z.string().trim() + }) +}); + +export const UpdateServiceTokenV3 = z.object({ + params: z.object({ + serviceTokenDataId: z.string() + }), + body: z.object({ + name: z.string().trim().optional(), + isActive: z.boolean().optional(), + scopes: z + .object({ + permissions: z.enum(["read", "write"]).array(), + environment: z.string().trim(), + secretPath: z.string().trim() + }) + .array() + .min(1) + .optional(), + trustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + expiresIn: z.number().optional() + }), +}); + +export const DeleteServiceTokenV3 = z.object({ + params: z.object({ + serviceTokenDataId: z.string() + }), +}); \ No newline at end of file diff --git a/backend/src/validation/workspace.ts b/backend/src/validation/workspace.ts index 44044d923..5c4ae25c7 100644 --- a/backend/src/validation/workspace.ts +++ b/backend/src/validation/workspace.ts @@ -7,6 +7,7 @@ import { WorkspaceNotFoundError } from "../utils/errors"; import { AuthData } from "../interfaces/middleware"; import { z } from "zod"; import { EventType, UserAgentType } from "../ee/models"; +import { UnauthorizedRequestError } from "../utils/errors"; /** * Validate authenticated clients for workspace with id [workspaceId] based @@ -33,9 +34,10 @@ export const validateClientForWorkspace = async ({ }) => { const workspace = await Workspace.findById(workspaceId); - if (!workspace) throw WorkspaceNotFoundError({ - message: "Failed to find workspace" - }); + if (!workspace) + throw WorkspaceNotFoundError({ + message: "Failed to find workspace" + }); let membership; switch (authData.actor.type) { @@ -56,8 +58,11 @@ export const validateClientForWorkspace = async ({ environment, requiredPermissions }); - - return {}; + return { membership, workspace}; + case ActorType.SERVICE_V3: + throw UnauthorizedRequestError({ + message: "Failed service token authorization for organization" + }); } }; @@ -67,7 +72,7 @@ export const GetWorkspaceSecretSnapshotsV1 = z.object({ }), query: z.object({ environment: z.string().trim(), - folderId: z.string().trim().default("root"), + directory: z.string().trim().default("/"), offset: z.coerce.number(), limit: z.coerce.number() }) @@ -79,7 +84,7 @@ export const GetWorkspaceSecretSnapshotsCountV1 = z.object({ }), query: z.object({ environment: z.string().trim(), - folderId: z.string().trim().default("root") + directory: z.string().trim().default("/") }) }); @@ -89,7 +94,7 @@ export const RollbackWorkspaceSecretSnapshotV1 = z.object({ }), body: z.object({ environment: z.string().trim(), - folderId: z.string().trim().default("root"), + directory: z.string().trim().default("/"), version: z.number() }) }); @@ -270,7 +275,7 @@ export const ToggleAutoCapitalizationV2 = z.object({ workspaceId: z.string().trim() }), body: z.object({ - autoCapitalization: z.string().trim() + autoCapitalization: z.boolean() }) }); @@ -299,3 +304,9 @@ export const NameWorkspaceSecretsV3 = z.object({ .array() }) }); + +export const GetWorkspaceServiceTokenDataV3 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); \ No newline at end of file diff --git a/backend/src/variables/authentication.ts b/backend/src/variables/authentication.ts index be2f35c81..3aa06a200 100644 --- a/backend/src/variables/authentication.ts +++ b/backend/src/variables/authentication.ts @@ -1,6 +1,15 @@ +export enum AuthTokenType { + ACCESS_TOKEN = "accessToken", + REFRESH_TOKEN = "refreshToken", + SIGNUP_TOKEN = "signupToken", + MFA_TOKEN = "mfaToken", + PROVIDER_TOKEN = "providerToken" +} + export enum AuthMode { JWT = "jwt", SERVICE_TOKEN = "serviceToken", + SERVICE_TOKEN_V3 = "serviceTokenV3", API_KEY = "apiKey" } diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index e8bf78b19..e6e20e73c 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -28,6 +28,7 @@ export const INTEGRATION_TRAVISCI = "travisci"; export const INTEGRATION_TEAMCITY = "teamcity"; export const INTEGRATION_SUPABASE = "supabase"; export const INTEGRATION_CHECKLY = "checkly"; +export const INTEGRATION_QOVERY = "qovery"; export const INTEGRATION_TERRAFORM_CLOUD = "terraform-cloud"; export const INTEGRATION_HASHICORP_VAULT = "hashicorp-vault"; export const INTEGRATION_CLOUDFLARE_PAGES = "cloudflare-pages"; @@ -53,6 +54,7 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_TEAMCITY, INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, + INTEGRATION_QOVERY, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, @@ -82,7 +84,9 @@ export const INTEGRATION_BITBUCKET_TOKEN_URL = "https://bitbucket.org/site/oauth // integration apps endpoints export const INTEGRATION_GCP_API_URL = "https://cloudresourcemanager.googleapis.com"; export const INTEGRATION_HEROKU_API_URL = "https://api.heroku.com"; -export const INTEGRATION_GITLAB_API_URL = "https://gitlab.com/api"; +export const GITLAB_URL = "https://gitlab.com"; +export const INTEGRATION_GITLAB_API_URL = `${GITLAB_URL}/api`; +export const INTEGRATION_GITHUB_API_URL = "https://api.github.com"; export const INTEGRATION_VERCEL_API_URL = "https://api.vercel.com"; export const INTEGRATION_NETLIFY_API_URL = "https://api.netlify.com"; export const INTEGRATION_RENDER_API_URL = "https://api.render.com"; @@ -93,6 +97,7 @@ export const INTEGRATION_TRAVISCI_API_URL = "https://api.travis-ci.com"; export const INTEGRATION_SUPABASE_API_URL = "https://api.supabase.com"; export const INTEGRATION_LARAVELFORGE_API_URL = "https://forge.laravel.com"; export const INTEGRATION_CHECKLY_API_URL = "https://api.checklyhq.com"; +export const INTEGRATION_QOVERY_API_URL = "https://api.qovery.com"; export const INTEGRATION_TERRAFORM_CLOUD_API_URL = "https://app.terraform.io"; export const INTEGRATION_CLOUDFLARE_PAGES_API_URL = "https://api.cloudflare.com"; export const INTEGRATION_BITBUCKET_API_URL = "https://api.bitbucket.org"; @@ -272,6 +277,15 @@ export const getIntegrationOptions = async () => { clientId: "", docsLink: "", }, + { + name: "Qovery", + slug: "qovery", + image: "Qovery.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "", + }, { name: "HashiCorp Vault", slug: "hashicorp-vault", diff --git a/backend/src/variables/secret.ts b/backend/src/variables/secret.ts index 74be3a1e9..b24d8a101 100644 --- a/backend/src/variables/secret.ts +++ b/backend/src/variables/secret.ts @@ -1,3 +1,3 @@ // secrets export const SECRET_SHARED = "shared"; -export const SECRET_PERSONAL = "personal"; \ No newline at end of file +export const SECRET_PERSONAL = "personal"; diff --git a/cli/go.mod b/cli/go.mod index b8f5aa20e..c67ccb1e5 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -1,6 +1,6 @@ module github.com/Infisical/infisical-merge -go 1.19 +go 1.21 require ( github.com/charmbracelet/lipgloss v0.5.0 @@ -75,4 +75,4 @@ require ( github.com/zalando/go-keyring v0.2.3 ) -replace github.com/zalando/go-keyring => github.com/Infisical/go-keyring v1.0.1 +replace github.com/zalando/go-keyring => github.com/Infisical/go-keyring v1.0.2 diff --git a/cli/go.sum b/cli/go.sum index 114125554..fd46e8656 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -39,8 +39,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Infisical/go-keyring v1.0.1 h1:E8XpqoT0H1G9C1kgxU+NeReXOeobmH7LbBHNpcOI380= -github.com/Infisical/go-keyring v1.0.1/go.mod h1:LWOnn/sw9FxDW/0VY+jHFAfOFEe03xmwBVSfJnBowto= +github.com/Infisical/go-keyring v1.0.2 h1:dWOkI/pB/7RocfSJgGXbXxLDcVYsdslgjEPmVhb+nl8= +github.com/Infisical/go-keyring v1.0.2/go.mod h1:LWOnn/sw9FxDW/0VY+jHFAfOFEe03xmwBVSfJnBowto= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 2307a920a..1be58985c 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -25,7 +25,7 @@ func CallGetEncryptedWorkspaceKey(httpClient *resty.Client, request GetEncrypted } if response.IsError() { - return GetEncryptedWorkspaceKeyResponse{}, fmt.Errorf("CallGetEncryptedWorkspaceKey: Unsuccessful response: [response=%s]", response) + return GetEncryptedWorkspaceKeyResponse{}, fmt.Errorf("CallGetEncryptedWorkspaceKey: Unsuccessful response [%v %v] [status-code=%v]", response.Request.Method, response.Request.URL, response.StatusCode()) } return result, nil @@ -339,3 +339,23 @@ func CallGetSingleSecretByNameV3(httpClient *resty.Client, request CreateSecretV return nil } + +func CallCreateServiceToken(httpClient *resty.Client, request CreateServiceTokenRequest) (CreateServiceTokenResponse, error) { + var createServiceTokenResponse CreateServiceTokenResponse + response, err := httpClient. + R(). + SetResult(&createServiceTokenResponse). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v2/service-token/", config.INFISICAL_URL)) + + if err != nil { + return CreateServiceTokenResponse{}, fmt.Errorf("CallCreateServiceToken: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return CreateServiceTokenResponse{}, fmt.Errorf("CallCreateServiceToken: Unsuccessful response [%v %v] [status-code=%v]", response.Request.Method, response.Request.URL, response.StatusCode()) + } + + return createServiceTokenResponse, nil +} diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 027243ef5..1531dce36 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -387,3 +387,37 @@ type GetSingleSecretByNameSecretResponse struct { UpdatedAt time.Time `json:"updatedAt"` } `json:"secrets"` } + +type ScopePermission struct { + Environment string `json:"environment"` + SecretPath string `json:"secretPath"` +} + +type CreateServiceTokenRequest struct { + Name string `json:"name"` + WorkspaceId string `json:"workspaceId"` + Scopes []ScopePermission `json:"scopes"` + ExpiresIn int `json:"expiresIn"` + EncryptedKey string `json:"encryptedKey"` + Iv string `json:"iv"` + Tag string `json:"tag"` + RandomBytes string `json:"randomBytes"` + Permissions []string `json:"permissions"` +} + +type ServiceTokenData struct { + ID string `json:"_id"` + Name string `json:"name"` + Workspace string `json:"workspace"` + Scopes []interface{} `json:"scopes"` + User string `json:"user"` + LastUsed time.Time `json:"lastUsed"` + Permissions []string `json:"permissions"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CreateServiceTokenResponse struct { + ServiceToken string `json:"serviceToken"` + ServiceTokenData ServiceTokenData `json:"serviceTokenData"` +} diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index 66d251dc9..5cff7770f 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -518,7 +518,7 @@ func browserCliLogin() (models.UserCredentials, error) { SERVER_TIMEOUT := 60 * 10 //create listener - listener, err := net.Listen("tcp", "localhost:0") + listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return models.UserCredentials{}, err } diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 672beefcd..5c4d314f5 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -4,6 +4,7 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( + "crypto/sha256" "encoding/base64" "fmt" "regexp" @@ -11,8 +12,6 @@ import ( "strings" "unicode" - "crypto/sha256" - "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/crypto" "github.com/Infisical/infisical-merge/packages/models" @@ -198,7 +197,7 @@ var secretsSetCmd = &cobra.Command{ } // Key and value from argument - key := strings.ToUpper(splitKeyValueFromArg[0]) + key := splitKeyValueFromArg[0] value := splitKeyValueFromArg[1] hashedKey := fmt.Sprintf("%x", sha256.Sum256([]byte(key))) @@ -417,7 +416,7 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { secretsMap := getSecretsByKeys(secrets) for _, secretKeyFromArg := range args { - if value, ok := secretsMap[strings.ToUpper(secretKeyFromArg)]; ok { + if value, ok := secretsMap[secretKeyFromArg]; ok { requestedSecrets = append(requestedSecrets, value) } else { requestedSecrets = append(requestedSecrets, models.SingleEnvironmentVariable{ @@ -441,6 +440,11 @@ func generateExampleEnv(cmd *cobra.Command, args []string) { } } + secretsPath, err := cmd.Flags().GetString("path") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + infisicalToken, err := cmd.Flags().GetString("token") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -451,7 +455,7 @@ func generateExampleEnv(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs}) + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath}) if err != nil { util.HandleError(err, "To fetch all secrets") } @@ -625,7 +629,7 @@ func generateExampleEnv(cmd *cobra.Command, args []string) { func CenterString(s string, numStars int) string { stars := strings.Repeat("*", numStars) padding := (numStars - len(s)) / 2 - cenetredTextWithStar := stars[:padding] + " " + strings.ToUpper(s) + " " + stars[padding:] + cenetredTextWithStar := stars[:padding] + " " + s + " " + stars[padding:] hashes := strings.Repeat("#", len(cenetredTextWithStar)+2) return fmt.Sprintf("%s \n# %s \n%s", hashes, cenetredTextWithStar, hashes) @@ -650,8 +654,8 @@ func getSecretsByKeys(secrets []models.SingleEnvironmentVariable) map[string]mod } func init() { - secretsGenerateExampleEnvCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") + secretsGenerateExampleEnvCmd.Flags().String("path", "/", "Fetch secrets from within a folder path") secretsCmd.AddCommand(secretsGenerateExampleEnvCmd) secretsGetCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") diff --git a/cli/packages/cmd/tokens.go b/cli/packages/cmd/tokens.go new file mode 100644 index 000000000..e2851f88f --- /dev/null +++ b/cli/packages/cmd/tokens.go @@ -0,0 +1,190 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/crypto" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" + "github.com/spf13/cobra" +) + +var tokensCmd = &cobra.Command{ + Use: "service-token", + Short: "Manage service tokens", + DisableFlagsInUseLine: true, + Example: "infisical service-token", + Args: cobra.ExactArgs(0), + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + }, +} + +var tokensCreateCmd = &cobra.Command{ + Use: "create", + Short: "Used to create service tokens", + DisableFlagsInUseLine: true, + Example: "infisical service-token create", + Args: cobra.ExactArgs(0), + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + // get plain text workspace key + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() + + if err != nil { + util.HandleError(err, "Unable to retrieve your logged in your details. Please login in then try again") + } + + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + + tokenOnly, err := cmd.Flags().GetBool("token-only") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + workspaceId, err := cmd.Flags().GetString("projectId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if workspaceId == "" { + configFile, err := util.GetWorkSpaceFromFile() + if err != nil { + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") + } + workspaceId = configFile.WorkspaceId + } + + serviceTokenName, err := cmd.Flags().GetString("name") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + expireSeconds, err := cmd.Flags().GetInt("expiry-seconds") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + scopes, err := cmd.Flags().GetStringSlice("scope") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if len(scopes) == 0 { + util.PrintErrorMessageAndExit("You must define the environments and paths your service token should have access to via the --scope flag") + } + + permissions := []api.ScopePermission{} + + for _, scope := range scopes { + parts := strings.Split(scope, ":") + + if len(parts) != 2 { + fmt.Println("--scope flag is malformed. Each scope flag should be in the following format: :") + return + } + + permissions = append(permissions, api.ScopePermission{Environment: parts[0], SecretPath: parts[1]}) + } + + accessLevels, err := cmd.Flags().GetStringSlice("access-level") + if err != nil { + util.HandleError(err, "Unable to parse flag accessLevels") + } + + if len(accessLevels) == 0 { + util.PrintErrorMessageAndExit("You must define whether your service token can be used to read and or write via the --access-level flag") + } + + for _, accessLevel := range accessLevels { + if accessLevel != "read" && accessLevel != "write" { + util.PrintErrorMessageAndExit("--access-level can only be of values read and write") + } + } + + workspaceKey, err := util.GetPlainTextWorkspaceKey(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceId) + if err != nil { + util.HandleError(err, "Unable to get workspace key needed to create service token") + } + + newWorkspaceEncryptionKey := make([]byte, 16) + _, err = rand.Read(newWorkspaceEncryptionKey) + if err != nil { + util.HandleError(err) + } + + newWorkspaceEncryptionKeyHexFormat := hex.EncodeToString(newWorkspaceEncryptionKey) + + // encrypt the workspace key symmetrically + encryptedDetails, err := crypto.EncryptSymmetric(workspaceKey, []byte(newWorkspaceEncryptionKeyHexFormat)) + if err != nil { + util.HandleError(err) + } + + // make a call to the api to save the encrypted symmetric key details + httpClient := resty.New() + httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). + SetHeader("Accept", "application/json") + + createServiceTokenResponse, err := api.CallCreateServiceToken(httpClient, api.CreateServiceTokenRequest{ + Name: serviceTokenName, + WorkspaceId: workspaceId, + Scopes: permissions, + ExpiresIn: expireSeconds, + EncryptedKey: base64.StdEncoding.EncodeToString(encryptedDetails.CipherText), + Iv: base64.StdEncoding.EncodeToString(encryptedDetails.Nonce), + Tag: base64.StdEncoding.EncodeToString(encryptedDetails.AuthTag), + RandomBytes: newWorkspaceEncryptionKeyHexFormat, + Permissions: accessLevels, + }) + + if err != nil { + util.HandleError(err, "Unable to create service token") + } + + serviceToken := createServiceTokenResponse.ServiceToken + "." + newWorkspaceEncryptionKeyHexFormat + + if tokenOnly { + fmt.Println(serviceToken) + } else { + printablePermission := []string{} + for _, permission := range permissions { + printablePermission = append(printablePermission, fmt.Sprintf("([environment: %v] [path: %v])", permission.Environment, permission.SecretPath)) + } + + fmt.Printf("New service token created\n") + fmt.Printf("Name: %v\n", serviceTokenName) + fmt.Printf("Project ID: %v\n", workspaceId) + fmt.Printf("Access type: [%v]\n", strings.Join(accessLevels, ", ")) + fmt.Printf("Permission(s): %v\n", strings.Join(printablePermission, ", ")) + fmt.Printf("Service Token: %v\n", serviceToken) + } + }, +} + +func init() { + tokensCreateCmd.Flags().String("projectId", "", "The project ID you'd like to create the service token for. Default: will use linked Infisical project in .infisical.json") + tokensCreateCmd.Flags().StringSliceP("scope", "s", []string{}, "Environment and secret path. Example format: :") + tokensCreateCmd.Flags().StringP("name", "n", "Service token generated via CLI", "Service token name") + tokensCreateCmd.Flags().StringSliceP("access-level", "a", []string{}, "The type of access the service token should have. Can be 'read' and or 'write'") + tokensCreateCmd.Flags().Bool("token-only", false, "When true, only the service token will be printed") + tokensCreateCmd.Flags().IntP("expiry-seconds", "e", 86400, "Set the service token's expiration time in seconds from now. To never expire set to zero. Default: 1 day ") + + tokensCmd.AddCommand(tokensCreateCmd) + + rootCmd.AddCommand(tokensCmd) +} diff --git a/cli/packages/cmd/user.go b/cli/packages/cmd/user.go index 0d8f1b214..96b03a0bb 100644 --- a/cli/packages/cmd/user.go +++ b/cli/packages/cmd/user.go @@ -14,7 +14,7 @@ import ( var userCmd = &cobra.Command{ Use: "user", - Short: "Used to manage user credentials", + Short: "Used to manage local user credentials", DisableFlagsInUseLine: true, Example: "infisical user", Args: cobra.ExactArgs(0), diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index a8d248a3b..73be43319 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -684,3 +684,44 @@ func GetEnvelopmentBasedOnGitBranch(workspaceFile models.WorkspaceConfigFile) st return "" } } + +func GetPlainTextWorkspaceKey(authenticationToken string, receiverPrivateKey string, workspaceId string) ([]byte, error) { + httpClient := resty.New() + httpClient.SetAuthToken(authenticationToken). + SetHeader("Accept", "application/json") + + request := api.GetEncryptedWorkspaceKeyRequest{ + WorkspaceId: workspaceId, + } + + workspaceKeyResponse, err := api.CallGetEncryptedWorkspaceKey(httpClient, request) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: unable to retrieve your encrypted workspace key. [err=%v]", err) + } + + encryptedWorkspaceKey, err := base64.StdEncoding.DecodeString(workspaceKeyResponse.EncryptedKey) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Unable to get bytes represented by the base64 for encryptedWorkspaceKey [err=%v]", err) + } + + encryptedWorkspaceKeySenderPublicKey, err := base64.StdEncoding.DecodeString(workspaceKeyResponse.Sender.PublicKey) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Unable to get bytes represented by the base64 for encryptedWorkspaceKeySenderPublicKey [err=%v]", err) + } + + encryptedWorkspaceKeyNonce, err := base64.StdEncoding.DecodeString(workspaceKeyResponse.Nonce) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Unable to get bytes represented by the base64 for encryptedWorkspaceKeyNonce [err=%v]", err) + } + + currentUsersPrivateKey, err := base64.StdEncoding.DecodeString(receiverPrivateKey) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Unable to get bytes represented by the base64 for currentUsersPrivateKey [err=%v]", err) + } + + if len(currentUsersPrivateKey) == 0 || len(encryptedWorkspaceKeySenderPublicKey) == 0 { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Missing credentials for generating plainTextEncryptionKey") + } + + return crypto.DecryptAsymmetric(encryptedWorkspaceKey, encryptedWorkspaceKeyNonce, encryptedWorkspaceKeySenderPublicKey, currentUsersPrivateKey), nil +} diff --git a/docker-compose.yml b/docker-compose.yml index c6108ea7f..57e5b5b63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,46 +1,20 @@ version: "3" services: - nginx: - container_name: infisical-nginx - image: nginx - restart: always - ports: - - 80:80 - - 443:443 - volumes: - - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - depends_on: - - frontend - - backend - networks: - - infisical - backend: container_name: infisical-backend restart: unless-stopped depends_on: - mongo - image: infisical/backend + image: infisical/infisical:latest env_file: .env + ports: + - 80:8080 environment: - NODE_ENV=production networks: - infisical - frontend: - container_name: infisical-frontend - restart: unless-stopped - depends_on: - - backend - image: infisical/frontend - env_file: .env - environment: - # - NEXT_PUBLIC_POSTHOG_API_KEY=${POSTHOG_PROJECT_API_KEY} - - INFISICAL_TELEMETRY_ENABLED=${TELEMETRY_ENABLED} - networks: - - infisical - redis: image: redis container_name: infisical-dev-redis diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index bdf820ad2..0e0b4b11e 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -4,6 +4,12 @@ title: "Changelog" The changelog below reflects new product developments and updates on a monthly basis. +## September + +- Released an update to access controls; every user role now clearly defines and enforces a certain set of conditions across Infisical. +- Updated UI/UX for integrations. +- Added a native integration with [Qovery](https://infisical.com/docs/integrations/cloud/qovery). + ## August 2023 - Release Audit Logs V2. diff --git a/docs/cli/commands/export.mdx b/docs/cli/commands/export.mdx index 9ba15c34d..49446e734 100644 --- a/docs/cli/commands/export.mdx +++ b/docs/cli/commands/export.mdx @@ -88,7 +88,7 @@ Export environment variables from the platform into a file format. - Format of the output file. Accepted values: `dotenv`, `dotenv-export`, `csv` and `json` + Format of the output file. Accepted values: `dotenv`, `dotenv-export`, `csv`, `json` and `yaml` Default value: `dotenv` diff --git a/docs/cli/commands/service-token.mdx b/docs/cli/commands/service-token.mdx new file mode 100644 index 000000000..007f845dc --- /dev/null +++ b/docs/cli/commands/service-token.mdx @@ -0,0 +1,81 @@ +--- +title: "infisical service-token" +description: "Manage Infisical service tokens" +--- + +```bash +infisical service-token create --scope=dev:/global --scope=dev:/backend --access-level=read --access-level=write +``` + +## Description +The Infisical `service-token` command allows you to manage service tokens for a given Infisical project. +With this command, you can create, view, and delete service tokens. + + + Use this command to create a service token + + ```bash + $ infisical service-token create --scope=dev:/backend/** --access-level=read --access-level=write + ``` + + ### Flags + + ```bash + infisical service-token create --scope=dev:/global --scope=dev:/backend/** --access-level=read + ``` + + Use the scope flag to define which environments and paths your service token should be authorized to access. + + The value of your scope flag should be in the following `:`. + Here, `environment slug` refers to the slug name of the environment, and `path` indicates the folder path where your secrets are stored. + + For specifying multiple scopes, you can use multiple --scope flags. + + + The `path` can be a Glob pattern + + + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --projectId=63cefb15c8d3175601cfa989 + ``` + + The project ID you'd like to create the service token for. + By default, the CLI will attempt to use the linked Infisical project in `.infisical.json` generated by `infisical init` command. + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --name service-token-name + ``` + + Service token name + + Default: `Service token generated via CLI` + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --expiry-seconds 120 + ``` + + Set the service token's expiration time in seconds from now. To never expire set to zero. + + Default: `1 day` + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --access-level=write + ``` + + The type of access the service token should have. Can be `read` and or `write` + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --access-level=write --token-only + ``` + + When true, only the service token will be printed + + Default: `false` + + diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index 1f04e1763..84d34e871 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -2,7 +2,8 @@ title: "Introduction" --- -Infisical is an [open-source](https://opensource.com/resources/what-open-source), [end-to-end encrypted](https://en.wikipedia.org/wiki/End-to-end_encryption) secret management platform that enables teams to easily manage and sync their environment variables. +Infisical is an [open-source](https://opensource.com/resources/what-open-source), [end-to-end encrypted](https://en.wikipedia.org/wiki/End-to-end_encryption) secret management platform for storing, managing, and syncing +application configuration and secrets like API keys, database credentials, and environment variables across applications and infrastructure. Start syncing environment variables with [Infisical Cloud](https://app.infisical.com) or learn how to [host Infisical](/self-hosting/overview) yourself. diff --git a/docs/documentation/getting-started/platform.mdx b/docs/documentation/getting-started/platform.mdx index a21352a09..429524161 100644 --- a/docs/documentation/getting-started/platform.mdx +++ b/docs/documentation/getting-started/platform.mdx @@ -2,24 +2,47 @@ title: "Platform" --- -Infisical is an [open-source](https://opensource.com/resources/what-open-source), [end-to-end encrypted](https://en.wikipedia.org/wiki/End-to-end_encryption) secret management platform that enables teams to easily store, manage, and sync secrets like API keys, database credentials, and environment variables across their apps and infrastructure. +This quickstart provides an overview of functionalities offered by Infisical. -This quickstart provides an overview of the functionalities offered by Infisical. +## Managing your Organization -## Projects +When you first make an account with Infisical, you also create a new **organization** where you are assigned the `admin` role by default. +From there, you can invite external members to the organization and start creating **projects** to house secrets. -Projects hold secrets for applications, which are further organized into environments such as development, testing and production. +### Projects + +The **Projects** page shows you all the projects that you have access to within your organization. +Here, you can also create a new project. + +![organization overview](../../images/organization-overview.png) + +### Members + +The **Members** page lets you add or remove external members to your organization. +Note that you can configure your organization in Infisical to have members authenticate with the platform via protocols like SAML 2.0. + +![organization members](../../images/organization-members.png) + +## Managing your Projects + +As mentioned before, projects house secrets which are further organized into environments such as development, testing and production. +A project can be anything from a single application to a collection of micro-services that you wish to manage secrets for. ### Secrets Overview -The secrets overview provides a bird's-eye view of all the secrets in a project and is particularly useful for identifying missing secrets across environments. +The **Secrets Overview** screen provides a bird's-eye view of all the secrets in a project and is useful for comparing secrets and identifying missing ones across environments. ![dashboard secrets overview](../../images/dashboard-secrets-overview.png) -### Secrets Dashboard +In the above image, you can already see that: +- `STRIPE_API_KEY` is missing from the **Staging** environment. +- `JWT_SECRET` is missing from the **Production** environment. +- `BAR` is `EMPTY` in the **Production** environment. + +### Dashboard The secrets dashboard lets you manage secrets for a specific environment in a project. -Here, developers can [override secrets](//project#personal-overrides), [version secrets](/documentation/platform/secret-versioning), [rollback projects to any point in time](/documentation/platform/pit-recovery), and much more. +Here, developers can override secrets, version secrets, rollback projects to any point in time and much more. ![dashboard](../../images/dashboard.png) @@ -27,31 +50,15 @@ Here, developers can [override secrets](//project#personal-overrides), [version The integrations page provides native integrations to sync secrets from a project environment to a [host of ever-expanding integrations](/integrations/overview). - - Depending on your infrastructure setup and compliance requirements, you may or may not prefer to use these native integrations since they break end-to-end encryption (E2EE). - - You will learn about various ways to integrate with Infisical and maintain E2EE in subsequent quickstart sections. - - ![integrations](../../images/integrations.png) -### Access Control +### Members -The members page lets you add/remove members for a project and provision them access to environments (access levels include `No Access`, `Read Only`, and `Read and Write`). +The members page lets you add/remove members to/from a project and provision them access to environments via roles. By default, Infisical provides the `admin`, `developer`, and `viewer` roles +which you can assign to members. ![project members](../../images/project-members.png) -## Organizations - -Organizations house projects and members. - -### Organization Settings - -At the organization-level, you can add/remove members and manage their access to projects. - -![organization name modal open](../../images/dashboard-name-modal-organization.png) -![organization name modal open](../../images/organization.png) - That's it for the platform quickstart! โ€” We encourage you to continue exploring the documentation to gain a deeper understanding of the extensive features and functionalities that Infisical has to offer. Next, head back to [Getting Started > Introduction](/documentation/getting-started/overview) to explore ways to fetch secrets from Infisical to your apps and infrastructure. \ No newline at end of file diff --git a/docs/documentation/getting-started/sdks.mdx b/docs/documentation/getting-started/sdks.mdx index 67608bb81..a406c3ae6 100644 --- a/docs/documentation/getting-started/sdks.mdx +++ b/docs/documentation/getting-started/sdks.mdx @@ -9,137 +9,11 @@ Prerequisites: - Have a project with secrets ready in [Infisical Cloud](https://app.infisical.com). - Create an [Infisical Token](/documentation/platform/token) scoped to an environment in your project in Infisical. -## Installation +## Usage -Follow the instructions for your language to install the SDK for it. +Follow the instructions for your language use the SDK for it: - - +- [Node SDK](https://github.com/Infisical/infisical-node) +- [Python SDK](https://github.com/Infisical/infisical-python) - Run `npm` to add [infisical-node](https://github.com/Infisical/infisical-node) to your project. - - ```console - $ npm install infisical-node --save - ``` - - ## Configuration - - Import the SDK and create a client instance with your [Infisical Token](/documentation/platform/token). - - - - ```js - import InfisicalClient from "infisical-node"; - - const client = new InfisicalClient({ - token: "your_infisical_token" - }); - ``` - - - - ```js - const InfisicalClient = require("infisical-node"); - - const client = new InfisicalClient({ - token: "your_infisical_token" - }); - ```` - - - ## Get a Secret - - ```js - const secret = await client.getSecret("API_KEY"); - const value = secret.secretValue; // get its value - ``` - - ## Basic Usage - - ```js - import express from "express"; - import InfisicalClient from "infisical-node"; - const app = express(); - const PORT = 3000; - - const client = new InfisicalClient({ - token: "YOUR_INFISICAL_TOKEN" - }); - - app.get("/", async (req, res) => { - // access value - const name = await client.getSecret("NAME"); - res.send(`Hello! My name is: ${name.secretValue}`); - }); - - app.listen(PORT, async () => { - console.log(`App listening on port ${port}`); - }); - ``` - - This example demonstrates how to use the Infisical Node SDK with an Express application. The application retrieves a secret named "NAME" and responds to requests with a greeting that includes the secret value. - - - - ## Installation - - Run `pip` to add [infisical-python](https://github.com/Astropilot/infisical-python) to your project - - ```console - $ pip install infisical - ``` - - Note: You need Python 3.7+. - - ## Configuration - - Import the SDK and create a client instance with your [Infisical Token](/documentation/platform/token). - - ```py - from infisical import InfisicalClient - - client = InfisicalClient(token="your_infisical_token") - ``` - - ## Get a Secret - - ```py - secret = client.get_secret("API_KEY") - value = secret.secret_value # get its value - ``` - - ## Basic Usage - - ```py - from flask import Flask - from infisical import InfisicalClient - - app = Flask(__name__) - - client = InfisicalClient(token="your_infisical_token") - - @app.route("/") - def hello_world(): - # access value - name = client.get_secret("NAME") - return f"Hello! My name is: {name.secret_value}" - ``` - - This example demonstrates how to use the Infisical Python SDK with a Flask application. The application retrieves a secret named "NAME" and responds to requests with a greeting that includes the secret value. - - - We're currently working on SDKs for other languages. Follow the GitHub issue for your needed language below: - - [Java](https://github.com/Infisical/infisical/issues/434) - - [Ruby](https://github.com/Infisical/infisical/issues/435) - - [Go](https://github.com/Infisical/infisical/issues/436) - - [Rust](https://github.com/Infisical/infisical/issues/437) - - [PHP](https://github.com/Infisical/infisical/issues/531) - - Missing a language? [Throw in a request](https://github.com/Infisical/infisical/issues). - - - -See also: - -- Explore the [Node SDK](https://github.com/Infisical/infisical-node) -- Explore the [Python SDK](https://github.com/Infisical/infisical-python) \ No newline at end of file +Missing a language? [Throw in a request](https://github.com/Infisical/infisical/issues). \ No newline at end of file diff --git a/docs/documentation/platform/audit-logs.mdx b/docs/documentation/platform/audit-logs.mdx index 4ec221f0c..5e6adb8e7 100644 --- a/docs/documentation/platform/audit-logs.mdx +++ b/docs/documentation/platform/audit-logs.mdx @@ -3,10 +3,25 @@ title: "Audit Logs" description: "See which events are triggered within your Infisical project." --- -Audit 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: + + Note that Audit Logs is a paid feature. -- 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? + If you're using Infisical Cloud, then it is available under the **Team Tier**, **Pro Tier**, + and **Enterprise Tier** with varying retention periods. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + -![Audit logs](../../images/activity-logs.png) +Infisical provides audit logs for security and compliance teams to monitor information access. +With this feature, teams can track 25+ different events; +filter audit logs by event, actor, source, date or any combination of these filters; +and inspect extensive metadata in the event of any suspicious activity or incident review. + +![Audit logs](../../images/platform/audit-logs/audit-logs-table.png) + +Each log contains the following data: + +- Event: The underlying action such as create, list, read, update, or delete secret(s). +- Actor: The entity responsible for performing or causing the event; this can be a user or service. +- Timestamp: The date and time at which point the event occured. +- Source (User agent + IP): The software (user agent) and network address (IP) from which the event was initiated. +- Metadata: Additional data to provide context for each event. For example, this could be the path at which a secret was fetched from etc. \ No newline at end of file diff --git a/docs/documentation/platform/folder.mdx b/docs/documentation/platform/folder.mdx index 506b30da2..162cabfb9 100644 --- a/docs/documentation/platform/folder.mdx +++ b/docs/documentation/platform/folder.mdx @@ -3,28 +3,50 @@ title: "Folders" description: "Organize your secrets with folders" --- -Folders provide a powerful and intuitive way to structure your secrets. -They offer a system to keep your secrets organized and easily accessible, which becomes increasingly important as your collection of secrets grows. +Infisical's folder feature lets you store secrets at a specific folder; we also call this **path-based secret storage**. +This is great for organizing secrets around hierarchies when multiple services, types of secrets, etc. are involved at great quantities. +With folders that can go infinitely deep, you can mirror your application architecture (be it microservices or monorepos) +or any logical grouping that best suits your needs. -With folders in Infisical, you can now create a hierarchy of folders to organize your secrets, mirroring your application's architecture or any logical grouping that suits your needs. -Whether you follow a microservices architecture or work with monorepos, folders make it simpler to locate, manage and collaborate between teams. +Consider the following structure for a microservice architecture: +``` +| service1 +|---- envars +|---- users +|-------- tokens1 +|-------- tokens2 +| service2 +|---- envars +... +``` -## Creating a folder +In this example, we store environment variables for each microservice under each respective `/envars` folder. +We also store user-specific secrets for micro-service 1 under `/service1/users`. With this folder structure in place, your applications only need to specify a path like `/microservice1/envars` to fetch secrets from there. +By extending this example, you can see how path-based secret storage provides a versatile approach to manage secrets for any architecture. -To create a folder, head over to the environment where you'd like to create the folder. Once there, click the `Add folder` button as shown below. -If you wish to create nested folders, simply click into the folder of choice and click `Add folder` button again. +## Folders -![dashboard add folders](../../images/dashboard-add-folder.png) +### Managing folders + +To add a folder, press the downward chevron to the right of the **Add Secret** button; then press on the **Add Folder** button. Folder names can only contain alphabets, numbers, and dashes -## Compare folders across environments +![add folder](../../images/platform/folder/folders-add.png) -The overview screen provides a comprehensive view of all your secrets and folders, organized by environment. +To delete a folder, hover over it and press the **X** button that appears on the right side. -![dashboard secret overview with folders](../../images/dashboard-folder-overview.png) +![delete folder](../../images/platform/folder/folders-delete.png) -When you click on a folder, the overview will be updated to show only the secrets and folders in that folder. This allows you to compare secrets across environment regardless of how deeply nested your folders are. +### Comparing folders + +It's possible to compare the contents of folders across environments in the **Secrets Overview** page. +When you click on a folder, the table will display the items within it across environments. + +In the image below, you can see that the **Development** environment is the only one that contains items +in the `/users` folder, being other folders `/user-a`, `/user-b`, ... `/user-f`. + +![comparing folders](../../images/platform/folder/folders-secrets-overview.png) \ No newline at end of file diff --git a/docs/documentation/platform/ip-allowlisting.mdx b/docs/documentation/platform/ip-allowlisting.mdx index b037d50d2..f0844e685 100644 --- a/docs/documentation/platform/ip-allowlisting.mdx +++ b/docs/documentation/platform/ip-allowlisting.mdx @@ -3,6 +3,20 @@ title: "IP Allowlisting" description: "Restrict access to your secrets in Infisical using trusted IPs" --- + + IP allowlisting at the project-level is being replaced with IP allowlisting at the token-level now available with the Service Token V3 authentication method. + + Instead of providing trusted IPs (specific IPs and CIDR ranges) to be applied across all service tokens, + you can now specify trusted IPs at the token-level. + + + + Note that IP Allowlisting is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + Projects in Infisical can be configured to restrict client access to specific IP addresses or CIDR ranges. This applies to any client using service tokens and can be useful, for example, for limiting access to traffic coming from corporate networks. @@ -13,7 +27,7 @@ For enhanced security, we strongly recommend replacing the default entry with yo You must be a project `admin` to manage your project's IP whitelist. -![IP whitelist](../../images/project-ip-whitelist.png) +![IP whitelist](../../images/platform/ip-allowlisting/ip-allowlisting-table.png) ## Creating a trusted IP entry @@ -21,4 +35,4 @@ To create a trusted IP entry, head over to the **IP Whitelist** tab in your proj you can specify either a specific IP address like `192.0.2.1` or a CIDR range like `2001:db8::/32`; both IPv4 and IPv6 formats are accepted. -![IP whitelist add](../../images/project-ip-whitelist-add.png) +![IP whitelist add](../../images/platform/ip-allowlisting/ip-allowlisting-modal.png) diff --git a/docs/documentation/platform/organization.mdx b/docs/documentation/platform/organization.mdx index 161a79f14..82a4058a5 100644 --- a/docs/documentation/platform/organization.mdx +++ b/docs/documentation/platform/organization.mdx @@ -5,37 +5,63 @@ description: "How Infisical structures its organizations." An organization houses projects and members. -By default, Infisical creates an organization under your name. You can manage your organization in your organization settings. +## Projects -![organization name modal open](../../images/dashboard-name-modal-organization.png) -![organization name modal open](../../images/organization.png) +The **Projects** page is where you can view the projects that you have access to within your organization +as well as create a new project. + +![organization](../../images/platform/organization/organization-projects.png) + +## Settings + +The **Settings** page lets you manage information about your organization including: + +- Name: The name of your organization. +- Incident contacts: Emails that should be alerted if anything abnormal is detected within the organization. +- SAML Authentication: The SAML SSO configuration of the organization (if applicable); Infisical currently +supports Okta, Azure, and JumpCloud identity providers. + +![organization settings general](../../images/platform/organization/organization-settings-general.png) +![organization settings auth](../../images/platform/organization/organization-settings-auth.png) ## Members -Members of an organization can create and add other members to projects within that organization. - -To add a member to your organization, scroll down to the "Organization Members" section and invite the member via email. They'll receive an email to confirm their organization invitation. If the member is an existing user on the platform, they will be automatically added to the organization. +The **Members** page is where you can manage members and their permissions within the organization. +In the **Members** tab, you can add external members to your organization or remove them; you can also +change their role. ![organization members](../../images/organization-members.png) - - Note that access to projects must be provisioned to new members after they've - accepted their organization invitation, and they will not be added to any - projects by default. - +In the **Roles** tab, you can manage roles for members within the organization. -## Service Accounts + + Note that Role-Based Access Management (RBAC) is partly a paid feature. + + Infisical provides immutable roles like `admin`, `member`, etc. + at the organization and project level for free. -Service accounts represent machine identities such as VMs or application clients that can authenticate with Infisical. They can be provisioned read/write permissions for project(s) and environment(s). + If you're using Infisical Cloud, the ability to create custom roles is available under the **Pro Tier**. + If you're self-hosting Infisical, then you should contact team@infisical.com to purchase an enterprise license to use it. + -To add a service account to your organization, scroll down to the "Service Accounts" section and create a service account. Afterwards, you can press on the edit button beside the service account to provision it permissions. +![organization roles](../../images/platform/organization/organization-members-roles.png) -![organization service accounts](../../images/organization-service-accounts.png) +As you can see next, Infisical supports granular permissions that you can tailor to each role. So, +if you need certain members to only be able to access billing details, for example, then you can +assign them that permission only. -## Incident contacts +![organization role permissions](../../images/platform/organization/organization-members-roles-add-perm.png) -Incident contacts of an organization are alerted if anything abnormal is detected within the operations of an organization. +## Usage & Billing -To add an incident contact to your organization, scroll down to the "Incident Contacts" section and add their email. +The **Usage & Billing** page applies only to [Infisical Cloud](https://app.infisical.com) and is where you can +manage your plan and billing information. -![organization incident contacts](../../images/organization-ic.png) +This includes the following items: + +- Current plan: The current plan information such as what tier your organization is on and what features/limits apply to this tier. +- Licenses: The license keys for self-hosted instances of Infisical (if applicable). +- Receipts: The receipts of monthly/annual invoices. +- Billing: The billing details of your organization including payment methods on file, tax IDs (if applicable), etc. + +![organization usage and billing](../../images/platform/organization/organization-usage-billing.png) \ No newline at end of file diff --git a/docs/documentation/platform/pit-recovery.mdx b/docs/documentation/platform/pit-recovery.mdx index 61f94f987..3cad5eff2 100644 --- a/docs/documentation/platform/pit-recovery.mdx +++ b/docs/documentation/platform/pit-recovery.mdx @@ -3,26 +3,37 @@ title: "Point-in-Time Recovery" description: "How to rollback secrets and configs to any commit with Infisical." --- -Point-in-time recovery allows secrets to be rolled back to any point in time. -It's powered by snapshots that get created after every mutations to a secret within a given [folder](./folder) and environment. + + Point-in-Time Recovery is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Team Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + -## Commits +Infisical's point-in-time recovery feature allows secrets to be rolled back to any point in time for any given [folder](./folder). +Under the hood, snapshots, capturing the state of the folder, get taken after any mutation an item within that folder. -Similar to Git, a commit in Infisical is a snapshot of your project's secrets at a specific point in time scoped to the environment and [folder](./folder) it is in. You can browse and view your project's snapshots via the "Point-in-Time Recovery" sidebar. +## Snapshots -![PIT commits](../../images/pit-commits.png) -![PIT snapshots](../../images/pit-snapshots.png) +Similar to Git, a commit (aka snapshot) in Infisical is the state of your project's secrets at a specific point in time scoped to +an environment and [folder](./folder) within it. + +To view a list of snapshots for the current folder, press the **Commits** button. + +![PIT commits](../../images/platform/pit-recovery/pit-recovery-commits.png) + +This opens up a sidebar from which you can select to view a particular snapshot: + +![PIT snapshots](../../images/platform/pit-recovery/pit-recovery-commits-drawer.png) ## Rolling back -Secrets can be rolled back to any point in time via the "Rollback to this snapshot" button. This will roll back the changes within the given [folder](./folder) and environment to the chosen time. +After pressing on a snapshot from the sidebar, you can view it and even roll back the state +of the folder to that point in time by pressing the **Rollback** button. -It's important to note that this rollback action is localized and does not affect other folders within the same environment. This means each [folder](./folder) maintains its own independent history of changes, offering precise and isolated control over rollback actions. -In essence, every [folder](./folder) possesses a distinct and separate timeline, providing granular control when managing your secrets. +![PIT snapshot](../../images/platform/pit-recovery/pit-recovery-rollback.png) -![PIT snapshot](../../images/pit-snapshot.png) +Rolling back secrets to a past snapshot creates a creates a snapshot at the top of the stack and updates secret versions. - - Rolling back secrets to a past snapshot creates a new commit, - creates a snapshot at the top of the stack and updates secret versions. - +Note that rollbacks are localized to not affect other folders within the same environment. This means each [folder](./folder) maintains its own independent history of changes, offering precise and isolated control over rollback actions. +Put differently, every [folder](./folder) possesses a distinct and separate timeline, providing granular control when managing your secrets. diff --git a/docs/documentation/platform/project.mdx b/docs/documentation/platform/project.mdx index 38a677ab0..19e5179a4 100644 --- a/docs/documentation/platform/project.mdx +++ b/docs/documentation/platform/project.mdx @@ -3,53 +3,101 @@ title: "Project" description: "How Infisical organizes secrets into projects." --- -A project houses environment variables for an application. +A project houses application configuration and secrets for an application. -## Dashboard +## Secrets Overview -The dashboard page is where you can manage environment variables for a given project. +The **Secrets Overview** page captures a birds-eye-view of secrets and folders across environments like development, staging, or production. +This is useful for comparing secrets, identifying if anything is missing, and making quick changes. + +![project secrets overview](../../images/platform/project/project-secrets-overview-open.png) + +## Secrets Dashboard + +The **Secrets Dashboard** page appears when you press to manage the secrets of a specific environment. ![project dashboard](../../images/dashboard.png) -### Environment variables +### Secrets -Environment variables can be added or removed from a project. By default, they are pre-populated in your first project for demonstration. For any subsequent project, it can be convenient to import existing environment variables by dragging and dropping a .env file containing them. +To add a secret, press **Add Secret** button at the top of the dashboard. -Here's what dragging and dropping a .env looks like: +![project add secret](../../images/platform/project/project-secrets-add.png) -![project drag and drop](../../images/project-drag-drop.png) +For a new project, it can be convenient to populate the dashboard by dropping a `.env` file into the provided pane as shown below: -### Environments +![project drop env file](../../images/platform/project/project-secrets-drop-env.png) -In most cases, environment variables belong to specific environments: development, staging, testing, and production. You can input environment variables for each environment that your project uses. +To delete a secret, hover over it and press the **X** button that appears on the right side. -![project environment](../../images/project-environment.png) +![project delete secret](../../images/platform/project/project-secrets-delete.png) -### Personal overrides +To delete multiple secrets at once, hover over and select the secrets you'd like to delete +and press the **Delete** button that appears at the top. -Every environment variable value can be overridden with a custom value. - -- An overridden value can only be read and accessed by the user that overrode the original shared value. -- A (default) shared value can be read and accessed by other users in a project. - -You can turn overrides on/off by toggling the override/branch icon: - -![project variable toggle open](../../images/project-envar-override.png) +![project delete secret batch](../../images/platform/project/project-secrets-delete-batch.png) ### Search -You can search for any environment variable by its key. +To search for specific secrets by their key name, you can use the search bar. -![project search](../../images/project-search.png) +![project search](../../images/platform/project/project-secrets-search.png) + +To assist you with finding secrets, you can also group them by similar prefixes and filter them by tags (if applicable). + +![project filter](../../images/platform/project/project-secrets-filter.png) ### Hide/Un-hide -You can hide or un-hide the values of your environment variables. By default, the values are hidden for your privacy. +To view/hide all secrets at once, toggle the hide or un-hide button. -![project hide](../../images/project-hide.png) +![project filter](../../images/platform/project/project-secrets-unhide.png) ### Download as .env -You can download your environment variables back in a .env file. +To download/export secrets back into a `.env` file, press the download button. + +![project download back env](../../images/platform/project/project-secrets-download-env.png) + +### Tags + +To better organize similar secrets, hover over them and label them with a tag. + +![project tag secret](../../images/platform/project/project-secrets-tag.png) + +### Comments + +To provide more context about a given secret, especially for your team, hover over it and press the comment button. + +![project comment secret](../../images/platform/project/project-secrets-comment.png) + +### Personal overrides + +Infisical employs the concept of **shared** and **personal** secrets to address the need +for common and custom secret values, or branching, amongst members of a team during software development. +To provide a helpful analogy: A shared value is to a `main` branch as a personal value is to a custom branch. + +Consider: + +- A team with users A, B, user C. +- A project with an environment containing a shared secret called D with the value E. + +Suppose user A overrides the value of secret D with the value F. + +Then: + +- If user A fetches the secret D back, they get the value F. +- If users B and C fetch the secret D back, they both get the value E. + +![project override secret](../../images/platform/project/project-secrets-override.png) + +### Drawer + +To view the full details of each secret, you can hover over it and press on the ellipses button. + +![project secrets ellipses](../../images/platform/project/project-secrets-ellipses.png) + +This opens up a side-drawer: + +![project secrets drawer](../../images/platform/project/project-secrets-drawer.png) -![project download](../../images/project-download.png) diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index d0d750330..940e7b263 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -1,23 +1,39 @@ --- -title: "Reference and Import Secrets" +title: "Secret Referencing / Importing" description: "How to use reference secrets in Infisical" --- -Secret referencing is a powerful feature that allows you to values of other secrets. This way, you just need to update the secret value once for it to be propagated to all the references. +## Secret Referencing -Consider a scenario where you have a database password. In order to utilize this password, you may need to incorporate it into a database connection string. -With secret referencing, you can easily construct these more intricate secrets by directly referencing the base secret. -This centralizes the management of your base secret, as any updates made to it will automatically propagate to all the secrets that depend on it. +Infisical's secret referencing feature lets you reference the value of a "base" secret when defining the value of another secret. +This means that updating the value of a base secret propagates directly to other secrets whose values depend on the base secret. -## Referencing syntax - + + Currently, the secret referencing feature is only supported by the + [Infisical CLI](/cli/overview) and [native integrations](/integrations/overview). -Secret referencing relies on interpolation syntax. This syntax allows you to reference a secret in any environment or [folder](./folder). + We intend to add support for it to the [Node SDK](https://github.com/Infisical/infisical-node) + and [Python SDK](https://github.com/Infisical/infisical-python) this quarter. + -To reference a secret named 'mysecret' in the same [folder](./folder) and environment, you'd use `${mysecret}`. -However, to reference the same secret at the root of a different environment, for instance `dev` environment, you'd use `${dev.mysecret}`. +![secret referencing](../../images/platform/secret-references-imports/secret-reference.png) -Here are a few more examples to help you understand how to reference secrets in different contexts: +Since secret referencing works by reconstructing values back on the client side, the client, be it a user or service token, fetching back secrets +must be permissioned access to all base and dependent secrets. + +For example, to access some secret `A` whose values depend on secrets `B` and `C` from different scopes, a client must have `read` access to the scopes of secrets `A`, `B`, and `C`. + +### Syntax + +When defining a secret reference, interpolation syntax is used to define references to secrets in other environments and [folders](./folder). + +Suppose you have some secret `MY_SECRET` at the root of some environment and want to reference part of its value from another base secret `BASE_SECRET` located elsewhere. +Then consider the following scenarios: + +- If `BASE_SECRET` is in the same environment and folder as `MY_SECRET`, then you'd reference it using `${BASE_SECRET}`. +- If `BASE_SECRET` is at the root of another environment with the slug `dev`, then you'd reference it using `${dev.MY_SECRET}`. + +Here are a few more helpful examples for how to reference secrets in different contexts: | Reference syntax | Environment | Folder | Secret Key | | --------------------- | ----------- | ------------ | ---------- | @@ -25,29 +41,29 @@ Here are a few more examples to help you understand how to reference secrets in | `${dev.KEY2}` | `dev` | `/` (root of dev environment) | KEY2 | | `${prod.frontend.KEY2}` | `prod` | `/frontend` | KEY2 | -## Fetching fully constructed values +## Secret Imports -Secret referencing combines multiple secrets into one unified value, reconstructed only on the client side. To retrieve this value, you need access to read the environment and [folder](./folder) from where the secrets originate. -For instance, to access a secret 'A' composed of secrets 'B' and 'C' from different environments, you must have read access to both 'A' and 'B' +Infisical's secret imports feature lets you import the items of another environment or folder into the current folder context. +This can be useful if you have common secrets that need to be available across multiple environments/folders. -When using [service tokens](./token) to fetch referenced secrets, ensure the service token has read access to all referenced environments and folders. -Without proper permissions, the final secret value may be incomplete. +To add a secret import, press the downward chevron to the right of the **Add Secret** button; then press on the **Add Import** button. -## Import entire folders/environments +![add secret import](../../images/platform/secret-references-imports/secret-import-add.png) -While secret referencing effectively minimizes duplication, there might be instances where you need to import or replicate an entire folder's secrets into another. This can be achieved using the 'Import' feature. +Once added, a secret import will show up with a green import icon on the secrets dashboard. +In the example below, you can see that the items in the path `/some-folder` are being imported into +the current folder context. -This feature allows you to link secrets from one environment/folder into another environment/folder. It proves beneficial when you have common secrets that need to be available across multiple environments/folders. +![added secret import](../../images/platform/secret-references-imports/secret-import-added.png) -To add an import, simply click on the `Add import` button and provide the environment and secret path from where the secrets should be imported. +To delete a secret import, hover over it and press the **X** button that appears on the right side. -![secret import change order](../../images/secret-import-add.png) +![delete secret import](../../images/platform/secret-references-imports/secret-import-delete.png) -The hierarchy of importing secrets is governed by a "last-one-wins" rule. This means the sequence in which you import matters - the final folder imported will override secrets from any prior folders. -Additionally, any secrets you define directly in your environment will override any secrets that are imported with the same name. +Lastly, note that the order of secret imports matters. If two secret imports contain secrets with the same name, then the secret value from the bottom-most secret import is taken โ€” "the last one wins." -You can modify the order of folders to control overrides using the `Change Order` drag handle. +To reorder a secret import, hover over it and drag the arrows handle to the position you want. -![secret import change order](../../images/secret-import-change-order.png) +![reorder secret import](../../images/platform/secret-references-imports/secret-import-reorder.png) diff --git a/docs/documentation/platform/sso/azure.mdx b/docs/documentation/platform/sso/azure.mdx index 9169c9f6f..b280feaa9 100644 --- a/docs/documentation/platform/sso/azure.mdx +++ b/docs/documentation/platform/sso/azure.mdx @@ -3,6 +3,13 @@ title: "Azure SAML" description: "Configure Azure SAML for Infisical SSO" --- + + Azure SAML SSO feature is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + 1. In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**. Next, copy the **Reply URL (Assertion Consumer Service URL)** and **Identifier (Entity ID)** to use when configuring the Azure SAML application. @@ -56,12 +63,17 @@ description: "Configure Azure SAML for Infisical SSO" 7. Get IdP values: -Back in the **Set up Single Sign-On with SAML** screen, copy the **Login URL**, **Azure AD Identifier** and **SAML Certificate** to use when finishing configuring Azure SAML in Infisical. +In the **Set up Single Sign-On with SAML** screen, copy the **Login URL** and **SAML Certificate** to use when finishing configuring Azure SAML in Infisical. -Back in Infisical, set **Login URL** and **Azure AD Identifier** from above. Once you've done that, press **Update** to complete the required configuration. +![Azure SAML identity provider values 1](../../../images/sso/azure/idp-values.png) -![Azure SAML identity provider values](../../../images/sso/azure/idp-values.png) -![Azure SAML paste identity provider values](../../../images/sso/azure/idp-values-2.png) +In the **Properties** screen, copy the **Application ID** to use when finishing configuring Azure SAML in Infisical. + +![Azure SAML identity provider values 2](../../../images/sso/azure/idp-values-2.png) + +Back in Infisical, set **Login URL**, **Azure Application ID**, and **SAML Certificate** from above. Once you've done that, press **Update** to complete the required configuration. + +![Azure SAML paste identity provider values](../../../images/sso/azure/idp-values-3.png) When pasting the certificate into Infisical, you'll want to retain `-----BEGIN @@ -82,3 +94,13 @@ Back in Azure, navigate to the **Users and groups** tab and select **+ Add user/ Enabling SAML SSO enforces all members in your organization to only be able to log into Infisical via Azure. ![Azure SAML assignment](../../../images/sso/azure/enable-saml.png) + + + If you're configuring SAML SSO on a self-hosted instance of Infisical, make sure to + set the `JWT_PROVIDER_AUTH_SECRET` and `SITE_URL` environment variable for it to work: + + - `JWT_PROVIDER_AUTH_SECRET`: This is secret key used for signing and verifying JWT. This could be a randomly-generated 256-bit hex string. + - `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com) + + + diff --git a/docs/documentation/platform/sso/github.mdx b/docs/documentation/platform/sso/github.mdx new file mode 100644 index 000000000..faab5a074 --- /dev/null +++ b/docs/documentation/platform/sso/github.mdx @@ -0,0 +1,52 @@ +--- +title: "GitHub SSO" +description: "Configure GitHub SSO for Infisical" +--- + +Using GitHub SSO on a self-hosted instance of Infisical requires configuring an OAuth2 application in GitHub and registering your instance with it. + +## Create an OAuth application in GitHub + +Navigate to your user Settings > Developer settings > OAuth Apps to create a new GitHub OAuth application. + +![GitHub settings](../../../images/sso/github/settings.png) +![GitHub developer settings](../../../images/sso/github/dev-settings.png) +![GitHub create new OAuth application](../../../images/sso/github/new-app.png) + +Create the OAuth application. As part of the form, set the **Homepage URL** to your self-hosted domain `https://your-domain.com` +and the **Authorization callback URL** to `https://your-domain.com/api/v1/sso/github`. + +![GitHub create new OAuth application form](../../../images/sso/github/new-app-form.png) + + + If you have a GitHub organization, you can create an OAuth application under it + in your organization Settings > Developer settings > OAuth Apps > New Org OAuth App. + + +## Add your OAuth application credentials to Infisical + +Obtain the **Client ID** and generate a new **Client Secret** for your GitHub OAuth application. + +![GCP obtain OAuth2 credentials](../../../images/sso/github/credentials.png) + +Back in your Infisical instance, make sure to set the following environment variables: + +- `CLIENT_ID_GITHUB_LOGIN`: The **Client ID** of your GitHub OAuth application. +- `CLIENT_SECRET_GITHUB_LOGIN`: The **Client Secret** of your GitHub OAuth application. +- `JWT_PROVIDER_AUTH_SECRET`: A secret key used for signing and verifying JWT. This could be a randomly-generated 256-bit hex string. +- `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com) + +Once added, restart your Infisical instance and log in with GitHub. + +## FAQ + + + + It is likely that you have misconfigured your self-hosted instance of Infisical. You should: + + - Check that you have set the `CLIENT_ID_GITHUB_LOGIN`, `CLIENT_SECRET_GITHUB_LOGIN`, + `JWT_PROVIDER_AUTH_SECRET`, and `SITE_URL` environment variables. + - Check that the **Authorization callback URL** specified in GitHub matches the `SITE_URL` environment variable. + For example, if the former is `https://app.infisical.com/api/v1/sso/github` then the latter should be `https://app.infisical.com`. + + \ No newline at end of file diff --git a/docs/documentation/platform/sso/gitlab.mdx b/docs/documentation/platform/sso/gitlab.mdx new file mode 100644 index 000000000..7ae6e4a95 --- /dev/null +++ b/docs/documentation/platform/sso/gitlab.mdx @@ -0,0 +1,52 @@ +--- +title: "GitLab SSO" +description: "Configure GitLab SSO for Infisical" +--- + +Using GitLab SSO on a self-hosted instance of Infisical requires configuring an OAuth application in GitLab and registering your instance with it. + +## Create an OAuth application in GitLab + +Navigate to your user Settings > Applications to create a new GitLab application. + +![sso gitlab config](/images/sso/gitlab/edit-profile.png) +![sso gitlab config](/images/sso/gitlab/new-app.png) + +Create the application. As part of the form, set the **Redirect URI** to `https://your-domain.com/api/v1/sso/gitlab`. +Note that only `read_user` is required as part of the **Scopes** configuration. + +![sso gitlab config](/images/sso/gitlab/new-app-form.png) + + + If you have a GitLab group, you can create an OAuth application under it + in your group Settings > Applications. + + +## Add your OAuth application credentials to Infisical + +Obtain the **Application ID** and **Secret** for your GitLab application. + +![sso gitlab config](/images/sso/gitlab/credentials.png) + +Back in your Infisical instance, make sure to set the following environment variables: + +- `CLIENT_ID_GITLAB_LOGIN`: The **Client ID** of your GitLab application. +- `CLIENT_SECRET_GITLAB_LOGIN`: The **Secret** of your GitLab application. +- (optional) `URL_GITLAB_LOGIN`: The URL of your self-hosted instance of GitLab where the OAuth application is registered. If no URL is passed in, this will default to `https://gitlab.com`. +- `JWT_PROVIDER_AUTH_SECRET`: A secret key used for signing and verifying JWT. This could be a randomly-generated 256-bit hex string. +- `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com) + +Once added, restart your Infisical instance and log in with GitLab. + +## FAQ + + + + It is likely that you have misconfigured your self-hosted instance of Infisical. You should: + + - Check that you have set the `CLIENT_ID_GITLAB_LOGIN`, `CLIENT_SECRET_GITLAB_LOGIN`, + `JWT_PROVIDER_AUTH_SECRET`, and `SITE_URL` environment variables. + - Check that the **Redirect URI** specified in GitLab matches the `SITE_URL` environment variable. + For example, if the former is `https://app.infisical.com/api/v1/sso/gitlab` then the latter should be `https://app.infisical.com`. + + \ No newline at end of file diff --git a/docs/documentation/platform/sso/google.mdx b/docs/documentation/platform/sso/google.mdx new file mode 100644 index 000000000..2546a5be8 --- /dev/null +++ b/docs/documentation/platform/sso/google.mdx @@ -0,0 +1,45 @@ +--- +title: "Google SSO" +description: "Configure Google SSO for Infisical" +--- + +Using Google SSO on a self-hosted instance of Infisical requires configuring an OAuth2 application in GCP and registering your instance with it. + +## Create an OAuth2 application in GCP + +Navigate to your project API & Services > Credentials to create a new OAuth2 application. + +![GCP API services](../../../images/sso/google/api-services.png) +![GCP create new OAuth2 application](../../../images/sso/google/new-app.png) + +Create the application. As part of the form, add to **Authorized redirect URIs**: `https://your-domain.com/api/v1/sso/google`. + +![GCP create new OAuth2 application form](../../../images/sso/google/new-app-form.png) + +## Add your OAuth2 application credentials to Infisical + +Obtain the **Client ID** and **Client Secret** for your GCP OAuth2 application. + +![GCP obtain OAuth2 credentials](../../../images/sso/google/credentials.png) + +Back in your Infisical instance, make sure to set the following environment variables: + +- `CLIENT_ID_GOOGLE_LOGIN`: The **Client ID** of your GCP OAuth2 application. +- `CLIENT_SECRET_GOOGLE_LOGIN`: The **Client Secret** of your GCP OAuth2 application. +- `JWT_PROVIDER_AUTH_SECRET`: A secret key used for signing and verifying JWT. This could be a randomly-generated 256-bit hex string. +- `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com) + +Once added, restart your Infisical instance and log in with Google + +## FAQ + + + + It is likely that you have misconfigured your self-hosted instance of Infisical. You should: + + - Check that you have set the `CLIENT_ID_GOOGLE_LOGIN`, `CLIENT_SECRET_GOOGLE_LOGIN`, + `JWT_PROVIDER_AUTH_SECRET`, and `SITE_URL` environment variables. + - Check that the **Authorized redirect URI** specified in GCP matches the `SITE_URL` environment variable. + For example, if the former is `https://app.infisical.com/api/v1/sso/google` then the latter should be `https://app.infisical.com`. + + \ No newline at end of file diff --git a/docs/documentation/platform/sso/jumpcloud.mdx b/docs/documentation/platform/sso/jumpcloud.mdx index f22d173d4..4c2908416 100644 --- a/docs/documentation/platform/sso/jumpcloud.mdx +++ b/docs/documentation/platform/sso/jumpcloud.mdx @@ -3,6 +3,13 @@ title: "JumpCloud SAML" description: "Configure JumpCloud SAML for Infisical SSO" --- + + JumpCloud SAML SSO feature is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + 1. In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**. Next, copy the **ACS URL** and **SP Entity ID** to use when configuring the JumpCloud SAML application. @@ -65,3 +72,11 @@ Back in JumpCloud, navigate to the **User Groups** tab and assign users to the n Enabling SAML SSO enforces all members in your organization to only be able to log into Infisical via JumpCloud. ![JumpCloud SAML assignment](../../../images/sso/jumpcloud/enable-saml.png) + + + If you're configuring SAML SSO on a self-hosted instance of Infisical, make sure to + set the `JWT_PROVIDER_AUTH_SECRET` and `SITE_URL` environment variable for it to work: + + - `JWT_PROVIDER_AUTH_SECRET`: This is secret key used for signing and verifying JWT. This could be a randomly-generated 256-bit hex string. + - `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com) + diff --git a/docs/documentation/platform/sso/okta.mdx b/docs/documentation/platform/sso/okta.mdx index 36293be14..f5db2529c 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -3,6 +3,13 @@ title: "Okta SAML" description: "Configure Okta SAML 2.0 for Infisical SSO" --- + + Okta SAML SSO feature is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + 1. In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**. Next, copy the **Single sign-on URL** and **Audience URI (SP Entity ID)** to use when configuring the Okta SAML 2.0 application. @@ -70,3 +77,11 @@ At this point, you have configured everything you need within the context of the Enabling SAML SSO enforces all members in your organization to only be able to log into Infisical via Okta. ![SAML Okta assignment](../../../images/sso/okta/enable-saml.png) + + + If you're configuring SAML SSO on a self-hosted instance of Infisical, make sure to + set the `JWT_PROVIDER_AUTH_SECRET` and `SITE_URL` environment variable for it to work: + + - `JWT_PROVIDER_AUTH_SECRET`: This is secret key used for signing and verifying JWT. This could be a randomly-generated 256-bit hex string. + - `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com) + \ No newline at end of file diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index 9a9afadef..917bb7e19 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -4,9 +4,11 @@ description: "Log in to Infisical via SSO protocols" --- - Infisical currently has confirmed support for SAML SSO authentication with - Okta, Azure AD, and JumpCloud. We're expanding support for other IdPs in the - coming months, so stay tuned and feel free to request a IdP at this + Infisical offers Google SSO and GitHub SSO for free across both Infisical Cloud and Infisical Self-hosted. + + Infisical also offers SAML SSO authentication but as paid features that can be unlocked on Infisical Cloud's **Pro** tier + or via enterprise license on self-hosted instances of Infisical. On this front, we currently support Okta, Azure AD, and JumpCloud and + are expanding support for other IdPs in the coming months; stay tuned and feel free to request a IdP at this [issue](https://github.com/Infisical/infisical/issues/442). @@ -15,6 +17,9 @@ You can configure your organization in Infisical to have members authenticate wi To note, configuring SSO retains the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps. In all login with SSO implementations, your IdP cannot and will not have access to the decryption key needed to decrypt your secrets. +- [Google SSO](/documentation/platform/sso/google) +- [GitHub SSO](/documentation/platform/sso/github) +- [GitLab SSO](/documentation/platform/sso/gitlab) - [Okta SAML](/documentation/platform/sso/okta) - [Azure SAML](/documentation/platform/sso/azure) -- [JumpCloud SAML](/documentation/platform/sso/jumpcloud) +- [JumpCloud SAML](/documentation/platform/sso/jumpcloud) \ No newline at end of file diff --git a/docs/documentation/platform/token.mdx b/docs/documentation/platform/token.mdx index 1371d4621..8be86be7f 100644 --- a/docs/documentation/platform/token.mdx +++ b/docs/documentation/platform/token.mdx @@ -3,30 +3,63 @@ title: "Service token" description: "Infisical service tokens allows you to programmatically interact with Infisical" --- -Service tokens play an integral role in allowing programmatic interactions with an Infisical project, functioning as digital token that open access to specific project resources such as secrets. +Service tokens are authentication credentials that services can use to access designated endpoints in the Infisical API to manage project resources like secrets. +Each service token can be provisioned scoped access to select environment(s) and path(s) within them. -When you generate a service token, you can define its access level, not only by specifying the paths and environments it can interact with, but also by determining the level of mutation it can perform, such as read-only, write, or both. +## Service Tokens -This level of control not only ensures maximum flexibility but also significantly enhances security as it allows you to define fine grained access to project resources. +You can manage service tokens in Project Settings > Service Tokens. +### Service Token (Current) + +Service Token (ST) is the current widely-used authentication method for managing secrets. + + + We're soon releasing ST V3, a revised version of this Service Token, so stay tuned. + + +Here's a few pointers to get you acquainted with it: + +- When you create a ST, you get a token prefixed with `st`. The part after the last `.` delimiter is a symmetric key; everything +before it is an access token. When authenticating with the Infisical API, it is important to send in only the access token portion +of the token. +- ST supports expiration; it gets deleted automatically upon expiration. +- ST supports provisioning `read` and/or `write` permissions broadly applied to all accessible environment(s) and path(s). +- ST is not editable. ## Creating a service token -To generate the token, head over to your project settings as shown below. On creating a service token you can scope it to a path to limit the access. +To create a service token, head to Project Settings > Service Tokens as shown below and press **Create token**. -![token add](../../images/project-token-add.png) +![token add](../../images/project-token-old-add.png) -### Service token permissions -![token add](../../images/service-token-permissions.png) +Now input any token configuration details such as which environment(s) and path(s) you'd like to provision +the token access to. Here's some guidance for each field: +- Name: A friendly name for the token. +- Scopes: The environment(s) and path(s) the token should have access to. +- Permissions: You can indicate whether or not the token should have `read/write` access to the paths. +Also, note that Infisical supports [glob patterns](https://www.malikbrowne.com/blog/a-beginners-guide-glob-patterns/) when defining access scopes to path(s). +- Expiration: The time when this token should be rendered inactive. -Service tokens can be scoped to multiple environments and paths. To add a new permission, choose the environment you want to give access to and then choose the path you'd like to give access to within that environment. +![token add](../../images/project-token-old-permissions.png) -Permissions for paths are powered by [Glob pattern](https://www.malikbrowne.com/blog/a-beginners-guide-glob-patterns/). This means you can create advanced folder permissions with a simple Glob patterns. +In the above screenshot, you can see that we are creating a token token with `read` access to all subfolders at any depth +of the `/common` path within the development environment of the project; the token expires in 6 months and can be used from any IP address. -**Examples of common Glob pattens** +**FAQ** - + + + There are a few reasons for why this might happen: + + - The service token has expired. + - The service token is insufficently permissioned to interact with the secrets in the given environment and path. + - You are attempting to access a `/raw` secrets endpoint that requires your project to disable E2EE. + - (If using ST V3) The service token has not been activated yet. + - (If using ST V3) The service token is being used from an untrusted IP. + + 1. `/**`: This pattern matches all folders at any depth in the directory structure. For example, it would match folders like `/folder1/`, `/folder1/subfolder/`, and so on. 2. `/*`: This pattern matches all immediate subfolders in the current directory. It does not match any folders at a deeper level. For example, it would match folders like `/folder1/`, `/folder2/`, but not `/folder1/subfolder/`. @@ -35,3 +68,4 @@ Permissions for paths are powered by [Glob pattern](https://www.malikbrowne.com/ 4. `/folder1/*`: This pattern matches all immediate subfolders within the `/folder1/` directory. It does not match any folders outside of `/folder1/`, nor does it match any subfolders within those immediate subfolders. For example, it would match folders like `/folder1/subfolder1/`, `/folder1/subfolder2/`, but not `/folder2/subfolder/`. + \ No newline at end of file diff --git a/docs/documentation/platform/token3.mdx b/docs/documentation/platform/token3.mdx new file mode 100644 index 000000000..a04c9b853 --- /dev/null +++ b/docs/documentation/platform/token3.mdx @@ -0,0 +1,105 @@ +--- +title: "Service token" +description: "Infisical service tokens allows you to programmatically interact with Infisical" +--- + +Service tokens are authentication credentials that services can use to access designated endpoints in the Infisical API to manage project resources like secrets. +Each service token can be provisioned scoped access to select environment(s) and path(s) within them. + +## Service Tokens + +Infisical currently offers Service Token V3 and Service Token; you can manage both types of tokens in Project Settings > Service Tokens. + +### Service Token V3 (Beta) + +Service Token V3 (ST V3) is a new and improved authentication method that is in beta. + + + Currently, the Service Token V3 authentication method can only be used with the latest [Node SDK](https://github.com/Infisical/infisical-node) and [Python SDK](https://github.com/Infisical/infisical-python). + You can also make an API call with it to create, read, update, or delete secrets. + + We will be releasing compatibility for it with the CLI and K8s operator in the coming month. + + That said, we recommend using ST V3 whenever possible. + + +Here's a few pointers to get you acquainted with it: + +- When you create a ST V3, you export a `JSON` file containing 3 components: `publicKey`, `privateKey`, and `serviceToken` where +`serviceToken` is a JWT token prefixed with `stv3`. The token provides access to the Infisical API and the public-private key +pairs are to support cryptographic operations for the client whenever E2EE is needed. +- ST V3 supports IP allowlisting; this means you can restrict the usage of a ST V3 to a specific IP or CIDR range. +- ST V3 supports provisioning granular `read` or `readWrite` access down to each path. +- ST V3 supports toggling on/off active states, so you can render a ST V3 inactive without deleting it. +- ST V3 supports expiration, so, if specified, a token will automatically turn inactive after a period of time. +- ST V3 tracks most recent usage; it also keeps track of each token's usage count. +- ST V3 is editable. + +### Service Token (Current) + +Service Token (ST) is the current widely-used authentication method. + + + We recently released ST V3, a revised version of this Service Token, which you can read about above. + + Whenever possible, you should use ST V3 because we will be deprecating ST sometime Q4 2023. + + +Here's a few pointers to get you acquainted with it: + +- When you create a ST, you get a token prefixed with `st`. The part after the last `.` delimiter is a symmetric key; everything +before it is an access token. When authenticating with the Infisical API, it is important to send in only the access token portion +of the token. +- ST supports expiration; it gets deleted automatically upon expiration. +- ST supports provisioning `read` and/or `write` permissions broadly applied to all accessible environment(s) and path(s). +- ST is not editable. + +## Creating a service token + +To create a service token, head to Project Settings > Service Tokens as shown below and press **Create token**. + +![token add](../../images/project-token-add.png) + +Now input any token configuration details such as which environment(s) and path(s) you'd like to provision +the token access to. Here's some guidance for each field: + +- Name: A friendly name for the token. +- Scopes: The environment(s) and path(s) the token should have access to. +If using ST V3, you can also indicate whether or not the token should have `read` or `readWrite` access to each path. +Also, note that Infisical supports [glob patterns](https://www.malikbrowne.com/blog/a-beginners-guide-glob-patterns/) when defining access scopes to path(s). +- Trusted IPs: The IPs or CIDR ranges that the token can be used from. By default, each token is given the `0.0.0.0/0` entry representing all possible IPv4 addresses. +- Expiration: The time when this token should be rendered inactive. + + + Restricting token usage to specific trusted IPs is a paid feature. + + If youโ€™re using Infisical Cloud, then it is available under the Pro Tier. If youโ€™re self-hosting Infisical, then you should contact team@infisical.com to purchase an enterprise license to use it. + + +![token add](../../images/project-token-permissions.png) + +In the above screenshot, you can see that we are creating a token token with `read` access to all subfolders at any depth +of the `/common` path within the development environment of the project; the token expires in 6 months and can be used from any IP address. + +**FAQ** + + + + There are a few reasons for why this might happen: + + - The service token has expired. + - The service token is insufficently permissioned to interact with the secrets in the given environment and path. + - You are attempting to access a `/raw` secrets endpoint that requires your project to disable E2EE. + - (If using ST V3) The service token has not been activated yet. + - (If using ST V3) The service token is being used from an untrusted IP. + + + 1. `/**`: This pattern matches all folders at any depth in the directory structure. For example, it would match folders like `/folder1/`, `/folder1/subfolder/`, and so on. + + 2. `/*`: This pattern matches all immediate subfolders in the current directory. It does not match any folders at a deeper level. For example, it would match folders like `/folder1/`, `/folder2/`, but not `/folder1/subfolder/`. + + 3. `/*/*`: This pattern matches all subfolders at a depth of two levels in the current directory. It does not match any folders at a shallower or deeper level. For example, it would match folders like `/folder1/subfolder/`, `/folder2/subfolder/`, but not `/folder1/` or `/folder1/subfolder/subsubfolder/`. + + 4. `/folder1/*`: This pattern matches all immediate subfolders within the `/folder1/` directory. It does not match any folders outside of `/folder1/`, nor does it match any subfolders within those immediate subfolders. For example, it would match folders like `/folder1/subfolder1/`, `/folder1/subfolder2/`, but not `/folder2/subfolder/`. + + \ No newline at end of file diff --git a/docs/images/activity-logs.png b/docs/images/activity-logs.png deleted file mode 100644 index 29349c82b..000000000 Binary files a/docs/images/activity-logs.png and /dev/null differ diff --git a/docs/images/authentication-google-redirect.png b/docs/images/authentication-google-redirect.png deleted file mode 100644 index 7c4a2bff6..000000000 Binary files a/docs/images/authentication-google-redirect.png and /dev/null differ diff --git a/docs/images/dashboard-add-folder.png b/docs/images/dashboard-add-folder.png deleted file mode 100644 index 6040dcc3f..000000000 Binary files a/docs/images/dashboard-add-folder.png and /dev/null differ diff --git a/docs/images/dashboard-folder-overview.png b/docs/images/dashboard-folder-overview.png deleted file mode 100644 index 265f732fe..000000000 Binary files a/docs/images/dashboard-folder-overview.png and /dev/null differ diff --git a/docs/images/dashboard-folders.png b/docs/images/dashboard-folders.png deleted file mode 100644 index d3e92482f..000000000 Binary files a/docs/images/dashboard-folders.png and /dev/null differ diff --git a/docs/images/dashboard-name-modal-organization.png b/docs/images/dashboard-name-modal-organization.png deleted file mode 100644 index 59145d21b..000000000 Binary files a/docs/images/dashboard-name-modal-organization.png and /dev/null differ diff --git a/docs/images/dashboard-name-selected.png b/docs/images/dashboard-name-selected.png deleted file mode 100644 index 41b0c4eab..000000000 Binary files a/docs/images/dashboard-name-selected.png and /dev/null differ diff --git a/docs/images/dashboard-secrets-overview.png b/docs/images/dashboard-secrets-overview.png index 68b31de72..2d43e385e 100644 Binary files a/docs/images/dashboard-secrets-overview.png and b/docs/images/dashboard-secrets-overview.png differ diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png index 2188debfc..506f0b0ed 100644 Binary files a/docs/images/dashboard.png and b/docs/images/dashboard.png differ diff --git a/docs/images/example-secret-referencing.png b/docs/images/example-secret-referencing.png deleted file mode 100644 index 031d343af..000000000 Binary files a/docs/images/example-secret-referencing.png and /dev/null differ diff --git a/docs/images/integrations-teamcity-auth.png b/docs/images/integrations-teamcity-auth.png deleted file mode 100644 index f0c95992f..000000000 Binary files a/docs/images/integrations-teamcity-auth.png and /dev/null differ diff --git a/docs/images/integrations-teamcity-create.png b/docs/images/integrations-teamcity-create.png deleted file mode 100644 index c5515581e..000000000 Binary files a/docs/images/integrations-teamcity-create.png and /dev/null differ diff --git a/docs/images/integrations-teamcity-dashboard.png b/docs/images/integrations-teamcity-dashboard.png deleted file mode 100644 index 40c8c5409..000000000 Binary files a/docs/images/integrations-teamcity-dashboard.png and /dev/null differ diff --git a/docs/images/integrations-teamcity-projects.png b/docs/images/integrations-teamcity-projects.png deleted file mode 100644 index 579c195a6..000000000 Binary files a/docs/images/integrations-teamcity-projects.png and /dev/null differ diff --git a/docs/images/integrations-teamcity-serverurl.png b/docs/images/integrations-teamcity-serverurl.png deleted file mode 100644 index 599e3205b..000000000 Binary files a/docs/images/integrations-teamcity-serverurl.png and /dev/null differ diff --git a/docs/images/integrations-teamcity-tokens.png b/docs/images/integrations-teamcity-tokens.png deleted file mode 100644 index 720ed90c6..000000000 Binary files a/docs/images/integrations-teamcity-tokens.png and /dev/null differ diff --git a/docs/images/integrations-teamcity.png b/docs/images/integrations-teamcity.png deleted file mode 100644 index 4fe92ac32..000000000 Binary files a/docs/images/integrations-teamcity.png and /dev/null differ diff --git a/docs/images/integrations-aws-access-key-1.png b/docs/images/integrations/aws/integrations-aws-access-key-1.png similarity index 100% rename from docs/images/integrations-aws-access-key-1.png rename to docs/images/integrations/aws/integrations-aws-access-key-1.png diff --git a/docs/images/integrations-aws-access-key-2.png b/docs/images/integrations/aws/integrations-aws-access-key-2.png similarity index 100% rename from docs/images/integrations-aws-access-key-2.png rename to docs/images/integrations/aws/integrations-aws-access-key-2.png diff --git a/docs/images/integrations-aws-access-key-3.png b/docs/images/integrations/aws/integrations-aws-access-key-3.png similarity index 100% rename from docs/images/integrations-aws-access-key-3.png rename to docs/images/integrations/aws/integrations-aws-access-key-3.png diff --git a/docs/images/integrations-aws-iam-1.png b/docs/images/integrations/aws/integrations-aws-iam-1.png similarity index 100% rename from docs/images/integrations-aws-iam-1.png rename to docs/images/integrations/aws/integrations-aws-iam-1.png diff --git a/docs/images/integrations-aws-parameter-store-auth.png b/docs/images/integrations/aws/integrations-aws-parameter-store-auth.png similarity index 100% rename from docs/images/integrations-aws-parameter-store-auth.png rename to docs/images/integrations/aws/integrations-aws-parameter-store-auth.png diff --git a/docs/images/integrations-aws-parameter-store-create.png b/docs/images/integrations/aws/integrations-aws-parameter-store-create.png similarity index 100% rename from docs/images/integrations-aws-parameter-store-create.png rename to docs/images/integrations/aws/integrations-aws-parameter-store-create.png diff --git a/docs/images/integrations-aws-parameter-store-iam-2.png b/docs/images/integrations/aws/integrations-aws-parameter-store-iam-2.png similarity index 100% rename from docs/images/integrations-aws-parameter-store-iam-2.png rename to docs/images/integrations/aws/integrations-aws-parameter-store-iam-2.png diff --git a/docs/images/integrations-aws-parameter-store-iam-3.png b/docs/images/integrations/aws/integrations-aws-parameter-store-iam-3.png similarity index 100% rename from docs/images/integrations-aws-parameter-store-iam-3.png rename to docs/images/integrations/aws/integrations-aws-parameter-store-iam-3.png diff --git a/docs/images/integrations-aws-parameter-store.png b/docs/images/integrations/aws/integrations-aws-parameter-store.png similarity index 100% rename from docs/images/integrations-aws-parameter-store.png rename to docs/images/integrations/aws/integrations-aws-parameter-store.png diff --git a/docs/images/integrations-aws-secret-manager-auth.png b/docs/images/integrations/aws/integrations-aws-secret-manager-auth.png similarity index 100% rename from docs/images/integrations-aws-secret-manager-auth.png rename to docs/images/integrations/aws/integrations-aws-secret-manager-auth.png diff --git a/docs/images/integrations-aws-secret-manager-create.png b/docs/images/integrations/aws/integrations-aws-secret-manager-create.png similarity index 100% rename from docs/images/integrations-aws-secret-manager-create.png rename to docs/images/integrations/aws/integrations-aws-secret-manager-create.png diff --git a/docs/images/integrations-aws-secret-manager-iam-2.png b/docs/images/integrations/aws/integrations-aws-secret-manager-iam-2.png similarity index 100% rename from docs/images/integrations-aws-secret-manager-iam-2.png rename to docs/images/integrations/aws/integrations-aws-secret-manager-iam-2.png diff --git a/docs/images/integrations-aws-secret-manager-iam-3.png b/docs/images/integrations/aws/integrations-aws-secret-manager-iam-3.png similarity index 100% rename from docs/images/integrations-aws-secret-manager-iam-3.png rename to docs/images/integrations/aws/integrations-aws-secret-manager-iam-3.png diff --git a/docs/images/integrations-aws-secret-manager.png b/docs/images/integrations/aws/integrations-aws-secret-manager.png similarity index 100% rename from docs/images/integrations-aws-secret-manager.png rename to docs/images/integrations/aws/integrations-aws-secret-manager.png diff --git a/docs/images/integrations-bitbucket-auth.png b/docs/images/integrations/bitbucket/integrations-bitbucket-auth.png similarity index 100% rename from docs/images/integrations-bitbucket-auth.png rename to docs/images/integrations/bitbucket/integrations-bitbucket-auth.png diff --git a/docs/images/integrations-bitbucket-create.png b/docs/images/integrations/bitbucket/integrations-bitbucket-create.png similarity index 100% rename from docs/images/integrations-bitbucket-create.png rename to docs/images/integrations/bitbucket/integrations-bitbucket-create.png diff --git a/docs/images/integrations-bitbucket.png b/docs/images/integrations/bitbucket/integrations-bitbucket.png similarity index 100% rename from docs/images/integrations-bitbucket.png rename to docs/images/integrations/bitbucket/integrations-bitbucket.png diff --git a/docs/images/integrations-checkly-auth.png b/docs/images/integrations/checkly/integrations-checkly-auth.png similarity index 100% rename from docs/images/integrations-checkly-auth.png rename to docs/images/integrations/checkly/integrations-checkly-auth.png diff --git a/docs/images/integrations-checkly-create.png b/docs/images/integrations/checkly/integrations-checkly-create.png similarity index 100% rename from docs/images/integrations-checkly-create.png rename to docs/images/integrations/checkly/integrations-checkly-create.png diff --git a/docs/images/integrations-checkly-dashboard.png b/docs/images/integrations/checkly/integrations-checkly-dashboard.png similarity index 100% rename from docs/images/integrations-checkly-dashboard.png rename to docs/images/integrations/checkly/integrations-checkly-dashboard.png diff --git a/docs/images/integrations-checkly-token.png b/docs/images/integrations/checkly/integrations-checkly-token.png similarity index 100% rename from docs/images/integrations-checkly-token.png rename to docs/images/integrations/checkly/integrations-checkly-token.png diff --git a/docs/images/integrations-checkly.png b/docs/images/integrations/checkly/integrations-checkly.png similarity index 100% rename from docs/images/integrations-checkly.png rename to docs/images/integrations/checkly/integrations-checkly.png diff --git a/docs/images/integrations-circleci-auth.png b/docs/images/integrations/circleci/integrations-circleci-auth.png similarity index 100% rename from docs/images/integrations-circleci-auth.png rename to docs/images/integrations/circleci/integrations-circleci-auth.png diff --git a/docs/images/integrations-circleci-create.png b/docs/images/integrations/circleci/integrations-circleci-create.png similarity index 100% rename from docs/images/integrations-circleci-create.png rename to docs/images/integrations/circleci/integrations-circleci-create.png diff --git a/docs/images/integrations-circleci-token.png b/docs/images/integrations/circleci/integrations-circleci-token.png similarity index 100% rename from docs/images/integrations-circleci-token.png rename to docs/images/integrations/circleci/integrations-circleci-token.png diff --git a/docs/images/integrations-circleci.png b/docs/images/integrations/circleci/integrations-circleci.png similarity index 100% rename from docs/images/integrations-circleci.png rename to docs/images/integrations/circleci/integrations-circleci.png diff --git a/docs/images/integrations-cloud-66-access-token.png b/docs/images/integrations/cloud-66/integrations-cloud-66-access-token.png similarity index 100% rename from docs/images/integrations-cloud-66-access-token.png rename to docs/images/integrations/cloud-66/integrations-cloud-66-access-token.png diff --git a/docs/images/integrations-cloud-66-copy-pat.png b/docs/images/integrations/cloud-66/integrations-cloud-66-copy-pat.png similarity index 100% rename from docs/images/integrations-cloud-66-copy-pat.png rename to docs/images/integrations/cloud-66/integrations-cloud-66-copy-pat.png diff --git a/docs/images/integrations-cloud-66-create.png b/docs/images/integrations/cloud-66/integrations-cloud-66-create.png similarity index 100% rename from docs/images/integrations-cloud-66-create.png rename to docs/images/integrations/cloud-66/integrations-cloud-66-create.png diff --git a/docs/images/integrations-cloud-66-dashboard.png b/docs/images/integrations/cloud-66/integrations-cloud-66-dashboard.png similarity index 100% rename from docs/images/integrations-cloud-66-dashboard.png rename to docs/images/integrations/cloud-66/integrations-cloud-66-dashboard.png diff --git a/docs/images/integrations-cloud-66-done.png b/docs/images/integrations/cloud-66/integrations-cloud-66-done.png similarity index 100% rename from docs/images/integrations-cloud-66-done.png rename to docs/images/integrations/cloud-66/integrations-cloud-66-done.png diff --git a/docs/images/integrations-cloud-66-infisical-dashboard.png b/docs/images/integrations/cloud-66/integrations-cloud-66-infisical-dashboard.png similarity index 100% rename from docs/images/integrations-cloud-66-infisical-dashboard.png rename to docs/images/integrations/cloud-66/integrations-cloud-66-infisical-dashboard.png diff --git a/docs/images/integrations-cloud-66-paste-pat.png b/docs/images/integrations/cloud-66/integrations-cloud-66-paste-pat.png similarity index 100% rename from docs/images/integrations-cloud-66-paste-pat.png rename to docs/images/integrations/cloud-66/integrations-cloud-66-paste-pat.png diff --git a/docs/images/integrations-cloud-66-pat-setup.png b/docs/images/integrations/cloud-66/integrations-cloud-66-pat-setup.png similarity index 100% rename from docs/images/integrations-cloud-66-pat-setup.png rename to docs/images/integrations/cloud-66/integrations-cloud-66-pat-setup.png diff --git a/docs/images/integrations-cloud-66-pat.png b/docs/images/integrations/cloud-66/integrations-cloud-66-pat.png similarity index 100% rename from docs/images/integrations-cloud-66-pat.png rename to docs/images/integrations/cloud-66/integrations-cloud-66-pat.png diff --git a/docs/images/integrations-cloudflare-auth.png b/docs/images/integrations/cloudflare/integrations-cloudflare-auth.png similarity index 100% rename from docs/images/integrations-cloudflare-auth.png rename to docs/images/integrations/cloudflare/integrations-cloudflare-auth.png diff --git a/docs/images/integrations-cloudflare-create.png b/docs/images/integrations/cloudflare/integrations-cloudflare-create.png similarity index 100% rename from docs/images/integrations-cloudflare-create.png rename to docs/images/integrations/cloudflare/integrations-cloudflare-create.png diff --git a/docs/images/integrations-cloudflare-credentials-1.png b/docs/images/integrations/cloudflare/integrations-cloudflare-credentials-1.png similarity index 100% rename from docs/images/integrations-cloudflare-credentials-1.png rename to docs/images/integrations/cloudflare/integrations-cloudflare-credentials-1.png diff --git a/docs/images/integrations-cloudflare-credentials-2.png b/docs/images/integrations/cloudflare/integrations-cloudflare-credentials-2.png similarity index 100% rename from docs/images/integrations-cloudflare-credentials-2.png rename to docs/images/integrations/cloudflare/integrations-cloudflare-credentials-2.png diff --git a/docs/images/integrations-cloudflare-credentials-3.png b/docs/images/integrations/cloudflare/integrations-cloudflare-credentials-3.png similarity index 100% rename from docs/images/integrations-cloudflare-credentials-3.png rename to docs/images/integrations/cloudflare/integrations-cloudflare-credentials-3.png diff --git a/docs/images/integrations-cloudflare-credentials-4.png b/docs/images/integrations/cloudflare/integrations-cloudflare-credentials-4.png similarity index 100% rename from docs/images/integrations-cloudflare-credentials-4.png rename to docs/images/integrations/cloudflare/integrations-cloudflare-credentials-4.png diff --git a/docs/images/integrations-cloudflare.png b/docs/images/integrations/cloudflare/integrations-cloudflare.png similarity index 100% rename from docs/images/integrations-cloudflare.png rename to docs/images/integrations/cloudflare/integrations-cloudflare.png diff --git a/docs/images/integrations-codefresh-auth.png b/docs/images/integrations/codefresh/integrations-codefresh-auth.png similarity index 100% rename from docs/images/integrations-codefresh-auth.png rename to docs/images/integrations/codefresh/integrations-codefresh-auth.png diff --git a/docs/images/integrations-codefresh-create.png b/docs/images/integrations/codefresh/integrations-codefresh-create.png similarity index 100% rename from docs/images/integrations-codefresh-create.png rename to docs/images/integrations/codefresh/integrations-codefresh-create.png diff --git a/docs/images/integrations-codefresh-dashboard.png b/docs/images/integrations/codefresh/integrations-codefresh-dashboard.png similarity index 100% rename from docs/images/integrations-codefresh-dashboard.png rename to docs/images/integrations/codefresh/integrations-codefresh-dashboard.png diff --git a/docs/images/integrations-codefresh-token.png b/docs/images/integrations/codefresh/integrations-codefresh-token.png similarity index 100% rename from docs/images/integrations-codefresh-token.png rename to docs/images/integrations/codefresh/integrations-codefresh-token.png diff --git a/docs/images/integrations-codefresh.png b/docs/images/integrations/codefresh/integrations-codefresh.png similarity index 100% rename from docs/images/integrations-codefresh.png rename to docs/images/integrations/codefresh/integrations-codefresh.png diff --git a/docs/images/integrations-do-dashboard.png b/docs/images/integrations/digital-ocean/integrations-do-dashboard.png similarity index 100% rename from docs/images/integrations-do-dashboard.png rename to docs/images/integrations/digital-ocean/integrations-do-dashboard.png diff --git a/docs/images/integrations-do-enter-token.png b/docs/images/integrations/digital-ocean/integrations-do-enter-token.png similarity index 100% rename from docs/images/integrations-do-enter-token.png rename to docs/images/integrations/digital-ocean/integrations-do-enter-token.png diff --git a/docs/images/integrations-do-select-projects.png b/docs/images/integrations/digital-ocean/integrations-do-select-projects.png similarity index 100% rename from docs/images/integrations-do-select-projects.png rename to docs/images/integrations/digital-ocean/integrations-do-select-projects.png diff --git a/docs/images/integrations-do-success.png b/docs/images/integrations/digital-ocean/integrations-do-success.png similarity index 100% rename from docs/images/integrations-do-success.png rename to docs/images/integrations/digital-ocean/integrations-do-success.png diff --git a/docs/images/integrations-do-token-modal.png b/docs/images/integrations/digital-ocean/integrations-do-token-modal.png similarity index 100% rename from docs/images/integrations-do-token-modal.png rename to docs/images/integrations/digital-ocean/integrations-do-token-modal.png diff --git a/docs/images/integrations-flyio-auth.png b/docs/images/integrations/flyio/integrations-flyio-auth.png similarity index 100% rename from docs/images/integrations-flyio-auth.png rename to docs/images/integrations/flyio/integrations-flyio-auth.png diff --git a/docs/images/integrations-flyio-create.png b/docs/images/integrations/flyio/integrations-flyio-create.png similarity index 100% rename from docs/images/integrations-flyio-create.png rename to docs/images/integrations/flyio/integrations-flyio-create.png diff --git a/docs/images/integrations-flyio-dashboard.png b/docs/images/integrations/flyio/integrations-flyio-dashboard.png similarity index 100% rename from docs/images/integrations-flyio-dashboard.png rename to docs/images/integrations/flyio/integrations-flyio-dashboard.png diff --git a/docs/images/integrations-flyio-token.png b/docs/images/integrations/flyio/integrations-flyio-token.png similarity index 100% rename from docs/images/integrations-flyio-token.png rename to docs/images/integrations/flyio/integrations-flyio-token.png diff --git a/docs/images/integrations-flyio.png b/docs/images/integrations/flyio/integrations-flyio.png similarity index 100% rename from docs/images/integrations-flyio.png rename to docs/images/integrations/flyio/integrations-flyio.png diff --git a/docs/images/integrations-hashicorp-vault-access-1.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-1.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-access-1.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-1.png diff --git a/docs/images/integrations-hashicorp-vault-access-2.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-2.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-access-2.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-2.png diff --git a/docs/images/integrations-hashicorp-vault-access-3.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-3.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-access-3.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-3.png diff --git a/docs/images/integrations-hashicorp-vault-auth.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-auth.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-auth.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-auth.png diff --git a/docs/images/integrations-hashicorp-vault-cluster-url.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-cluster-url.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-cluster-url.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-cluster-url.png diff --git a/docs/images/integrations-hashicorp-vault-create.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-create.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-create.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-create.png diff --git a/docs/images/integrations-hashicorp-vault-engine-1.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-1.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-engine-1.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-1.png diff --git a/docs/images/integrations-hashicorp-vault-engine-2.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-2.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-engine-2.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-2.png diff --git a/docs/images/integrations-hashicorp-vault-engine-3.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-3.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-engine-3.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-3.png diff --git a/docs/images/integrations-hashicorp-vault-policy-1.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-1.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-policy-1.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-1.png diff --git a/docs/images/integrations-hashicorp-vault-policy-2.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-2.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-policy-2.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-2.png diff --git a/docs/images/integrations-hashicorp-vault-policy-3.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-3.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-policy-3.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-3.png diff --git a/docs/images/integrations-hashicorp-vault-shell.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-shell.png similarity index 100% rename from docs/images/integrations-hashicorp-vault-shell.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault-shell.png diff --git a/docs/images/integrations-hashicorp-vault.png b/docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault.png similarity index 100% rename from docs/images/integrations-hashicorp-vault.png rename to docs/images/integrations/hashicorp-vault/integrations-hashicorp-vault.png diff --git a/docs/images/integrations-laravelforge-api.png b/docs/images/integrations/laravel-forge/integrations-laravelforge-api.png similarity index 100% rename from docs/images/integrations-laravelforge-api.png rename to docs/images/integrations/laravel-forge/integrations-laravelforge-api.png diff --git a/docs/images/integrations-laravelforge-auth.png b/docs/images/integrations/laravel-forge/integrations-laravelforge-auth.png similarity index 100% rename from docs/images/integrations-laravelforge-auth.png rename to docs/images/integrations/laravel-forge/integrations-laravelforge-auth.png diff --git a/docs/images/integrations-laravelforge-create.png b/docs/images/integrations/laravel-forge/integrations-laravelforge-create.png similarity index 100% rename from docs/images/integrations-laravelforge-create.png rename to docs/images/integrations/laravel-forge/integrations-laravelforge-create.png diff --git a/docs/images/integrations-laravelforge-dashboard.png b/docs/images/integrations/laravel-forge/integrations-laravelforge-dashboard.png similarity index 100% rename from docs/images/integrations-laravelforge-dashboard.png rename to docs/images/integrations/laravel-forge/integrations-laravelforge-dashboard.png diff --git a/docs/images/integrations-laravelforge-serverid.png b/docs/images/integrations/laravel-forge/integrations-laravelforge-serverid.png similarity index 100% rename from docs/images/integrations-laravelforge-serverid.png rename to docs/images/integrations/laravel-forge/integrations-laravelforge-serverid.png diff --git a/docs/images/integrations-laravelforge-servers.png b/docs/images/integrations/laravel-forge/integrations-laravelforge-servers.png similarity index 100% rename from docs/images/integrations-laravelforge-servers.png rename to docs/images/integrations/laravel-forge/integrations-laravelforge-servers.png diff --git a/docs/images/integrations-laravelforge.png b/docs/images/integrations/laravel-forge/integrations-laravelforge.png similarity index 100% rename from docs/images/integrations-laravelforge.png rename to docs/images/integrations/laravel-forge/integrations-laravelforge.png diff --git a/docs/images/integrations-northflank-auth.png b/docs/images/integrations/northflank/integrations-northflank-auth.png similarity index 100% rename from docs/images/integrations-northflank-auth.png rename to docs/images/integrations/northflank/integrations-northflank-auth.png diff --git a/docs/images/integrations-northflank-create.png b/docs/images/integrations/northflank/integrations-northflank-create.png similarity index 100% rename from docs/images/integrations-northflank-create.png rename to docs/images/integrations/northflank/integrations-northflank-create.png diff --git a/docs/images/integrations-northflank-dashboard.png b/docs/images/integrations/northflank/integrations-northflank-dashboard.png similarity index 100% rename from docs/images/integrations-northflank-dashboard.png rename to docs/images/integrations/northflank/integrations-northflank-dashboard.png diff --git a/docs/images/integrations-northflank-token.png b/docs/images/integrations/northflank/integrations-northflank-token.png similarity index 100% rename from docs/images/integrations-northflank-token.png rename to docs/images/integrations/northflank/integrations-northflank-token.png diff --git a/docs/images/integrations-northflank.png b/docs/images/integrations/northflank/integrations-northflank.png similarity index 100% rename from docs/images/integrations-northflank.png rename to docs/images/integrations/northflank/integrations-northflank.png diff --git a/docs/images/integrations/qovery/integrations-qovery-auth.png b/docs/images/integrations/qovery/integrations-qovery-auth.png new file mode 100644 index 000000000..3619bc87b Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery-auth.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery-create-1.png b/docs/images/integrations/qovery/integrations-qovery-create-1.png new file mode 100644 index 000000000..cfbc05895 Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery-create-1.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery-create-2.png b/docs/images/integrations/qovery/integrations-qovery-create-2.png new file mode 100644 index 000000000..7c186b6b5 Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery-create-2.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery-token.png b/docs/images/integrations/qovery/integrations-qovery-token.png new file mode 100644 index 000000000..aa1b81b7d Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery-token.png differ diff --git a/docs/images/integrations/qovery/integrations-qovery.png b/docs/images/integrations/qovery/integrations-qovery.png new file mode 100644 index 000000000..4a8f5c400 Binary files /dev/null and b/docs/images/integrations/qovery/integrations-qovery.png differ diff --git a/docs/images/integrations-railway-authorization.png b/docs/images/integrations/railway/integrations-railway-authorization.png similarity index 100% rename from docs/images/integrations-railway-authorization.png rename to docs/images/integrations/railway/integrations-railway-authorization.png diff --git a/docs/images/integrations-railway-create.png b/docs/images/integrations/railway/integrations-railway-create.png similarity index 100% rename from docs/images/integrations-railway-create.png rename to docs/images/integrations/railway/integrations-railway-create.png diff --git a/docs/images/integrations-railway-dashboard.png b/docs/images/integrations/railway/integrations-railway-dashboard.png similarity index 100% rename from docs/images/integrations-railway-dashboard.png rename to docs/images/integrations/railway/integrations-railway-dashboard.png diff --git a/docs/images/integrations-railway-token.png b/docs/images/integrations/railway/integrations-railway-token.png similarity index 100% rename from docs/images/integrations-railway-token.png rename to docs/images/integrations/railway/integrations-railway-token.png diff --git a/docs/images/integrations-railway.png b/docs/images/integrations/railway/integrations-railway.png similarity index 100% rename from docs/images/integrations-railway.png rename to docs/images/integrations/railway/integrations-railway.png diff --git a/docs/images/integrations-render-auth.png b/docs/images/integrations/render/integrations-render-auth.png similarity index 100% rename from docs/images/integrations-render-auth.png rename to docs/images/integrations/render/integrations-render-auth.png diff --git a/docs/images/integrations-render-create.png b/docs/images/integrations/render/integrations-render-create.png similarity index 100% rename from docs/images/integrations-render-create.png rename to docs/images/integrations/render/integrations-render-create.png diff --git a/docs/images/integrations-render-dashboard.png b/docs/images/integrations/render/integrations-render-dashboard.png similarity index 100% rename from docs/images/integrations-render-dashboard.png rename to docs/images/integrations/render/integrations-render-dashboard.png diff --git a/docs/images/integrations-render-token.png b/docs/images/integrations/render/integrations-render-token.png similarity index 100% rename from docs/images/integrations-render-token.png rename to docs/images/integrations/render/integrations-render-token.png diff --git a/docs/images/integrations-render.png b/docs/images/integrations/render/integrations-render.png similarity index 100% rename from docs/images/integrations-render.png rename to docs/images/integrations/render/integrations-render.png diff --git a/docs/images/integrations-supabase-authorization.png b/docs/images/integrations/supabase/integrations-supabase-authorization.png similarity index 100% rename from docs/images/integrations-supabase-authorization.png rename to docs/images/integrations/supabase/integrations-supabase-authorization.png diff --git a/docs/images/integrations-supabase-create.png b/docs/images/integrations/supabase/integrations-supabase-create.png similarity index 100% rename from docs/images/integrations-supabase-create.png rename to docs/images/integrations/supabase/integrations-supabase-create.png diff --git a/docs/images/integrations-supabase-dashboard.png b/docs/images/integrations/supabase/integrations-supabase-dashboard.png similarity index 100% rename from docs/images/integrations-supabase-dashboard.png rename to docs/images/integrations/supabase/integrations-supabase-dashboard.png diff --git a/docs/images/integrations-supabase-token.png b/docs/images/integrations/supabase/integrations-supabase-token.png similarity index 100% rename from docs/images/integrations-supabase-token.png rename to docs/images/integrations/supabase/integrations-supabase-token.png diff --git a/docs/images/integrations-supabase.png b/docs/images/integrations/supabase/integrations-supabase.png similarity index 100% rename from docs/images/integrations-supabase.png rename to docs/images/integrations/supabase/integrations-supabase.png diff --git a/docs/images/integrations-terraformcloud-auth.png b/docs/images/integrations/terraform/integrations-terraformcloud-auth.png similarity index 100% rename from docs/images/integrations-terraformcloud-auth.png rename to docs/images/integrations/terraform/integrations-terraformcloud-auth.png diff --git a/docs/images/integrations-terraformcloud-create.png b/docs/images/integrations/terraform/integrations-terraformcloud-create.png similarity index 100% rename from docs/images/integrations-terraformcloud-create.png rename to docs/images/integrations/terraform/integrations-terraformcloud-create.png diff --git a/docs/images/integrations-terraformcloud-dashboard.png b/docs/images/integrations/terraform/integrations-terraformcloud-dashboard.png similarity index 100% rename from docs/images/integrations-terraformcloud-dashboard.png rename to docs/images/integrations/terraform/integrations-terraformcloud-dashboard.png diff --git a/docs/images/integrations-terraformcloud-tokens.png b/docs/images/integrations/terraform/integrations-terraformcloud-tokens.png similarity index 100% rename from docs/images/integrations-terraformcloud-tokens.png rename to docs/images/integrations/terraform/integrations-terraformcloud-tokens.png diff --git a/docs/images/integrations-terraformcloud-workspaceid.png b/docs/images/integrations/terraform/integrations-terraformcloud-workspaceid.png similarity index 100% rename from docs/images/integrations-terraformcloud-workspaceid.png rename to docs/images/integrations/terraform/integrations-terraformcloud-workspaceid.png diff --git a/docs/images/integrations-terraformcloud-workspaces.png b/docs/images/integrations/terraform/integrations-terraformcloud-workspaces.png similarity index 100% rename from docs/images/integrations-terraformcloud-workspaces.png rename to docs/images/integrations/terraform/integrations-terraformcloud-workspaces.png diff --git a/docs/images/integrations-terraformcloud.png b/docs/images/integrations/terraform/integrations-terraformcloud.png similarity index 100% rename from docs/images/integrations-terraformcloud.png rename to docs/images/integrations/terraform/integrations-terraformcloud.png diff --git a/docs/images/integrations-travisci-auth.png b/docs/images/integrations/travis-ci/integrations-travisci-auth.png similarity index 100% rename from docs/images/integrations-travisci-auth.png rename to docs/images/integrations/travis-ci/integrations-travisci-auth.png diff --git a/docs/images/integrations-travisci-create.png b/docs/images/integrations/travis-ci/integrations-travisci-create.png similarity index 100% rename from docs/images/integrations-travisci-create.png rename to docs/images/integrations/travis-ci/integrations-travisci-create.png diff --git a/docs/images/integrations-travisci-token.png b/docs/images/integrations/travis-ci/integrations-travisci-token.png similarity index 100% rename from docs/images/integrations-travisci-token.png rename to docs/images/integrations/travis-ci/integrations-travisci-token.png diff --git a/docs/images/integrations-travisci.png b/docs/images/integrations/travis-ci/integrations-travisci.png similarity index 100% rename from docs/images/integrations-travisci.png rename to docs/images/integrations/travis-ci/integrations-travisci.png diff --git a/docs/images/integrations-windmill-auth.png b/docs/images/integrations/windmill/integrations-windmill-auth.png similarity index 100% rename from docs/images/integrations-windmill-auth.png rename to docs/images/integrations/windmill/integrations-windmill-auth.png diff --git a/docs/images/integrations-windmill-create.png b/docs/images/integrations/windmill/integrations-windmill-create.png similarity index 100% rename from docs/images/integrations-windmill-create.png rename to docs/images/integrations/windmill/integrations-windmill-create.png diff --git a/docs/images/integrations-windmill-dashboard.png b/docs/images/integrations/windmill/integrations-windmill-dashboard.png similarity index 100% rename from docs/images/integrations-windmill-dashboard.png rename to docs/images/integrations/windmill/integrations-windmill-dashboard.png diff --git a/docs/images/integrations-windmill-token.png b/docs/images/integrations/windmill/integrations-windmill-token.png similarity index 100% rename from docs/images/integrations-windmill-token.png rename to docs/images/integrations/windmill/integrations-windmill-token.png diff --git a/docs/images/integrations-windmill.png b/docs/images/integrations/windmill/integrations-windmill.png similarity index 100% rename from docs/images/integrations-windmill.png rename to docs/images/integrations/windmill/integrations-windmill.png diff --git a/docs/images/organization-ic-add.png b/docs/images/organization-ic-add.png deleted file mode 100644 index 4f526ae2c..000000000 Binary files a/docs/images/organization-ic-add.png and /dev/null differ diff --git a/docs/images/organization-ic.png b/docs/images/organization-ic.png deleted file mode 100644 index e45ad2d82..000000000 Binary files a/docs/images/organization-ic.png and /dev/null differ diff --git a/docs/images/organization-members-add.png b/docs/images/organization-members-add.png deleted file mode 100644 index 3349ad8f4..000000000 Binary files a/docs/images/organization-members-add.png and /dev/null differ diff --git a/docs/images/organization-members.png b/docs/images/organization-members.png index 90344b698..70190df0c 100644 Binary files a/docs/images/organization-members.png and b/docs/images/organization-members.png differ diff --git a/docs/images/organization-overview.png b/docs/images/organization-overview.png new file mode 100644 index 000000000..df4273b6f Binary files /dev/null and b/docs/images/organization-overview.png differ diff --git a/docs/images/organization-service-accounts.png b/docs/images/organization-service-accounts.png deleted file mode 100644 index f48e848d3..000000000 Binary files a/docs/images/organization-service-accounts.png and /dev/null differ diff --git a/docs/images/organization.png b/docs/images/organization.png deleted file mode 100644 index 488d6c4cf..000000000 Binary files a/docs/images/organization.png and /dev/null differ diff --git a/docs/images/pit-commits.png b/docs/images/pit-commits.png deleted file mode 100644 index 22599311a..000000000 Binary files a/docs/images/pit-commits.png and /dev/null differ diff --git a/docs/images/pit-snapshot.png b/docs/images/pit-snapshot.png deleted file mode 100644 index b7e913108..000000000 Binary files a/docs/images/pit-snapshot.png and /dev/null differ diff --git a/docs/images/pit-snapshots.png b/docs/images/pit-snapshots.png deleted file mode 100644 index aa1cd52d3..000000000 Binary files a/docs/images/pit-snapshots.png and /dev/null differ diff --git a/docs/images/platform/audit-logs/audit-logs-table.png b/docs/images/platform/audit-logs/audit-logs-table.png new file mode 100644 index 000000000..a24b02bc0 Binary files /dev/null and b/docs/images/platform/audit-logs/audit-logs-table.png differ diff --git a/docs/images/platform/folder/folders-add.png b/docs/images/platform/folder/folders-add.png new file mode 100644 index 000000000..e436c5015 Binary files /dev/null and b/docs/images/platform/folder/folders-add.png differ diff --git a/docs/images/platform/folder/folders-delete.png b/docs/images/platform/folder/folders-delete.png new file mode 100644 index 000000000..dff663be9 Binary files /dev/null and b/docs/images/platform/folder/folders-delete.png differ diff --git a/docs/images/platform/folder/folders-secrets-overview.png b/docs/images/platform/folder/folders-secrets-overview.png new file mode 100644 index 000000000..1b758ea0a Binary files /dev/null and b/docs/images/platform/folder/folders-secrets-overview.png differ diff --git a/docs/images/project-ip-whitelist-add.png b/docs/images/platform/ip-allowlisting/ip-allowlisting-modal.png similarity index 100% rename from docs/images/project-ip-whitelist-add.png rename to docs/images/platform/ip-allowlisting/ip-allowlisting-modal.png diff --git a/docs/images/project-ip-whitelist.png b/docs/images/platform/ip-allowlisting/ip-allowlisting-table.png similarity index 100% rename from docs/images/project-ip-whitelist.png rename to docs/images/platform/ip-allowlisting/ip-allowlisting-table.png diff --git a/docs/images/platform/organization/organization-members-roles-add-perm.png b/docs/images/platform/organization/organization-members-roles-add-perm.png new file mode 100644 index 000000000..0e87e3e5f Binary files /dev/null and b/docs/images/platform/organization/organization-members-roles-add-perm.png differ diff --git a/docs/images/platform/organization/organization-members-roles.png b/docs/images/platform/organization/organization-members-roles.png new file mode 100644 index 000000000..454af0809 Binary files /dev/null and b/docs/images/platform/organization/organization-members-roles.png differ diff --git a/docs/images/platform/organization/organization-projects.png b/docs/images/platform/organization/organization-projects.png new file mode 100644 index 000000000..82c0aee57 Binary files /dev/null and b/docs/images/platform/organization/organization-projects.png differ diff --git a/docs/images/platform/organization/organization-settings-auth.png b/docs/images/platform/organization/organization-settings-auth.png new file mode 100644 index 000000000..8643c44da Binary files /dev/null and b/docs/images/platform/organization/organization-settings-auth.png differ diff --git a/docs/images/platform/organization/organization-settings-general.png b/docs/images/platform/organization/organization-settings-general.png new file mode 100644 index 000000000..2c60090fe Binary files /dev/null and b/docs/images/platform/organization/organization-settings-general.png differ diff --git a/docs/images/platform/organization/organization-usage-billing.png b/docs/images/platform/organization/organization-usage-billing.png new file mode 100644 index 000000000..345a437f7 Binary files /dev/null and b/docs/images/platform/organization/organization-usage-billing.png differ diff --git a/docs/images/platform/pit-recovery/pit-recovery-commits-drawer.png b/docs/images/platform/pit-recovery/pit-recovery-commits-drawer.png new file mode 100644 index 000000000..032271f04 Binary files /dev/null and b/docs/images/platform/pit-recovery/pit-recovery-commits-drawer.png differ diff --git a/docs/images/platform/pit-recovery/pit-recovery-commits.png b/docs/images/platform/pit-recovery/pit-recovery-commits.png new file mode 100644 index 000000000..8dfc3397c Binary files /dev/null and b/docs/images/platform/pit-recovery/pit-recovery-commits.png differ diff --git a/docs/images/platform/pit-recovery/pit-recovery-rollback.png b/docs/images/platform/pit-recovery/pit-recovery-rollback.png new file mode 100644 index 000000000..5bee95103 Binary files /dev/null and b/docs/images/platform/pit-recovery/pit-recovery-rollback.png differ diff --git a/docs/images/platform/project/project-secrets-add.png b/docs/images/platform/project/project-secrets-add.png new file mode 100644 index 000000000..b208818d2 Binary files /dev/null and b/docs/images/platform/project/project-secrets-add.png differ diff --git a/docs/images/platform/project/project-secrets-comment.png b/docs/images/platform/project/project-secrets-comment.png new file mode 100644 index 000000000..f669e5e12 Binary files /dev/null and b/docs/images/platform/project/project-secrets-comment.png differ diff --git a/docs/images/platform/project/project-secrets-delete-batch.png b/docs/images/platform/project/project-secrets-delete-batch.png new file mode 100644 index 000000000..be0e02361 Binary files /dev/null and b/docs/images/platform/project/project-secrets-delete-batch.png differ diff --git a/docs/images/platform/project/project-secrets-delete.png b/docs/images/platform/project/project-secrets-delete.png new file mode 100644 index 000000000..6a5bca0cd Binary files /dev/null and b/docs/images/platform/project/project-secrets-delete.png differ diff --git a/docs/images/platform/project/project-secrets-download-env.png b/docs/images/platform/project/project-secrets-download-env.png new file mode 100644 index 000000000..aba2669d3 Binary files /dev/null and b/docs/images/platform/project/project-secrets-download-env.png differ diff --git a/docs/images/platform/project/project-secrets-drawer.png b/docs/images/platform/project/project-secrets-drawer.png new file mode 100644 index 000000000..1901b1dc7 Binary files /dev/null and b/docs/images/platform/project/project-secrets-drawer.png differ diff --git a/docs/images/platform/project/project-secrets-drop-env.png b/docs/images/platform/project/project-secrets-drop-env.png new file mode 100644 index 000000000..410d5b9d7 Binary files /dev/null and b/docs/images/platform/project/project-secrets-drop-env.png differ diff --git a/docs/images/platform/project/project-secrets-ellipses.png b/docs/images/platform/project/project-secrets-ellipses.png new file mode 100644 index 000000000..2bd34db89 Binary files /dev/null and b/docs/images/platform/project/project-secrets-ellipses.png differ diff --git a/docs/images/platform/project/project-secrets-filter.png b/docs/images/platform/project/project-secrets-filter.png new file mode 100644 index 000000000..3a9444e77 Binary files /dev/null and b/docs/images/platform/project/project-secrets-filter.png differ diff --git a/docs/images/platform/project/project-secrets-override.png b/docs/images/platform/project/project-secrets-override.png new file mode 100644 index 000000000..bf2a0d590 Binary files /dev/null and b/docs/images/platform/project/project-secrets-override.png differ diff --git a/docs/images/platform/project/project-secrets-overview-open.png b/docs/images/platform/project/project-secrets-overview-open.png new file mode 100644 index 000000000..b79339f70 Binary files /dev/null and b/docs/images/platform/project/project-secrets-overview-open.png differ diff --git a/docs/images/platform/project/project-secrets-overview.png b/docs/images/platform/project/project-secrets-overview.png new file mode 100644 index 000000000..9ff628bc0 Binary files /dev/null and b/docs/images/platform/project/project-secrets-overview.png differ diff --git a/docs/images/platform/project/project-secrets-search.png b/docs/images/platform/project/project-secrets-search.png new file mode 100644 index 000000000..50650e1f9 Binary files /dev/null and b/docs/images/platform/project/project-secrets-search.png differ diff --git a/docs/images/platform/project/project-secrets-tag.png b/docs/images/platform/project/project-secrets-tag.png new file mode 100644 index 000000000..3fc618731 Binary files /dev/null and b/docs/images/platform/project/project-secrets-tag.png differ diff --git a/docs/images/platform/project/project-secrets-unhide.png b/docs/images/platform/project/project-secrets-unhide.png new file mode 100644 index 000000000..55356d878 Binary files /dev/null and b/docs/images/platform/project/project-secrets-unhide.png differ diff --git a/docs/images/platform/secret-references-imports/secret-import-add.png b/docs/images/platform/secret-references-imports/secret-import-add.png new file mode 100644 index 000000000..0b4f4225c Binary files /dev/null and b/docs/images/platform/secret-references-imports/secret-import-add.png differ diff --git a/docs/images/platform/secret-references-imports/secret-import-added.png b/docs/images/platform/secret-references-imports/secret-import-added.png new file mode 100644 index 000000000..f69f56966 Binary files /dev/null and b/docs/images/platform/secret-references-imports/secret-import-added.png differ diff --git a/docs/images/platform/secret-references-imports/secret-import-delete.png b/docs/images/platform/secret-references-imports/secret-import-delete.png new file mode 100644 index 000000000..3e6edb2fb Binary files /dev/null and b/docs/images/platform/secret-references-imports/secret-import-delete.png differ diff --git a/docs/images/platform/secret-references-imports/secret-import-reorder.png b/docs/images/platform/secret-references-imports/secret-import-reorder.png new file mode 100644 index 000000000..f55bc55e4 Binary files /dev/null and b/docs/images/platform/secret-references-imports/secret-import-reorder.png differ diff --git a/docs/images/platform/secret-references-imports/secret-reference.png b/docs/images/platform/secret-references-imports/secret-reference.png new file mode 100644 index 000000000..aee1cd92e Binary files /dev/null and b/docs/images/platform/secret-references-imports/secret-reference.png differ diff --git a/docs/images/project-download.png b/docs/images/project-download.png deleted file mode 100644 index 00b02d9bf..000000000 Binary files a/docs/images/project-download.png and /dev/null differ diff --git a/docs/images/project-drag-drop.png b/docs/images/project-drag-drop.png deleted file mode 100644 index 7ae0ed718..000000000 Binary files a/docs/images/project-drag-drop.png and /dev/null differ diff --git a/docs/images/project-envar-override.png b/docs/images/project-envar-override.png deleted file mode 100644 index d9e01c8fd..000000000 Binary files a/docs/images/project-envar-override.png and /dev/null differ diff --git a/docs/images/project-envar-toggle-moved.png b/docs/images/project-envar-toggle-moved.png deleted file mode 100644 index 2c1517b9c..000000000 Binary files a/docs/images/project-envar-toggle-moved.png and /dev/null differ diff --git a/docs/images/project-envar-toggle.png b/docs/images/project-envar-toggle.png deleted file mode 100644 index b8ffa322a..000000000 Binary files a/docs/images/project-envar-toggle.png and /dev/null differ diff --git a/docs/images/project-environment.png b/docs/images/project-environment.png deleted file mode 100644 index e2e0d2959..000000000 Binary files a/docs/images/project-environment.png and /dev/null differ diff --git a/docs/images/project-folder-token.png b/docs/images/project-folder-token.png deleted file mode 100644 index 2402acdf2..000000000 Binary files a/docs/images/project-folder-token.png and /dev/null differ diff --git a/docs/images/project-hide.png b/docs/images/project-hide.png deleted file mode 100644 index 61aed0bee..000000000 Binary files a/docs/images/project-hide.png and /dev/null differ diff --git a/docs/images/project-integrations.png b/docs/images/project-integrations.png deleted file mode 100644 index 7c2c35bd4..000000000 Binary files a/docs/images/project-integrations.png and /dev/null differ diff --git a/docs/images/project-members.png b/docs/images/project-members.png index 87b2a7ee4..a166362de 100644 Binary files a/docs/images/project-members.png and b/docs/images/project-members.png differ diff --git a/docs/images/project-search-typed.png b/docs/images/project-search-typed.png deleted file mode 100644 index 09513ba4c..000000000 Binary files a/docs/images/project-search-typed.png and /dev/null differ diff --git a/docs/images/project-search.png b/docs/images/project-search.png deleted file mode 100644 index bafc0b2c3..000000000 Binary files a/docs/images/project-search.png and /dev/null differ diff --git a/docs/images/project-token-add.png b/docs/images/project-token-add.png index f282399e6..111282c42 100644 Binary files a/docs/images/project-token-add.png and b/docs/images/project-token-add.png differ diff --git a/docs/images/project-token-added.png b/docs/images/project-token-added.png deleted file mode 100644 index fb47f3525..000000000 Binary files a/docs/images/project-token-added.png and /dev/null differ diff --git a/docs/images/project-token-name.png b/docs/images/project-token-name.png deleted file mode 100644 index 662fbb0f7..000000000 Binary files a/docs/images/project-token-name.png and /dev/null differ diff --git a/docs/images/project-token-old-add.png b/docs/images/project-token-old-add.png new file mode 100644 index 000000000..960013a3f Binary files /dev/null and b/docs/images/project-token-old-add.png differ diff --git a/docs/images/project-token-old-permissions.png b/docs/images/project-token-old-permissions.png new file mode 100644 index 000000000..11c91b77c Binary files /dev/null and b/docs/images/project-token-old-permissions.png differ diff --git a/docs/images/project-token-permissions.png b/docs/images/project-token-permissions.png new file mode 100644 index 000000000..91de75ec4 Binary files /dev/null and b/docs/images/project-token-permissions.png differ diff --git a/docs/images/project_settings_page.png b/docs/images/project_settings_page.png deleted file mode 100644 index 6f1347f52..000000000 Binary files a/docs/images/project_settings_page.png and /dev/null differ diff --git a/docs/images/secret-import-add.png b/docs/images/secret-import-add.png deleted file mode 100644 index 67445dd16..000000000 Binary files a/docs/images/secret-import-add.png and /dev/null differ diff --git a/docs/images/secret-import-change-order.png b/docs/images/secret-import-change-order.png deleted file mode 100644 index 0fba240d1..000000000 Binary files a/docs/images/secret-import-change-order.png and /dev/null differ diff --git a/docs/images/secret-versioning.png b/docs/images/secret-versioning.png deleted file mode 100644 index 96dbfde41..000000000 Binary files a/docs/images/secret-versioning.png and /dev/null differ diff --git a/docs/images/email-aws-ses-console.png b/docs/images/self-hosting/configuration/email/email-aws-ses-console.png similarity index 100% rename from docs/images/email-aws-ses-console.png rename to docs/images/self-hosting/configuration/email/email-aws-ses-console.png diff --git a/docs/images/email-aws-ses-user.png b/docs/images/self-hosting/configuration/email/email-aws-ses-user.png similarity index 100% rename from docs/images/email-aws-ses-user.png rename to docs/images/self-hosting/configuration/email/email-aws-ses-user.png diff --git a/docs/images/email-gmail-app-access.png b/docs/images/self-hosting/configuration/email/email-gmail-app-access.png similarity index 100% rename from docs/images/email-gmail-app-access.png rename to docs/images/self-hosting/configuration/email/email-gmail-app-access.png diff --git a/docs/images/email-mailhog-credentials.png b/docs/images/self-hosting/configuration/email/email-mailhog-credentials.png similarity index 100% rename from docs/images/email-mailhog-credentials.png rename to docs/images/self-hosting/configuration/email/email-mailhog-credentials.png diff --git a/docs/images/email-resend-create-domain.png b/docs/images/self-hosting/configuration/email/email-resend-create-domain.png similarity index 100% rename from docs/images/email-resend-create-domain.png rename to docs/images/self-hosting/configuration/email/email-resend-create-domain.png diff --git a/docs/images/email-resend-create-key.png b/docs/images/self-hosting/configuration/email/email-resend-create-key.png similarity index 100% rename from docs/images/email-resend-create-key.png rename to docs/images/self-hosting/configuration/email/email-resend-create-key.png diff --git a/docs/images/email-resend-smtp-settings.png b/docs/images/self-hosting/configuration/email/email-resend-smtp-settings.png similarity index 100% rename from docs/images/email-resend-smtp-settings.png rename to docs/images/self-hosting/configuration/email/email-resend-smtp-settings.png diff --git a/docs/images/email-sendgrid-create-key.png b/docs/images/self-hosting/configuration/email/email-sendgrid-create-key.png similarity index 100% rename from docs/images/email-sendgrid-create-key.png rename to docs/images/self-hosting/configuration/email/email-sendgrid-create-key.png diff --git a/docs/images/email-sendgrid-restrictions.png b/docs/images/self-hosting/configuration/email/email-sendgrid-restrictions.png similarity index 100% rename from docs/images/email-sendgrid-restrictions.png rename to docs/images/self-hosting/configuration/email/email-sendgrid-restrictions.png diff --git a/docs/images/email-socketlabs-credentials.png b/docs/images/self-hosting/configuration/email/email-socketlabs-credentials.png similarity index 100% rename from docs/images/email-socketlabs-credentials.png rename to docs/images/self-hosting/configuration/email/email-socketlabs-credentials.png diff --git a/docs/images/email-socketlabs-dashboard.png b/docs/images/self-hosting/configuration/email/email-socketlabs-dashboard.png similarity index 100% rename from docs/images/email-socketlabs-dashboard.png rename to docs/images/self-hosting/configuration/email/email-socketlabs-dashboard.png diff --git a/docs/images/email-socketlabs-domains.png b/docs/images/self-hosting/configuration/email/email-socketlabs-domains.png similarity index 100% rename from docs/images/email-socketlabs-domains.png rename to docs/images/self-hosting/configuration/email/email-socketlabs-domains.png diff --git a/docs/images/service-token-permissions.png b/docs/images/service-token-permissions.png deleted file mode 100644 index 16ecaa939..000000000 Binary files a/docs/images/service-token-permissions.png and /dev/null differ diff --git a/docs/images/sso/azure/idp-values-2.png b/docs/images/sso/azure/idp-values-2.png index e95b1781c..30b87da12 100644 Binary files a/docs/images/sso/azure/idp-values-2.png and b/docs/images/sso/azure/idp-values-2.png differ diff --git a/docs/images/sso/azure/idp-values-3.png b/docs/images/sso/azure/idp-values-3.png new file mode 100644 index 000000000..d534f1b76 Binary files /dev/null and b/docs/images/sso/azure/idp-values-3.png differ diff --git a/docs/images/sso/github/credentials.png b/docs/images/sso/github/credentials.png new file mode 100644 index 000000000..82b44c03b Binary files /dev/null and b/docs/images/sso/github/credentials.png differ diff --git a/docs/images/sso/github/dev-settings.png b/docs/images/sso/github/dev-settings.png new file mode 100644 index 000000000..e95e94321 Binary files /dev/null and b/docs/images/sso/github/dev-settings.png differ diff --git a/docs/images/sso/github/new-app-form.png b/docs/images/sso/github/new-app-form.png new file mode 100644 index 000000000..c162fee55 Binary files /dev/null and b/docs/images/sso/github/new-app-form.png differ diff --git a/docs/images/sso/github/new-app.png b/docs/images/sso/github/new-app.png new file mode 100644 index 000000000..64f552564 Binary files /dev/null and b/docs/images/sso/github/new-app.png differ diff --git a/docs/images/sso/github/settings.png b/docs/images/sso/github/settings.png new file mode 100644 index 000000000..27547dbfe Binary files /dev/null and b/docs/images/sso/github/settings.png differ diff --git a/docs/images/sso/gitlab/credentials.png b/docs/images/sso/gitlab/credentials.png new file mode 100644 index 000000000..f44223a1e Binary files /dev/null and b/docs/images/sso/gitlab/credentials.png differ diff --git a/docs/images/sso/gitlab/edit-profile.png b/docs/images/sso/gitlab/edit-profile.png new file mode 100644 index 000000000..c6eb4d95a Binary files /dev/null and b/docs/images/sso/gitlab/edit-profile.png differ diff --git a/docs/images/sso/gitlab/new-app-form.png b/docs/images/sso/gitlab/new-app-form.png new file mode 100644 index 000000000..988778b90 Binary files /dev/null and b/docs/images/sso/gitlab/new-app-form.png differ diff --git a/docs/images/sso/gitlab/new-app.png b/docs/images/sso/gitlab/new-app.png new file mode 100644 index 000000000..fac7490a6 Binary files /dev/null and b/docs/images/sso/gitlab/new-app.png differ diff --git a/docs/images/sso/google/api-services.png b/docs/images/sso/google/api-services.png new file mode 100644 index 000000000..59bd43fcd Binary files /dev/null and b/docs/images/sso/google/api-services.png differ diff --git a/docs/images/sso/google/credentials.png b/docs/images/sso/google/credentials.png new file mode 100644 index 000000000..af32df88a Binary files /dev/null and b/docs/images/sso/google/credentials.png differ diff --git a/docs/images/sso/google/new-app-form.png b/docs/images/sso/google/new-app-form.png new file mode 100644 index 000000000..834ed181f Binary files /dev/null and b/docs/images/sso/google/new-app-form.png differ diff --git a/docs/images/sso/google/new-app.png b/docs/images/sso/google/new-app.png new file mode 100644 index 000000000..b950d8070 Binary files /dev/null and b/docs/images/sso/google/new-app.png differ diff --git a/docs/integrations/build-tools/gradle.mdx b/docs/integrations/build-tools/gradle.mdx new file mode 100644 index 000000000..5005433d0 --- /dev/null +++ b/docs/integrations/build-tools/gradle.mdx @@ -0,0 +1,91 @@ +--- +title: "Gradle" +description: "How to use Infisical to inject environment variables with Gradle" +--- + +# Using Infisical with Gradle + +By integrating [Infisical CLI](../../cli/overview) with Gradle, you can configure your builds and scripts to different environments, CI/CD pipelines, and more without explicitly setting variables in the command line. + +This documentation provides an overview of how to use Infisical with [Gradle](https://gradle.org/). + +## Basic Usage + +To run a Gradle task with Infisical, you can use the `run` command. The basic structure is: + +``` +infisical run -- [Your command here] +``` + +For example, to run the `generateFile` task in Gradle: + +```groovy build.gradle +task generateFile { + doLast { + String content = System.getenv('ENV_NAME_FROM_INFISICAL') ?: 'Default Content' + file('output.txt').text = content + println "Generated output.txt with content: $content" + } +} +``` + +``` +infisical run -- gradle generateFile +``` + +With this command, Infisical will automatically inject the environment variables associated with the current Infisical project into the Gradle process. +Your Gradle script can then access these variables using `System.getenv('VARIABLE_NAME')`. + +## More Examples + +### 1. Building a Project with a Specific Profile + +Assuming you have different build profiles (e.g., 'development', 'production'), you can use Infisical to switch between them: + +``` +infisical run -- gradle build +``` + +Inside your `build.gradle`, you might have: + +```groovy build.gradle +if (System.getenv('PROFILE') == 'production') { + // production-specific configurations +} +``` + +### 2. Running Tests with Different Database Configurations + +If you want to run tests against different database configurations: + +``` +infisical run -- gradle test +``` + +Your test configuration in `build.gradle` can then adjust the database URL accordingly: + +```groovy build.gradle +test { + systemProperty 'db.url', System.getenv('DB_URL') +} +``` + +### 3. Generating Artifacts with Versioning + +For automated CI/CD pipelines, you might want to inject a build number or version: + +``` +infisical run -- gradle assemble +``` + +And in `build.gradle`: + +```groovy build.gradle +version = System.getenv('BUILD_NUMBER') ?: '1.0.0-SNAPSHOT' +``` + +## Advantages of Using Infisical with Gradle + +1. **Flexibility**: Easily adapt your Gradle builds to different environments without modifying the build scripts or setting environment variables manually. +2. **Reproducibility**: Ensure consistent builds by leveraging the environment variables from the related Infisical project. +3. **Security**: Protect sensitive information by using Infisical's secrets management without exposing them in scripts or logs. diff --git a/docs/integrations/cicd/bitbucket.mdx b/docs/integrations/cicd/bitbucket.mdx index ff33c9f9b..357de5ee5 100644 --- a/docs/integrations/cicd/bitbucket.mdx +++ b/docs/integrations/cicd/bitbucket.mdx @@ -15,7 +15,7 @@ Prerequisites: Press on the Bitbucket tile and grant Infisical access to your Bitbucket account. -![integrations bitbucket authorization](../../images/integrations-bitbucket-auth.png) +![integrations bitbucket authorization](../../images/integrations/bitbucket/integrations-bitbucket-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -28,4 +28,4 @@ Press on the Bitbucket tile and grant Infisical access to your Bitbucket account Select which Infisical environment secrets you want to sync to which Bitbucket repo and press start integration to start syncing secrets to the repo. -![integrations bitbucket](../../images/integrations-bitbucket.png) +![integrations bitbucket](../../images/integrations/bitbucket/integrations-bitbucket.png) diff --git a/docs/integrations/cicd/circleci.mdx b/docs/integrations/cicd/circleci.mdx index 58b8ae3ff..5310748ac 100644 --- a/docs/integrations/cicd/circleci.mdx +++ b/docs/integrations/cicd/circleci.mdx @@ -15,11 +15,11 @@ Prerequisites: Obtain an API token in User Settings > Personal API Tokens -![integrations circleci token](../../images/integrations-circleci-token.png) +![integrations circleci token](../../images/integrations/circleci/integrations-circleci-token.png) Press on the CircleCI tile and input your CircleCI API token to grant Infisical access to your CircleCI account. -![integrations circleci authorization](../../images/integrations-circleci-auth.png) +![integrations circleci authorization](../../images/integrations/circleci/integrations-circleci-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -32,5 +32,5 @@ Press on the CircleCI tile and input your CircleCI API token to grant Infisical Select which Infisical environment secrets you want to sync to which CircleCI project and press create integration to start syncing secrets to CircleCI. -![create integration circleci](../../images/integrations-circleci-create.png) -![integrations circleci](../../images/integrations-circleci.png) +![create integration circleci](../../images/integrations/circleci/integrations-circleci-create.png) +![integrations circleci](../../images/integrations/circleci/integrations-circleci.png) diff --git a/docs/integrations/cicd/codefresh.mdx b/docs/integrations/cicd/codefresh.mdx index e71552a09..4d0f78d33 100644 --- a/docs/integrations/cicd/codefresh.mdx +++ b/docs/integrations/cicd/codefresh.mdx @@ -15,12 +15,12 @@ Prerequisites: Obtain an API key in User Settings > API Keys -![integrations codefresh dashboard](../../images/integrations-codefresh-dashboard.png) -![integrations codefresh token](../../images/integrations-codefresh-token.png) +![integrations codefresh dashboard](../../images/integrations/codefresh/integrations-codefresh-dashboard.png) +![integrations codefresh token](../../images/integrations/codefresh/integrations-codefresh-token.png) Press on the Codefresh tile and input your Codefresh API key to grant Infisical access to your Codefresh account. -![integrations codefresh authorization](../../images/integrations-codefresh-auth.png) +![integrations codefresh authorization](../../images/integrations/codefresh/integrations-codefresh-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -33,5 +33,5 @@ Press on the Codefresh tile and input your Codefresh API key to grant Infisical Select which Infisical environment secrets you want to sync to which Codefresh service and press create integration to start syncing secrets to Codefresh. -![create integration codefresh](../../images/integrations-codefresh-create.png) -![integrations codefresh](../../images/integrations-codefresh.png) +![create integration codefresh](../../images/integrations/codefresh/integrations-codefresh-create.png) +![integrations codefresh](../../images/integrations/codefresh/integrations-codefresh.png) diff --git a/docs/integrations/cicd/gitlab.mdx b/docs/integrations/cicd/gitlab.mdx index 9c3df9720..8635a5527 100644 --- a/docs/integrations/cicd/gitlab.mdx +++ b/docs/integrations/cicd/gitlab.mdx @@ -107,7 +107,7 @@ build-job: Back in your Infisical instance, add two new environment variables for the credentials of your GitLab application: - `CLIENT_ID_GITLAB`: The **Client ID** of your GitLab application. - - `CLIENT_SECRET_GITLAB`: The **Client Secret** of your GitLab application. + - `CLIENT_SECRET_GITLAB`: The **Secret** of your GitLab application. Once added, restart your Infisical instance and use the GitLab integration. diff --git a/docs/integrations/cicd/travisci.mdx b/docs/integrations/cicd/travisci.mdx index 79737a2d8..c0f917d5f 100644 --- a/docs/integrations/cicd/travisci.mdx +++ b/docs/integrations/cicd/travisci.mdx @@ -15,11 +15,11 @@ Prerequisites: Obtain your API token in User Settings > API authentication > Token -![integrations travis ci token](../../images/integrations-travisci-token.png) +![integrations travis ci token](../../images/integrations/travis-ci/integrations-travisci-token.png) Press on the Travis CI tile and input your Travis CI API token to grant Infisical access to your Travis CI account. -![integrations travis ci authorization](../../images/integrations-travisci-auth.png) +![integrations travis ci authorization](../../images/integrations/travis-ci/integrations-travisci-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -32,5 +32,5 @@ Press on the Travis CI tile and input your Travis CI API token to grant Infisica Select which Infisical environment secrets you want to sync to which Travis CI repository and press create integration to start syncing secrets to Travis CI. -![create integration travis ci](../../images/integrations-travisci-create.png) -![integrations travis ci](../../images/integrations-travisci.png) +![create integration travis ci](../../images/integrations/travis-ci/integrations-travisci-create.png) +![integrations travis ci](../../images/integrations/travis-ci/integrations-travisci.png) diff --git a/docs/integrations/cloud/aws-parameter-store.mdx b/docs/integrations/cloud/aws-parameter-store.mdx index 42b41ac73..1ebb6d500 100644 --- a/docs/integrations/cloud/aws-parameter-store.mdx +++ b/docs/integrations/cloud/aws-parameter-store.mdx @@ -12,9 +12,9 @@ Prerequisites: Navigate to your IAM user permissions and add a permission policy to grant access to AWS Parameter Store. -![integration IAM 1](../../images/integrations-aws-iam-1.png) -![integration IAM 2](../../images/integrations-aws-parameter-store-iam-2.png) -![integrations IAM 3](../../images/integrations-aws-parameter-store-iam-3.png) +![integration IAM 1](../../images/integrations/aws/integrations-aws-iam-1.png) +![integration IAM 2](../../images/integrations/aws/integrations-aws-parameter-store-iam-2.png) +![integrations IAM 3](../../images/integrations/aws/integrations-aws-parameter-store-iam-3.png) For enhanced security, here's a custom policy containing the minimum permissions required by Infisical to sync secrets to AWS Parameter Store for the IAM user that you can use: @@ -45,13 +45,13 @@ For enhanced security, here's a custom policy containing the minimum permissions Obtain a AWS access key ID and secret access key for your IAM user in IAM > Users > User > Security credentials > Access keys -![access key 1](../../images/integrations-aws-access-key-1.png) -![access key 2](../../images/integrations-aws-access-key-2.png) -![access key 3](../../images/integrations-aws-access-key-3.png) +![access key 1](../../images/integrations/aws/integrations-aws-access-key-1.png) +![access key 2](../../images/integrations/aws/integrations-aws-access-key-2.png) +![access key 3](../../images/integrations/aws/integrations-aws-access-key-3.png) Press on the AWS Parameter Store tile and input your AWS access key ID and secret access key from the previous step. -![integration auth](../../images/integrations-aws-parameter-store-auth.png) +![integration auth](../../images/integrations/aws/integrations-aws-parameter-store-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -64,7 +64,7 @@ Press on the AWS Parameter Store tile and input your AWS access key ID and secre Select which Infisical environment secrets you want to sync to which AWS Parameter Store region and indicate the path for your secrets. Then, press create integration to start syncing secrets to AWS Parameter Store. -![integration create](../../images/integrations-aws-parameter-store-create.png) +![integration create](../../images/integrations/aws/integrations-aws-parameter-store-create.png) Infisical requires you to add a path for your secrets to be stored in AWS diff --git a/docs/integrations/cloud/aws-secret-manager.mdx b/docs/integrations/cloud/aws-secret-manager.mdx index 4df054077..eadb66d91 100644 --- a/docs/integrations/cloud/aws-secret-manager.mdx +++ b/docs/integrations/cloud/aws-secret-manager.mdx @@ -12,9 +12,9 @@ Prerequisites: Navigate to your IAM user permissions and add a permission policy to grant access to AWS Secrets Manager. -![integration IAM 1](../../images/integrations-aws-iam-1.png) -![integration IAM 2](../../images/integrations-aws-secret-manager-iam-2.png) -![integrations IAM 3](../../images/integrations-aws-secret-manager-iam-3.png) +![integration IAM 1](../../images/integrations/aws/integrations-aws-iam-1.png) +![integration IAM 2](../../images/integrations/aws/integrations-aws-secret-manager-iam-2.png) +![integrations IAM 3](../../images/integrations/aws/integrations-aws-secret-manager-iam-3.png) For better security, here's a custom policy containing the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager for the IAM user that you can use: @@ -44,13 +44,13 @@ For better security, here's a custom policy containing the minimum permissions r Obtain a AWS access key ID and secret access key for your IAM user in IAM > Users > User > Security credentials > Access keys -![access key 1](../../images/integrations-aws-access-key-1.png) -![access key 2](../../images/integrations-aws-access-key-2.png) -![access key 3](../../images/integrations-aws-access-key-3.png) +![access key 1](../../images/integrations/aws/integrations-aws-access-key-1.png) +![access key 2](../../images/integrations/aws/integrations-aws-access-key-2.png) +![access key 3](../../images/integrations/aws/integrations-aws-access-key-3.png) Press on the AWS Secrets Manager tile and input your AWS access key ID and secret access key from the previous step. -![integration auth](../../images/integrations-aws-secret-manager-auth.png) +![integration auth](../../images/integrations/aws/integrations-aws-secret-manager-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -63,7 +63,7 @@ Press on the AWS Secrets Manager tile and input your AWS access key ID and secre Select which Infisical environment secrets you want to sync to which AWS Secrets Manager region and under which secret name. Then, press create integration to start syncing secrets to AWS Secrets Manager. -![integration create](../../images/integrations-aws-secret-manager-create.png) +![integration create](../../images/integrations/aws/integrations-aws-secret-manager-create.png) Infisical currently syncs environment variables to AWS Secrets Manager as diff --git a/docs/integrations/cloud/checkly.mdx b/docs/integrations/cloud/checkly.mdx index 315764ce8..711c8cec9 100644 --- a/docs/integrations/cloud/checkly.mdx +++ b/docs/integrations/cloud/checkly.mdx @@ -15,12 +15,12 @@ Prerequisites: Obtain a Checkly API Key in User Settings > API Keys. -![integrations checkly dashboard](../../images/integrations-checkly-dashboard.png) -![integrations checkly token](../../images/integrations-checkly-token.png) +![integrations checkly dashboard](../../images/integrations/checkly/integrations-checkly-dashboard.png) +![integrations checkly token](../../images/integrations/checkly/integrations-checkly-token.png) Press on the Checkly tile and input your Checkly API Key to grant Infisical access to your Checkly account. -![integrations checkly authorization](../../images/integrations-checkly-auth.png) +![integrations checkly authorization](../../images/integrations/checkly/integrations-checkly-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -33,8 +33,8 @@ Press on the Checkly tile and input your Checkly API Key to grant Infisical acce Select which Infisical environment secrets you want to sync to Checkly and press create integration to start syncing secrets. -![integrations checkly](../../images/integrations-checkly-create.png) -![integrations checkly](../../images/integrations-checkly.png) +![integrations checkly](../../images/integrations/checkly/integrations-checkly-create.png) +![integrations checkly](../../images/integrations/checkly/integrations-checkly.png) In the new version of the Checkly integration, you are able to specify suffixes that depend on the secrets' environment and path. diff --git a/docs/integrations/cloud/cloud-66.mdx b/docs/integrations/cloud/cloud-66.mdx index 86c28440e..ab362137c 100644 --- a/docs/integrations/cloud/cloud-66.mdx +++ b/docs/integrations/cloud/cloud-66.mdx @@ -14,22 +14,22 @@ Prerequisites: ## Enter your Cloud 66 Access Token In Cloud 66 Dashboard, click on the top right icon > Account Settings > Access Token -![integrations cloud 66 dashboard](../../images/integrations-cloud-66-dashboard.png) -![integrations cloud 66 access token](../../images/integrations-cloud-66-access-token.png) +![integrations cloud 66 dashboard](../../images/integrations/cloud-66/integrations-cloud-66-dashboard.png) +![integrations cloud 66 access token](../../images/integrations/cloud-66/integrations-cloud-66-access-token.png) Create new Personal Access Token. -![integrations cloud 66 personal access token](../../images/integrations-cloud-66-pat.png) +![integrations cloud 66 personal access token](../../images/integrations/cloud-66/integrations-cloud-66-pat.png) Name it **infisical** and check **Public** and **Admin**. Then click "Create Token" -![integrations cloud 66 personal access token setup](../../images/integrations-cloud-66-pat-setup.png) +![integrations cloud 66 personal access token setup](../../images/integrations/cloud-66/integrations-cloud-66-pat-setup.png) Copy and save your token. -![integrations cloud 66 copy API token](../../images/integrations-cloud-66-copy-pat.png) +![integrations cloud 66 copy API token](../../images/integrations/cloud-66/integrations-cloud-66-copy-pat.png) ### Go to Infisical Integration Page Click on the Cloud 66 tile and enter your API token to grant Infisical access to your Cloud 66 account. -![integrations cloud 66 tile in infisical dashboard](../../images/integrations-cloud-66-infisical-dashboard.png) +![integrations cloud 66 tile in infisical dashboard](../../images/integrations/cloud-66/integrations-cloud-66-infisical-dashboard.png) If this is your project's first cloud integration, then you'll have to grant @@ -39,17 +39,17 @@ Click on the Cloud 66 tile and enter your API token to grant Infisical access to Enter your Cloud 66 Personal Access Token here. Then click "Connect to Cloud 66". -![integrations cloud 66 tile in infisical dashboard](../../images/integrations-cloud-66-paste-pat.png) +![integrations cloud 66 tile in infisical dashboard](../../images/integrations/cloud-66/integrations-cloud-66-paste-pat.png) ## Start integration Select which Infisical environment secrets you want to sync to which Cloud 66 stacks and press create integration to start syncing secrets to Cloud 66. -![integrations laravel forge](../../images/integrations-cloud-66-create.png) +![integrations laravel forge](../../images/integrations/cloud-66/integrations-cloud-66-create.png) Any existing environment variables in Cloud 66 will be deleted when you start syncing. Make sure to add all the secrets into the Infisical dashboard first before doing any integrations. Done! -![integrations laravel forge](../../images/integrations-cloud-66-done.png) +![integrations laravel forge](../../images/integrations/cloud-66/integrations-cloud-66-done.png) diff --git a/docs/integrations/cloud/cloudflare-pages.mdx b/docs/integrations/cloud/cloudflare-pages.mdx index 4a95a006a..7160aeb58 100644 --- a/docs/integrations/cloud/cloudflare-pages.mdx +++ b/docs/integrations/cloud/cloudflare-pages.mdx @@ -17,17 +17,17 @@ Obtain a Cloudflare [API token](https://dash.cloudflare.com/profile/api-tokens) 1. Create a new [API token](https://dash.cloudflare.com/profile/api-tokens) in My Profile > API Tokens -![integrations cloudflare credentials 1](../../images/integrations-cloudflare-credentials-1.png) -![integrations cloudflare credentials 2](../../images/integrations-cloudflare-credentials-2.png) -![integrations cloudflare credentials 3](../../images/integrations-cloudflare-credentials-3.png) +![integrations cloudflare credentials 1](../../images/integrations/cloudflare/integrations-cloudflare-credentials-1.png) +![integrations cloudflare credentials 2](../../images/integrations/cloudflare/integrations-cloudflare-credentials-2.png) +![integrations cloudflare credentials 3](../../images/integrations/cloudflare/integrations-cloudflare-credentials-3.png) 2. Copy your [Account ID](https://developers.cloudflare.com/fundamentals/get-started/basic-tasks/find-account-and-zone-ids/) from Account > Workers & Pages > Overview -![integrations cloudflare credentials 4](../../images/integrations-cloudflare-credentials-4.png) +![integrations cloudflare credentials 4](../../images/integrations/cloudflare/integrations-cloudflare-credentials-4.png) Press on the Cloudflare Pages tile and input your Cloudflare API token and account ID to grant Infisical access to your Cloudflare Pages. -![integrations cloudflare authorization](../../images/integrations-cloudflare-auth.png) +![integrations cloudflare authorization](../../images/integrations/cloudflare/integrations-cloudflare-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -40,5 +40,5 @@ Press on the Cloudflare Pages tile and input your Cloudflare API token and accou Select which Infisical environment secrets you want to sync to Cloudflare and press create integration to start syncing secrets. -![integrations cloudflare](../../images/integrations-cloudflare-create.png) -![integrations cloudflare](../../images/integrations-cloudflare.png) +![integrations cloudflare](../../images/integrations/cloudflare/integrations-cloudflare-create.png) +![integrations cloudflare](../../images/integrations/cloudflare/integrations-cloudflare.png) diff --git a/docs/integrations/cloud/digital-ocean-app-platform.mdx b/docs/integrations/cloud/digital-ocean-app-platform.mdx index 10dd7751d..1ed255a48 100644 --- a/docs/integrations/cloud/digital-ocean-app-platform.mdx +++ b/docs/integrations/cloud/digital-ocean-app-platform.mdx @@ -10,10 +10,10 @@ Prerequisites: ## Get your Digital Ocean Personal Access Tokens On Digital Ocean dashboard, navigate to **API > Tokens** and click on "Generate New Token" -![integrations digital ocean dashboard](../../images/integrations-do-dashboard.png) +![integrations digital ocean dashboard](../../images/integrations/digital-ocean/integrations-do-dashboard.png) Name it **infisical**, choose **No expiry**, and make sure to check **Write (optional)**. Then click on "Generate Token" and copy your API token. -![integrations digital ocean token modal](../../images/integrations-do-token-modal.png) +![integrations digital ocean token modal](../../images/integrations/digital-ocean/integrations-do-token-modal.png) ## Navigate to your project's integrations tab @@ -28,12 +28,12 @@ Click on the **Digital Ocean App Platform** tile and enter your API token to gra Then enter your Digital Ocean Personal Access Token here. Then click "Connect to Digital Ocean App Platform". -![integrations infisical dashboard digital ocean integration](../../images/integrations-do-enter-token.png) +![integrations infisical dashboard digital ocean integration](../../images/integrations/digital-ocean/integrations-do-enter-token.png) ## Start integration Select which Infisical environment secrets you want to sync to which Digital Ocean App and click "Create Integration". -![integrations digital ocean select projects](../../images/integrations-do-select-projects.png) +![integrations digital ocean select projects](../../images/integrations/digital-ocean/integrations-do-select-projects.png) Done! -![integrations digital ocean integration success](../../images/integrations-do-success.png) +![integrations digital ocean integration success](../../images/integrations/digital-ocean/integrations-do-success.png) diff --git a/docs/integrations/cloud/flyio.mdx b/docs/integrations/cloud/flyio.mdx index 283c54176..7fe89495d 100644 --- a/docs/integrations/cloud/flyio.mdx +++ b/docs/integrations/cloud/flyio.mdx @@ -15,12 +15,12 @@ Prerequisites: Obtain a Fly.io access token in Access Tokens -![integrations fly dashboard](../../images/integrations-flyio-dashboard.png) -![integrations fly token](../../images/integrations-flyio-token.png) +![integrations fly dashboard](../../images/integrations/flyio/integrations-flyio-dashboard.png) +![integrations fly token](../../images/integrations/flyio/integrations-flyio-token.png) Press on the Fly.io tile and input your Fly.io access token to grant Infisical access to your Fly.io account. -![integrations fly authorization](../../images/integrations-flyio-auth.png) +![integrations fly authorization](../../images/integrations/flyio/integrations-flyio-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -33,5 +33,5 @@ Press on the Fly.io tile and input your Fly.io access token to grant Infisical a Select which Infisical environment secrets you want to sync to which Fly.io app and press create integration to start syncing secrets to Fly.io. -![integrations fly](../../images/integrations-flyio-create.png) -![integrations fly](../../images/integrations-flyio.png) +![integrations fly](../../images/integrations/flyio/integrations-flyio-create.png) +![integrations fly](../../images/integrations/flyio/integrations-flyio.png) diff --git a/docs/integrations/cloud/hashicorp-vault.mdx b/docs/integrations/cloud/hashicorp-vault.mdx index 936a16941..51a66f3ff 100644 --- a/docs/integrations/cloud/hashicorp-vault.mdx +++ b/docs/integrations/cloud/hashicorp-vault.mdx @@ -34,17 +34,17 @@ To begin, navigate to the cluster / namespace that you want to sync secrets to i In Secrets, enable a KV Secrets Engine at a path for Infisical to sync secrets to; we'll use the path `kv`. -![integrations hashicorp vault secrets engine](../../images/integrations-hashicorp-vault-engine-1.png) -![integrations hashicorp vault secrets engine](../../images/integrations-hashicorp-vault-engine-2.png) -![integrations hashicorp vault secrets engine](../../images/integrations-hashicorp-vault-engine-3.png) +![integrations hashicorp vault secrets engine](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-1.png) +![integrations hashicorp vault secrets engine](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-2.png) +![integrations hashicorp vault secrets engine](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-engine-3.png) ### Enable the AppRole auth method In Access > Auth Methods, enable the AppRole auth method. -![integrations hashicorp vault access](../../images/integrations-hashicorp-vault-access-1.png) -![integrations hashicorp vault access](../../images/integrations-hashicorp-vault-access-2.png) -![integrations hashicorp vault access](../../images/integrations-hashicorp-vault-access-3.png) +![integrations hashicorp vault access](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-1.png) +![integrations hashicorp vault access](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-2.png) +![integrations hashicorp vault access](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-access-3.png) ### Create an ACL Policy @@ -67,9 +67,9 @@ path "sys/namespaces/*" { that we want to sync secrets to. -![integrations hashicorp vault policy](../../images/integrations-hashicorp-vault-policy-1.png) -![integrations hashicorp vault policy](../../images/integrations-hashicorp-vault-policy-2.png) -![integrations hashicorp vault policy](../../images/integrations-hashicorp-vault-policy-3.png) +![integrations hashicorp vault policy](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-1.png) +![integrations hashicorp vault policy](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-2.png) +![integrations hashicorp vault policy](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-policy-3.png) ### Create a role with the policy attached @@ -77,7 +77,7 @@ We now create a `infisical` role with the generated token's time-to-live (TTL) s 1. Click the Vault CLI shell icon (`>_`) to open a command shell in the browser. -![integrations hashicorp vault shell](../../images/integrations-hashicorp-vault-shell.png) +![integrations hashicorp vault shell](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-shell.png) 2. Copy the command below. @@ -129,7 +129,7 @@ Great. We're now ready to connect Infisical to Vault! Back in Infisical, press on the HashiCorp Vault tile and input your Vault instance and `infisical` role RoleID and SecretID. -![integrations hashicorp vault authorization](../../images/integrations-hashicorp-vault-auth.png) +![integrations hashicorp vault authorization](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-auth.png) For additional details on each field: @@ -137,7 +137,7 @@ For additional details on each field: If using HCP, you can copy your Cluster URL in the Cluster Overview: -![integrations hashicorp vault cluster URL](../../images/integrations-hashicorp-vault-cluster-url.png) +![integrations hashicorp vault cluster URL](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-cluster-url.png) - Vault Namespace: The Vault namespace you wish to connect to. - Vault RoleID: The RoleID previously created for the `infisical` role. @@ -154,8 +154,8 @@ For additional details on each field: Press create integration to start syncing secrets to Vault. -![integrations hashicorp vault](../../images/integrations-hashicorp-vault-create.png) -![integrations hashicorp vault](../../images/integrations-hashicorp-vault.png) +![integrations hashicorp vault](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault-create.png) +![integrations hashicorp vault](../../images/integrations/hashicorp-vault/integrations-hashicorp-vault.png) diff --git a/docs/integrations/cloud/laravel-forge.mdx b/docs/integrations/cloud/laravel-forge.mdx index a3cf51843..67a771e6f 100644 --- a/docs/integrations/cloud/laravel-forge.mdx +++ b/docs/integrations/cloud/laravel-forge.mdx @@ -15,17 +15,17 @@ Prerequisites: Obtain a Laravel Forge access token in API Tokens -![integrations laravel forge dashboard](../../images/integrations-laravelforge-dashboard.png) -![integrations laravel forge api tokens](../../images/integrations-laravelforge-api.png) +![integrations laravel forge dashboard](../../images/integrations/laravel-forge/integrations-laravelforge-dashboard.png) +![integrations laravel forge api tokens](../../images/integrations/laravel-forge/integrations-laravelforge-api.png) Obtain your Laravel Forge Server ID in Servers > Server ID -![integrations laravel forge server](../../images/integrations-laravelforge-servers.png) -![integrations laravel forge server id](../../images/integrations-laravelforge-serverid.png) +![integrations laravel forge server](../../images/integrations/laravel-forge/integrations-laravelforge-servers.png) +![integrations laravel forge server id](../../images/integrations/laravel-forge/integrations-laravelforge-serverid.png) Press on the Laravel Forge tile and input your Laravel Forge access token and server ID to grant Infisical access to your Laravel Forge account. -![integrations laravel forge authorization](../../images/integrations-laravelforge-auth.png) +![integrations laravel forge authorization](../../images/integrations/laravel-forge/integrations-laravelforge-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -38,5 +38,5 @@ Press on the Laravel Forge tile and input your Laravel Forge access token and se Select which Infisical environment secrets you want to sync to which Laravel Forge site and press create integration to start syncing secrets to Laravel Forge. -![integrations laravel forge](../../images/integrations-laravelforge-create.png) -![integrations laravel forge](../../images/integrations-laravelforge.png) +![integrations laravel forge](../../images/integrations/laravel-forge/integrations-laravelforge-create.png) +![integrations laravel forge](../../images/integrations/laravel-forge/integrations-laravelforge.png) diff --git a/docs/integrations/cloud/northflank.mdx b/docs/integrations/cloud/northflank.mdx index 8b4f7eeb9..c677e871c 100644 --- a/docs/integrations/cloud/northflank.mdx +++ b/docs/integrations/cloud/northflank.mdx @@ -16,12 +16,12 @@ Prerequisites: Obtain a Northflank API token in Account settings > API > Tokens -![integrations northflank dashboard](../../images/integrations-northflank-dashboard.png) -![integrations northflank token](../../images/integrations-northflank-token.png) +![integrations northflank dashboard](../../images/integrations/northflank/integrations-northflank-dashboard.png) +![integrations northflank token](../../images/integrations/northflank/integrations-northflank-token.png) Press on the Northflank tile and input your Northflank API token to grant Infisical access to your Northflank account. -![integrations northflank authorization](../../images/integrations-northflank-auth.png) +![integrations northflank authorization](../../images/integrations/northflank/integrations-northflank-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -34,5 +34,5 @@ Press on the Northflank tile and input your Northflank API token to grant Infisi Select which Infisical environment secrets you want to sync to which Northflank project and secret group. Finally, press create integration to start syncing secrets to Northflank. -![integrations northflank](../../images/integrations-northflank-create.png) -![integrations northflank](../../images/integrations-northflank.png) +![integrations northflank](../../images/integrations/northflank/integrations-northflank-create.png) +![integrations northflank](../../images/integrations/northflank/integrations-northflank.png) diff --git a/docs/integrations/cloud/qovery.mdx b/docs/integrations/cloud/qovery.mdx new file mode 100644 index 000000000..98539dc8f --- /dev/null +++ b/docs/integrations/cloud/qovery.mdx @@ -0,0 +1,43 @@ +--- +title: "Qovery" +description: "How to sync secrets from Infisical to Qovery" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Enter your Qovery API Token + +Obtain a Qovery API Token in Settings > API Token. + +![integrations qovery api token](../../images/integrations/qovery/integrations-qovery-token.png) + +Press on the Qovery tile and input your Qovery API Token to grant Infisical access to your Qovery account. + +![integrations qovery authorization](../../images/integrations/qovery/integrations-qovery-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it is necessary for Infisical to sync the environment variables to + the cloud platform. + + +## Start integration + +Select which Infisical environment secrets you want to sync to Qovery and press create integration to start syncing secrets. + +![integrations qovery create](../../images/integrations/qovery/integrations-qovery-create-1.png) + +![integrations qovery create](../../images/integrations/qovery/integrations-qovery-create-2.png) + + + Infisical supports syncing secrets to various Qovery scopes including applications, jobs, or containers. + + +![integrations qovery settings](../../images/integrations/qovery/integrations-qovery.png) \ No newline at end of file diff --git a/docs/integrations/cloud/railway.mdx b/docs/integrations/cloud/railway.mdx index e19cfb626..e12881995 100644 --- a/docs/integrations/cloud/railway.mdx +++ b/docs/integrations/cloud/railway.mdx @@ -16,8 +16,8 @@ Prerequisites: Obtain a Railway API Token in your Railway [Account Settings > Tokens](https://railway.app/account/tokens). -![integrations railway dashboard](../../images/integrations-railway-dashboard.png) -![integrations railway token](../../images/integrations-railway-token.png) +![integrations railway dashboard](../../images/integrations/railway/integrations-railway-dashboard.png) +![integrations railway token](../../images/integrations/railway/integrations-railway-token.png) If this is your first time creating a Railway API token, then you'll be prompted to join @@ -29,7 +29,7 @@ Obtain a Railway API Token in your Railway [Account Settings > Tokens](https://r Press on the Railway tile and input your Railway API Key to grant Infisical access to your Railway account. -![integrations railway authorization](../../images/integrations-railway-authorization.png) +![integrations railway authorization](../../images/integrations/railway/integrations-railway-authorization.png) If this is your project's first cloud integration, then you'll have to grant @@ -42,7 +42,7 @@ Press on the Railway tile and input your Railway API Key to grant Infisical acce Select which Infisical environment secrets you want to sync to which Railway project and environment (and optionally service). Lastly, press create integration to start syncing secrets to Railway. -![integrations create railway](../../images/integrations-railway-create.png) +![integrations create railway](../../images/integrations/railway/integrations-railway-create.png) Infisical integrates with both Railway's [shared variables](https://blog.railway.app/p/shared-variables-release) at the project environment level as well as service variables at the service level. @@ -50,5 +50,5 @@ Select which Infisical environment secrets you want to sync to which Railway pro To sync secrets to a specific service in a project, you can select a service from the Railway Service dropdown; otherwise, leaving it empty will sync secrets to the shared variables of that project. -![integrations railway](../../images/integrations-railway.png) +![integrations railway](../../images/integrations/railway/integrations-railway.png) diff --git a/docs/integrations/cloud/render.mdx b/docs/integrations/cloud/render.mdx index 290e39012..237226562 100644 --- a/docs/integrations/cloud/render.mdx +++ b/docs/integrations/cloud/render.mdx @@ -15,12 +15,12 @@ Prerequisites: Obtain a Render API Key in your Render Account Settings > API Keys. -![integrations render dashboard](../../images/integrations-render-dashboard.png) -![integrations render token](../../images/integrations-render-token.png) +![integrations render dashboard](../../images/integrations/render/integrations-render-dashboard.png) +![integrations render token](../../images/integrations/render/integrations-render-token.png) Press on the Render tile and input your Render API Key to grant Infisical access to your Render account. -![integrations render authorization](../../images/integrations-render-auth.png) +![integrations render authorization](../../images/integrations/render/integrations-render-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -33,5 +33,5 @@ Press on the Render tile and input your Render API Key to grant Infisical access Select which Infisical environment secrets you want to sync to which Render service and press create integration to start syncing secrets to Render. -![integrations render](../../images/integrations-render-create.png) -![integrations render](../../images/integrations-render.png) +![integrations render](../../images/integrations/render/integrations-render-create.png) +![integrations render](../../images/integrations/render/integrations-render.png) diff --git a/docs/integrations/cloud/supabase.mdx b/docs/integrations/cloud/supabase.mdx index 14c787f75..14e113ea6 100644 --- a/docs/integrations/cloud/supabase.mdx +++ b/docs/integrations/cloud/supabase.mdx @@ -21,12 +21,12 @@ Prerequisites: ## Enter your Supabase Access Token Obtain a Supabase Access Token in your Supabase [Account > Access Tokens](https://app.supabase.com/account/tokens). -![integrations supabase dashboard](../../images/integrations-supabase-dashboard.png) -![integrations supabase token](../../images/integrations-supabase-token.png) +![integrations supabase dashboard](../../images/integrations/supabase/integrations-supabase-dashboard.png) +![integrations supabase token](../../images/integrations/supabase/integrations-supabase-token.png) Press on the Supabase tile and input your Supabase Access Token to grant Infisical access to your Supabase account. -![integrations supabase authorization](../../images/integrations-supabase-authorization.png) +![integrations supabase authorization](../../images/integrations/supabase/integrations-supabase-authorization.png) If this is your project's first cloud integration, then you'll have to grant @@ -39,6 +39,6 @@ Press on the Supabase tile and input your Supabase Access Token to grant Infisic Select which Infisical environment secrets you want to sync to which Supabase project. Lastly, press create integration to start syncing secrets to Supabase. -![integrations supabase create](../../images/integrations-supabase-create.png) +![integrations supabase create](../../images/integrations/supabase/integrations-supabase-create.png) -![integrations supabase](../../images/integrations-supabase.png) \ No newline at end of file +![integrations supabase](../../images/integrations/supabase/integrations-supabase.png) \ No newline at end of file diff --git a/docs/integrations/cloud/terraform-cloud.mdx b/docs/integrations/cloud/terraform-cloud.mdx index 9000fe194..0d68b02e3 100644 --- a/docs/integrations/cloud/terraform-cloud.mdx +++ b/docs/integrations/cloud/terraform-cloud.mdx @@ -15,17 +15,17 @@ Prerequisites: Obtain a Terraform Cloud API Token in User Settings > Tokens -![integrations terraform cloud dashboard](../../images/integrations-terraformcloud-dashboard.png) -![integrations terraform cloud tokens](../../images/integrations-terraformcloud-tokens.png) +![integrations terraform cloud dashboard](../../images/integrations/terraform/integrations-terraformcloud-dashboard.png) +![integrations terraform cloud tokens](../../images/integrations/terraform/integrations-terraformcloud-tokens.png) Obtain your Terraform Cloud Workspace Id in Projects & Workspaces > Workspace > ID -![integrations terraform cloud projects & workspaces](../../images/integrations-terraformcloud-workspaces.png) -![integrations terraform cloud workspace id](../../images/integrations-terraformcloud-workspaceid.png) +![integrations terraform cloud projects & workspaces](../../images/integrations/terraform/integrations-terraformcloud-workspaces.png) +![integrations terraform cloud workspace id](../../images/integrations/terraform/integrations-terraformcloud-workspaceid.png) Press on the Terraform Cloud tile and input your Terraform Cloud API Token and Workspace Id to grant Infisical access to your Terraform Cloud account. -![integrations terraform cloud authorization](../../images/integrations-terraformcloud-auth.png) +![integrations terraform cloud authorization](../../images/integrations/terraform/integrations-terraformcloud-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -38,5 +38,5 @@ Press on the Terraform Cloud tile and input your Terraform Cloud API Token and W Select which Infisical environment secrets and Terraform Cloud variable type you want to sync to which Terraform Cloud workspace/project and press create integration to start syncing secrets to Terraform Cloud. -![integrations terraform cloud](../../images/integrations-terraformcloud-create.png) -![integrations terraform cloud](../../images/integrations-terraformcloud.png) +![integrations terraform cloud](../../images/integrations/terraform/integrations-terraformcloud-create.png) +![integrations terraform cloud](../../images/integrations/terraform/integrations-terraformcloud.png) diff --git a/docs/integrations/cloud/vercel.mdx b/docs/integrations/cloud/vercel.mdx index f756e4fe8..b7396d067 100644 --- a/docs/integrations/cloud/vercel.mdx +++ b/docs/integrations/cloud/vercel.mdx @@ -61,22 +61,23 @@ Select which Infisical environment secrets you want to sync to which Vercel app ![integrations Vercel config](../../images/integrations/vercel/integrations-vercel-config-integrations-console.png) ![integrations Vercel config](../../images/integrations/vercel/integrations-vercel-config-new-app.png) - Create the application. As part of the form, set **Redirect URL** to `https://your-domain.com/integrations/vercel/oauth2/callback`. Also, + Create the application. As part of the form, set a **URL Slug** to a unique slug like `infisical-your-domain` and keep it handy. Also, set **Redirect URL** to `https://your-domain.com/integrations/vercel/oauth2/callback`. Lastly, be sure to set the API Scopes according to the second screenshot below. ![integrations Vercel config](../../images/integrations/vercel/integrations-vercel-config-new-app-form-1.png) ![integrations Vercel config](../../images/integrations/vercel/integrations-vercel-config-new-app-form-2.png) - ## Add your Vercel integration credentials to Infisical + ## Add your Vercel integration credentials and information to Infisical - Obtain the **Client (Integration) ID** and **Client (Integration) Secret** for your Vercel integration. + Obtain the **Client (Integration) ID** and **Client (Integration) Secret** as well as the **URL Slug** from earlier for your Vercel integration. ![integrations Vercel config](../../images/integrations/vercel/integrations-vercel-config-credentials.png) - Back in your Infisical instance, add two new environment variables for the credentials of your Vercel integration. + Back in your Infisical instance, add three new environment variables for the credentials of your Vercel integration. - `CLIENT_ID_VERCEL`: The **Client (Integration) ID** of your Vercel integration. - `CLIENT_SECRET_VERCEL`: The **Client (Integration) Secret** of your Vercel integration. + - `CLIENT_SLUG_VERCEL`: The **URL Slug** of your Vercel integration. Once added, restart your Infisical instance and use the Vercel integration. diff --git a/docs/integrations/cloud/windmill.mdx b/docs/integrations/cloud/windmill.mdx index c79931b70..9b41b12bd 100644 --- a/docs/integrations/cloud/windmill.mdx +++ b/docs/integrations/cloud/windmill.mdx @@ -15,12 +15,12 @@ Prerequisites: Obtain a [Windmill](https://www.windmill.dev/) access token in Access Tokens -![integrations windmill dashboard](../../images/integrations-windmill-dashboard.png) -![integrations windmill token](../../images/integrations-windmill-token.png) +![integrations windmill dashboard](../../images/integrations/windmill/integrations-windmill-dashboard.png) +![integrations windmill token](../../images/integrations/windmill/integrations-windmill-token.png) Press on the Windmill tile and input your Windmill access token to grant Infisical access to your Windmill account. -![integrations windmill authorization](../../images/integrations-windmill-auth.png) +![integrations windmill authorization](../../images/integrations/windmill/integrations-windmill-auth.png) If this is your project's first cloud integration, then you'll have to grant @@ -33,8 +33,8 @@ Press on the Windmill tile and input your Windmill access token to grant Infisic Select which Infisical environment secrets you want to sync to which Windmill workspace and press create integration to start syncing secrets to Windmill. -![integrations windmill](../../images/integrations-windmill-create.png) -![integrations windmill](../../images/integrations-windmill.png) +![integrations windmill](../../images/integrations/windmill/integrations-windmill-create.png) +![integrations windmill](../../images/integrations/windmill/integrations-windmill.png) Secrets synced to Windmill are subject to the [ownership path diff --git a/docs/integrations/frameworks/bun.mdx b/docs/integrations/frameworks/bun.mdx new file mode 100644 index 000000000..a5085920a --- /dev/null +++ b/docs/integrations/frameworks/bun.mdx @@ -0,0 +1,34 @@ +--- +title: "Bun" +description: "How to use Infisical to inject environment variables and secrets into a Bun app." +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) +- [Install the CLI](/cli/overview) + +## Initialize Infisical for your [Bun](https://bun.sh) + +```bash +# navigate to the root of your of your project +cd /path/to/project + +# then initialize infisical +infisical init +``` + +## Start your application as usual but with Infisical + +```bash +infisical run -- + +# Example +infisical run -- bun run dev +``` + + + Bun environment variables can be called as either `Bun.env.SECRET` or `process.env.SECRET`. We also recommend you check this more in-depth [guide to environment variables in Bun](https://infisical.com/blog/bun-environment-variables). + + + \ No newline at end of file diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index fc05d817c..b90cf7c36 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -27,6 +27,7 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [Northflank](/integrations/cloud/northflank) | Cloud | Available | | [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | | [Checkly](/integrations/cloud/checkly) | Cloud | Available | +| [Qovery](/integrations/cloud/qovery) | Cloud | Available | | [HashiCorp Vault](/integrations/cloud/hashicorp-vault) | Cloud | Available | | [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | | [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | diff --git a/docs/internals/service-tokens-new.mdx b/docs/internals/service-tokens-new.mdx new file mode 100644 index 000000000..4fc8a29cf --- /dev/null +++ b/docs/internals/service-tokens-new.mdx @@ -0,0 +1,72 @@ +--- +title: "Service tokens" +description: "Understanding service tokens and their best practices" +--- +โ€‹ +Many clients use service tokens to authenticate and read/write secrets from/to Infisical; they can be created in your project settings. + +On this page, we discuss Service Token V3, the new and improved authentication method. + +## Anatomy + +A service token in Infisical exports a `JSON` file containing 3 components: `publicKey`, `privateKey`, and `serviceToken` where +`serviceToken` is a JWT token prefixed with `proj_token`. The token provides access to the Infisical API and the public-private key +pairs are to support cryptographic operations for the client whenever E2EE is needed. + +### Database model + +The storage backend model for a token contains the following information: + +- ID: The token identifier. +- Expiration: The date at which point the token is invalid. +- Project: The project that the token is part of. +- Status: The active/inactive state of a token. +- Scopes: The project environment(s) and path(s) that the token has access to as well as `read` or `readWrite` permissions for them. +- Trusted IPs: The specific (IPv4 or IPv6) IPs or CIDR ranges that the token can be used from. +- Last used: The date at which point the token was last used. +- Usage count: The number of times that the token has been used. + +### Token + +As mentioned before, a service token consists of three components, exported as a `JSON`, used for authentication and cryptographic purposes. + +Consider the following `JSON`: + +``` +{ + "publicKey": "...", + "privateKey": "...", + "serviceToken": "stv3..." +} +``` + +Here, the `serviceToken` component can be used to authenticate with the API, by including it in the `Authorization` header under `Bearer ` and retrieve (encrypted) secrets as well as a project key back. Meanwhile, the `privateKey` (in the `JSON`), and `publicKey` (returned in the encrypted project key response) can be used to decrypt the project key used to decrypt the secrets. + +Note that when using service tokens via select client methods like SDK or CLI, cryptographic operations are abstracted for you that is the token is parsed and encryption/decryption operations are handled. If using service tokens with the REST API and end-to-end encryption enabled, then you will have to handle the encryption/decryption operations yourself. +โ€‹ +## Recommendations + +### Permissions + +You should consider the [principle of least privilege(PoLP)](https://en.wikipedia.org/wiki/Principle_of_least_privilege) when setting which environment(s) and path(s) +should be accessible by a service token; you should also consider whether or not it needs `read` or `readWrite` access. + +For example, if the client using the token only requires `read` access to the secrets in the `/config` path of the staging environment, then you should scope the token to the `/config` path of that environment only with `read` permission. + +### Status & Expiration + +We recommend considering whether or not a service token should be able to access secrets indefinitely or within a finite lifetime such as until 6 months or 1 year from now + +### Network access + +We recommend configuring the IP allowlist configuration of each service token to restrict its usage to specific IP addresses or CIDR-notated range of addresses. + +### Storage + +Since service tokens grant access to your secrets, we recommend storing them securely across your development cycle whether it be in a .env file in local development or as an environment variable of your deployment platform. + +### Rotation + +We recommend periodically rotating the service token, even in the absence of compromise. Since service tokens are capable of decrypting project keys used to decrypt secrets, they should be rotated before approximately 2^32 encryptions have been performed; this follows the guidance set forth by [NIST publication 800-38D](https://csrc.nist.gov/pubs/sp/800/38/d/final). + +Note that Infisical keeps track of the number of times that service tokens are used and will alert you when you have reached 90% of the recommended capacity. \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index 9eaeddb1f..dbe122531 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -117,15 +117,16 @@ "documentation/platform/secret-reference", "documentation/platform/webhooks", "documentation/platform/pit-recovery", - "documentation/platform/secret-versioning", "documentation/platform/audit-logs", "documentation/platform/token", - "documentation/platform/ip-allowlisting", "documentation/platform/mfa", { "group": "SSO", "pages": [ "documentation/platform/sso/overview", + "documentation/platform/sso/google", + "documentation/platform/sso/github", + "documentation/platform/sso/gitlab", "documentation/platform/sso/okta", "documentation/platform/sso/azure", "documentation/platform/sso/jumpcloud" @@ -140,16 +141,17 @@ "group": "Deployment options", "pages": [ "self-hosting/overview", + "self-hosting/deployment-options/standalone-infisical", "self-hosting/deployment-options/kubernetes-helm", "self-hosting/deployment-options/aws-ec2", "self-hosting/deployment-options/docker-compose", - "self-hosting/deployment-options/standalone-infisical", "self-hosting/deployment-options/digital-ocean-marketplace" ] }, "self-hosting/configuration/envars", "self-hosting/configuration/email", "self-hosting/configuration/redis", + "self-hosting/configuration/sso", "self-hosting/faq" ] }, @@ -166,6 +168,7 @@ "cli/commands/run", "cli/commands/secrets", "cli/commands/export", + "cli/commands/service-token", "cli/commands/vault", "cli/commands/user", "cli/commands/reset", @@ -235,6 +238,7 @@ "integrations/cloud/teamcity", "integrations/cloud/cloudflare-pages", "integrations/cloud/checkly", + "integrations/cloud/qovery", "integrations/cloud/hashicorp-vault", "integrations/cloud/azure-key-vault", "integrations/cloud/gcp-secret-manager", @@ -271,6 +275,12 @@ "integrations/platforms/pm2" ] }, + { + "group": "Build Tool Integrations", + "pages": [ + "integrations/build-tools/gradle" + ] + }, { "group": "Overview", "pages": ["sdks/overview"] @@ -377,15 +387,6 @@ "internals/service-tokens" ] }, - { - "group": "Security", - "pages": [ - "security/overview", - "security/data-model", - "security/mechanics", - "security/service-tokens" - ] - }, { "group": "Overview", "pages": ["changelog/overview"] diff --git a/docs/security/data-model.mdx b/docs/security/data-model.mdx deleted file mode 100644 index af4adeb24..000000000 --- a/docs/security/data-model.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: "Data Model" -description: "Infisical's current Data Structure." ---- - -Infisical stores a range of data namely user, secrets, keys, organization, project, and membership data. - -## Users - -The `User` model includes the fields `email`, `firstName`, `lastName`, `publicKey`, `encryptionVersion`, `protectedKey`, `protectedKeyIV`, `protectedKeyTag`, `encryptedPrivateKey`, `iv`, `tag`, `salt`, `verifier`, and `refreshVersion`. - -Infisical makes a usability-security tradeoff that is to give users convenient access to public-private key pairs across different devices upon login, solving key-storage and transfer challenges across device and browser mediums, in exchange for it storing `encryptedPrivateKey`. - - - `encryptedPrivateKey` is obtained by symmetrically encrypting the user's - private key locally with a protected key which is encrypted by the key derived - from the user's password and salt. Encryption is done via `AES256-GCM` and key - derivation via `argon2id`. The user's password is not sent to the server โ€” - this is done with SRP. - - -## Secrets - -The `Secret` model includes the fields `workspace`, `type`, `user`, `environment`, `secretBlindIndex`, `secretKeyCiphertext`, `secretKeyIV`, `secretKeyTag`, `secretValueCiphertext`, `secretValueIV`, and `secretValueTag`. - -Each secret consists of a key name and value pair and is symmetrically encrypted by the key of the project that it belongs to; that key's encrypted copies are stored in a separate `Key` collection. - -The `secretBlindIndex` enables users to query secrets by their names; it is a blind index computed by applying `argon2id` with a 128-bit random salt (unique to each project) and the name of the secret. The salt itself is symmetrically encrypted under the server key and stored in the `SecretBlindIndexData` collection. - -## Blind Index Data - -The `SecretBlindIndexData` model includes the fields `workspace`, `encryptedSaltCiphertext`, `saltIV`, and `saltTag`. - -Infisical stores salts (unique to each project) symmetrically encrypted under the server key. The salts are used to compute blind indices for secrets that enable -users to query secrets by name. - -## Project Keys - -The `Key` model includes the fields `encryptedKey`, `nonce`, `sender`, `receiver`, and `workspace`. - -Infisical stores copies of project keys, one for each member of a project, asymmetrically encrypted under each member's public key. - -## Bots - -The `Bot` model contains the fields `name`, `workspace`, `isActive`, `publicKey`, `encryptedPrivateKey`, `iv`, and `tag`. - -Each project comes with a bot that has its own public-private key pair; its private key is symmetrically encrypted by the server's key. If needed, a user can opt-in to share their project key with the bot (i.e. Infisical) to give the platform access to the project's secrets. - - - Sharing secrets with Infisical so they can be synced to integrations like - Vercel, GitHub, and Netlify is something we make sure users consent to before - opting in. - - -## Organizations and Workspaces - -The `Organization`, `Workspace`, `MembershipOrg`, and `Membership` models contain enrollment information for organizations and projects; they are used to check if users are authorized to retrieve select secrets. - -## Service Tokens - -The `ServiceTokenData` model contains data for service tokens that enable users to fetch secrets from a particular project and environment; each service token data record includes an (encrypted) copy of the project key that it is bound to as well as a validation hash for `bcrypt`. - -## API Keys - -The `APIKeyData` model contains data for API keys that enable users to interact with [Infisical's Open API](https://infisical.com/docs/api-reference/overview/introduction); each API key data record includes a validation hash for `bcrypt`. diff --git a/docs/security/mechanics.mdx b/docs/security/mechanics.mdx deleted file mode 100644 index 06d5fedd9..000000000 --- a/docs/security/mechanics.mdx +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: "Mechanics" -description: "Quick explanation of how Infisical works." ---- - -## Signup - -During account signup, a user confirms their email address via OTP, generates a public-private key pair to be stored locally, generates a user salt, generates a 256-bit key, and enters their password. - -The 256-bit key is used to encrypt the private key; the 256-bit key itself is then encrypted by a key generated from the user's password and salt with key derivation function `argon2id`. The resulting, 256-bit key is known as the protected key. - -The encrypted private key, protected key, user identifier information, and SRP details are forwarded to the server. - -Once authenticated via SRP, a user is issued a JWT and refresh token. The JWT token is stored in browser memory and is appended to all future outbound requests requiring authentication. The refresh token is stored in an `HttpOnly` cookie and included in future requests to `/api/token` for JWT token renewal. This design side-steps potential XSS attacks on local storage. - - - Infisical authenticates users using the SRP protocol. With SRP, the server can - authenticate users without ever seeing their passwords. - - -## Invitation - -After signing up, a user can invite other users to their organization to partake in projects โ€” An invitation here consists of an email verification link sent to the invitee to confirm their identity if they've not previously signed up to Infisical. Both organization and project invites authorize invitees for resources but project invites differ in that they also involve sharing project keys by encrypting them under the invitees' public keys. - -## Pushing/Pulling Secrets - -To push secrets, a sender randomly-generates a symmetric encryption key, uses that key to encrypt their secret keys and values separately, asymmetrically encrypts the key with the receiversโ€™ public keys, and uploads the encrypted secrets and keys to the server. - -To pull secrets, a receiver obtains encrypted secret keys and values and their encrypted copy of the project key to decrypt the secrets from the server โ€” they asymmetrically decrypt the key using their private key and use the decrypted key to decrypt the secrets. This public-key mechanism prevents the server-side from reading any secrets. - -When dealing with individual secrets (e.g. pulling one secret by name) or creating new secrets, a user passes the name of the secret to the server which is then converted to a blind index by applying `argon2id` with the name of the secret and a 128-bit random salt unique to each project; the salt itself is encrypted by the server key and stored in the database. - - - Infisical ensures that the name of any secret is never stored in plaintext and - instead only a blind index generated from the name. It is infeasible to - reverse back a blind index to the name of a secret without knowledge of the - server key. - - -## Bot - -To use some features like integrations, users must opt out of E2EE (this means sharing access to secrets with Infisical). - -Infisical employs the concept of a bot which is a cryptographic abstraction for how Infisical interacts with secrets when users opt out of E2EE. In this model, each project is assigned a bot with its own public-private key pair where each bot's private key is stored symmetrically encrypted under the server key. When a user opts out of E2EE, they share the project key with the bot by encrypting a copy of it under the public key of the bot. - -When users wish to sync secrets from a project and environment Infisical to other platform integrations like Vercel or GitHub, Infisical decrypts the intended secrets and uses the integration platform's APIs to send secrets over. It should be noted that opting out of E2EE is optional and it is entirely possible to use Infisical to manage secrets across your team and infrastructure without opting out of E2EE. diff --git a/docs/security/overview.mdx b/docs/security/overview.mdx deleted file mode 100644 index bd0ed0878..000000000 --- a/docs/security/overview.mdx +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: "Overview" -description: "Infisical's security statement." ---- - -## Summary - -Infisical uses end-to-end encryption (E2EE) whenever possible to securely store and share secret values. It uses secure remote password (SRP) to handle authentication and public-key cryptography for secret sharing and syncing; secrets are symmetrically encrypted by keys decryptable only by members of the project. - -Infisical uses AES256-GCM for symmetric encryption and x25519-xsalsa20-poly1305 for asymmetric encryption operations mentioned in this brief; key generation and asymmetric algorithms are implemented with the [TweetNaCl.js](https://tweetnacl.js.org/#/) library which has been well-audited and recommended for use by cybersecurity firm Cure53. Lastly, the secure remote password (SRP) implementation uses [jsrp](https://github.com/alax/jsrp) package for user authentication. - -As part of our commitment to user privacy and security, we undergo penetration tests twice a year and are working to achieve SOC 2 (Type II) compliance in Fall 2023. - -## Scope - -Infisical's security model spans sensitive data stored on the server-side and in transit between user devices; it makes no security guarantees for malicious events that can occur beyond its control such as user-device security exploits or key-logging arising from poor cybersecurity management on the usersโ€™ behalf. - -## Lingo - -In subsequent sections, we refer: - -- To users uploading their secrets to Infisical as โ€œsendersโ€ and those receiving secrets as โ€œreceivers". For instance, if Bob and Alice are both enrolled in a project and Bob adds new secrets to the project to be pulled by Alice, then Bob is considered to be the sender and Alice the receiver. -- To any activity involving uploading or modifying secrets to Infisical as "pushing" and fetching secrets from Infisical as "pulling." - -## Statement - -As a secrets manager, we are deeply committed to enforcing the privacy and security of all users and data on the platform but acknowledge that it is virtually impossible to guarantee perfect security; unfortunately, even the most secure systems have vulnerabilities. - -As part of our commitment, we do our best to maintain platform privacy and security, notify users if anything goes wrong, and rectify adverse situations immediately if anything happens. -We are continuously adding more opt-in security measures to ensure better data protection and maintain trust within the growing community. With that, letโ€™s make the most simple and secure secrets management system out there! - -Best, - -Infisical Team diff --git a/docs/security/service-tokens.mdx b/docs/security/service-tokens.mdx deleted file mode 100644 index b70f527eb..000000000 --- a/docs/security/service-tokens.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: "Service Tokens" -description: "Understanding service tokens and their best practices" ---- - -## Background - -Many clients use service tokens to authenticate and read/write secrets from/to Infisical. - -Each service token consist of two parts used for authentication and decryption, separated by `.`. Consider the token `st.abc.def.ghi`. Here, `st.abc.def` can be used to authenticate with the API, by including it in the `Authorization` header under `Bearer st.abc.def`, and retrieve (encrypted) secrets as well as a project key back. Meanwhile, `ghi`, a hex-string, can be used to decrypt the project key used to decrypt the secrets. - -Note that when using service tokens via select client methods like SDK or CLI, cryptographic operations are abstracted for you that is the token is parsed and encryption/decryption operations are handled. If using service tokens with the REST API and end-to-end encryption enabled, then you will have to handle the encryption/decryption operations yourself. - -## Recommendations - -1. Issuance: When creating a new service token, itโ€™s important to consider the [principle of least privilege(PoLP)](https://en.wikipedia.org/wiki/Principle_of_least_privilege) when setting its scope and expiration date. For example, if the client using the token only requires access to a staging environment, then you should scope the token to that environment only; you can further scope tokens to path(s) within environment(s) if you happen to use [path-based secret storage](/documentation/platform/folder). Likewise, if the client does not intend to access secrets indefinitely, then you may consider setting a finite lifetime for the token such as 6 months or 1 year from now. Finally, you should consider carefully whether or not your client requires the ability to read and/or write secrets from/to Infisical. - -2. Network access: We recommend configuring the IP whitelist settings of each project to allow either single IP addresses or CIDR-notated range of addresses to read/write secrets to Infisical. With this feature, you can specify the IP range of your client servers to restrict access to your project in Infisical. - -3. Storage: Since service tokens grant access to your secrets, we recommend storing service tokens securely across your development cycle whether it be in a `.env` file in local development or as an environment variable of your deployment platform. - -4. Rotation: We recommend periodically rotating the service token, even in the absence of compromise. Since service tokens are capable of decrypting project keys used to decrypt secrets, all of which use AES-256-GCM encryption, they should be rotated before approximately 2^32 encryptions have been performed; this follows the guidance set forth by NIST publication 800-38D. Note that Infisical keeps track of the number of times that service tokens are used and will alert you when you have reached 90% of the recommended capacity. diff --git a/docs/self-hosting/authentication/google.mdx b/docs/self-hosting/authentication/google.mdx deleted file mode 100644 index dc3de4089..000000000 --- a/docs/self-hosting/authentication/google.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: "Google" -description: "Learn to configure Google signin/signup when self-hosting Infisical." ---- -To enable Google Auth for your Infisical project, you first need to set up a Google OAuth application. -Follow Google's Setting up OAuth 2.0 documentation [here](https://support.google.com/googleapi/answer/6158849). After the setup, copy the Client ID and Client secret. You will need them below. - -## Configure Redirect URI -Add the following URI to the Authorized redirect URIs section in the OAuth Client credentials page - `BASE_URL/api/v1/auth/callback/google`. Replace BASE_URL with the URL of your hosted Infisical instance. -![google redirect](../../images/authentication-google-redirect.png) - -## General Configuration -You wil need to configure the following [environment variables](https://infisical.com/docs/self-hosting/configuration/envars): - -- `CLIENT_ID_GOOGLE`: OAuth 2.0 Client ID obtained from the credentials page -- `CLIENT_SECRET_GOOGLE`: OAuth 2.0 Client Secret obtained from the credentials page -- `JWT_PROVIDER_AUTH_SECRET`: Secret Key for signing OAuth 2.0 access token -- `JWT_PROVIDER_AUTH_LIFETIME`: Lifetime for OAuth 2.0 access token \ No newline at end of file diff --git a/docs/self-hosting/configuration/email.mdx b/docs/self-hosting/configuration/email.mdx index 566265a2d..c26c20648 100644 --- a/docs/self-hosting/configuration/email.mdx +++ b/docs/self-hosting/configuration/email.mdx @@ -29,15 +29,15 @@ Below you will find details on how to configure common email providers: 1. Create an account on [Resend](https://resend.com). 2. Add a [Domain](https://resend.com/domains). -![adding resend domain](../../images/email-resend-create-domain.png) +![adding resend domain](../../images/self-hosting/configuration/email/email-resend-create-domain.png) 3. Create an [API Key](https://resend.com/api-keys). -![creating resend api key](../../images/email-resend-create-key.png) +![creating resend api key](../../images/self-hosting/configuration/email/email-resend-create-key.png) 4. Go to the [SMTP page](https://resend.com/settings/smtp) and copy the values. -![go to resend smtp settings](../../images/email-resend-smtp-settings.png) +![go to resend smtp settings](../../images/self-hosting/configuration/email/email-resend-smtp-settings.png) 5. With the API Key, you can now set your SMTP environment variables variables: @@ -61,9 +61,9 @@ SMTP_FROM_NAME=Infisical 2. Create a SendGrid API Key under Settings > [API Keys](https://app.sendgrid.com/settings/api_keys) 3. Set a name for your API Key, we recommend using "Infisical," and select the "Restricted Key" option. You will need to enable the "Mail Send" permission as shown below: -![creating sendgrid api key](../../images/email-sendgrid-create-key.png) +![creating sendgrid api key](../../images/self-hosting/configuration/email/email-sendgrid-create-key.png) -![setting sendgrid api key restriction](../../images/email-sendgrid-restrictions.png) +![setting sendgrid api key restriction](../../images/self-hosting/configuration/email/email-sendgrid-restrictions.png) 4. With the API Key, you can now set your SMTP environment variables: @@ -87,7 +87,7 @@ SMTP_FROM_NAME=Infisical 1. Create an account and configure [Mailgun](https://www.mailgun.com) to send emails. 2. Obtain your Mailgun credentials in Sending > Overview > SMTP -![obtain mailhog api key estriction](../../images/email-mailhog-credentials.png) +![obtain mailhog api key estriction](../../images/self-hosting/configuration/email/email-mailhog-credentials.png) 3. With your Mailgun credentials, you can now set up your SMTP environment variables: @@ -108,9 +108,9 @@ SMTP_FROM_NAME=Infisical 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) +![opening AWS SES console](../../images/self-hosting/configuration/email/email-aws-ses-console.png) -![creating AWS IAM SES user](../../images/email-aws-ses-user.png) +![creating AWS IAM SES user](../../images/self-hosting/configuration/email/email-aws-ses-user.png) 3. With your AWS SES SMTP credentials, you can now set up your SMTP environment variables: @@ -134,9 +134,9 @@ SMTP_FROM_NAME=Infisical 1. Create an account and configure [SocketLabs](https://www.socketlabs.com/) to send emails. 2. From the dashboard, navigate to SMTP Credentials > SMTP & APIs > SMTP Credentials to obtain your SocketLabs SMTP credentials. -![opening SocketLabs dashboard](../../images/email-socketlabs-dashboard.png) +![opening SocketLabs dashboard](../../images/self-hosting/configuration/email/email-socketlabs-dashboard.png) -![obtaining SocketLabs credentials](../../images/email-socketlabs-credentials.png) +![obtaining SocketLabs credentials](../../images/self-hosting/configuration/email/email-socketlabs-credentials.png) 3. With your SocketLabs SMTP credentials, you can now set up your SMTP environment variables: @@ -157,7 +157,7 @@ SMTP_FROM_NAME=Infisical email like `team@sandbox.socketlabs.dev`. -![SocketLabs domain management](../../images/email-socketlabs-domains.png) +![SocketLabs domain management](../../images/self-hosting/configuration/email/email-socketlabs-domains.png) Remember that you will need to restart Infisical for this to work properly. @@ -169,7 +169,7 @@ SMTP_FROM_NAME=Infisical Create an account and enable "less secure app access" in Gmail Account Settings > Security. This will allow applications like Infisical to authenticate with Gmail via your username and password. -![Gmail secure app access](../../images/email-gmail-app-access.png) +![Gmail secure app access](../../images/self-hosting/configuration/email/email-gmail-app-access.png) With your Gmail username and password, you can set your SMTP environment variables: diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index d03f57128..9b054e376 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -3,7 +3,7 @@ title: "All environment variables" description: "Configure your environment variables when self-hosting Infisical." --- -## Backend environment variables +## Environment variables Depending on your chosen self hosted deployment method, you may need to configured at least the required environment variable listed below. Other environment variables are listed below to increase the functionality of your self hosted instance based on your use case. @@ -11,43 +11,12 @@ Other environment variables are listed below to increase the functionality of yo - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` - + Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` + -{" "} - - - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex - 16` - - -{" "} - - - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex - 16` - - -{" "} - - - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex - 16` - - -{" "} - - - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex - 16` - - -{" "} - - - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex - 16` - + + Must be a random 32 byte base64 string. Can be generated with `openssl rand -base64 32` + *TLS based connection string is not yet supported @@ -140,9 +109,6 @@ Other environment variables are listed below to increase the functionality of yo To integrate with external auth providers, provide value for the related keys - - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` - OAuth2 client ID for Google login @@ -155,6 +121,15 @@ Other environment variables are listed below to increase the functionality of yo OAuth2 client secret for GitHub login + + OAuth2 client ID for GitLab login + + + OAuth2 client secret for GitLab login + + + URL of your self-hosted instance of GitLab where the OAuth application is registered + #### JWT @@ -223,12 +198,3 @@ Infisical uses Sentry to report error logs - -## Frontend environment variables - - diff --git a/docs/self-hosting/configuration/sso.mdx b/docs/self-hosting/configuration/sso.mdx new file mode 100644 index 000000000..2d663790d --- /dev/null +++ b/docs/self-hosting/configuration/sso.mdx @@ -0,0 +1,21 @@ +--- +title: "Configure SSO" +description: "How to configure SSO when self-hosting Infisical." +--- + + + Infisical offers Google SSO and GitHub SSO for free. + + Infisical also offers SAML SSO authentication but as paid features that can be unlocked via enterprise license; if this is of interest, please contact team@infisical.com. + On this front, we currently support Okta, Azure AD, and JumpCloud and are expanding support for other IdPs in the coming months; stay tuned and feel free to request a IdP at this + [issue](https://github.com/Infisical/infisical/issues/442). + + +You can view specific documentation for how to set up each SSO authentication method below: + +- [Google SSO](/documentation/platform/sso/google) +- [GitHub SSO](/documentation/platform/sso/github) +- [GitLab SSO](/documentation/platform/sso/gitlab) +- [Okta SAML](/documentation/platform/sso/okta) +- [Azure SAML](/documentation/platform/sso/azure) +- [JumpCloud SAML](/documentation/platform/sso/jumpcloud) \ No newline at end of file diff --git a/docs/self-hosting/deployment-options/fly.io.mdx b/docs/self-hosting/deployment-options/fly.io.mdx index 4f43c9f46..b8c561224 100644 --- a/docs/self-hosting/deployment-options/fly.io.mdx +++ b/docs/self-hosting/deployment-options/fly.io.mdx @@ -31,7 +31,7 @@ primary_region = "iad" MONGO_URL = <> [http_service] - internal_port = 80 + internal_port = 8080 ``` diff --git a/docs/self-hosting/deployment-options/kubernetes-helm.mdx b/docs/self-hosting/deployment-options/kubernetes-helm.mdx index 37383fa82..e0a4f36e6 100644 --- a/docs/self-hosting/deployment-options/kubernetes-helm.mdx +++ b/docs/self-hosting/deployment-options/kubernetes-helm.mdx @@ -23,40 +23,27 @@ helm repo update ## Add Helm values -Create a values.yaml file to configure various installation settings, such as the docker image tags and environment variables for both the frontend and backend. To explore all configurable properties for your values file, [visit this page](https://github.com/Infisical/infisical/tree/main/helm-charts/infisical). +Create a values.yaml file to configure various installation settings, such as the docker image tags and environment variables. To explore all configurable properties for your values file, [visit this page](https://github.com/Infisical/infisical/tree/main/helm-charts/infisical). #### Set image tags -By default, the application will use the latest tag to retrieve the required Docker images, which may be appropriate for most cases. -However, it's important to specify a particular version of Infisical during installation to prevent any significant updates from disrupting your deployment. -View [properties for frontend and backend](https://github.com/Infisical/infisical/tree/main/helm-charts/infisical#parameters). - +By default, the application will use the `latest` docker image tag. This is okay for test environments; however, for production deployments it is important to pin your deployment to a particular docker image tag to prevent receiving unintended changes. - To find the latest version number of Infisical, follow the links below - - [frontend Docker image](https://hub.docker.com/r/infisical/frontend/tags) - - [backend Docker image](https://hub.docker.com/r/infisical/backend/tags) + To find the latest version number of Infisical, click [here](https://hub.docker.com/r/infisical/infisical/tags) ```yaml simple-values-example.yaml -frontend: - name: frontend - replicaCount: 2 - image: - repository: infisical/frontend - tag: "v0.34.2" # <--- frontend version - pullPolicy: Always - backend: replicaCount: 2 image: - repository: infisical/backend - tag: "v0.34.2" # <--- backend version + repository: infisical/infisical + tag: "v0.39.5" pullPolicy: Always ``` #### Configure environment variables -You can configure environment variables for the frontend and backend in your Helm values file under the property `frontendEnvironmentVariables` and `backendEnvironmentVariables` respectively. View configurable [environment variables](../configuration/envars). +You can configure environment variables for your instance of Infisical though the Helm values file under the property `backendEnvironmentVariables`. View configurable [environment variables](../configuration/envars). Infisical requires the following backend environment variables to be defined: _`ENCRYPTION_KEY`_, _`JWT_SIGNUP_SECRET`_, _`JWT_REFRESH_SECRET`_, _`JWT_AUTH_SECRET`_, _`JWT_MFA_SECRET`_ and _`JWT_SERVICE_SECRET`_. @@ -87,38 +74,30 @@ Infisical uses Nginx to route external traffic. You can install Nginx along with ... ingress: nginx: - enabled: false #<-- if you would like to install nginx along with Infisical + enabled: true #<-- if you would like to install nginx along with Infisical ``` #### Database -Infisical uses a document database as its persistence layer. With this Helm chart, you spin up a MongoDB instance power by Bitnami along side other Infisical services in your cluster. +Infisical uses a MongoDB as its persistence layer. With this Helm chart, a MongoDB instance is automatically spun up for use with Infisical. When persistence is enabled, the data will be stored as Kubernetes Persistence Volume. View all [properties for mongodb](https://github.com/Infisical/infisical/tree/main/helm-charts/infisical). ```yaml simple-values-example.yaml mongodb: - enabled: false + enabled: true persistence: enabled: false ``` -To increase data redundancy, we recommend that you use a managed document database service such as AWS Document DB, MongoDB or similar services instead. +To achieve high availability and data redundancy, we recommend that you use a managed document database service such as AWS Document DB, MongoDB or similar services instead of the in cluster database. Managed database connection string can be set in the `backendEnvironmentVariables`. #### Example helm values ```yaml simple-values-example.yaml -frontend: - name: frontend - replicaCount: 2 - image: - repository: infisical/frontend - tag: "v0.34.2" # <--- frontend version - pullPolicy: Always - backend: replicaCount: 2 image: - repository: infisical/backend - tag: "v0.34.2" # <--- backend version + repository: infisical/infisical + tag: "v0.39.5" pullPolicy: Always backendEnvironmentVariables: @@ -126,7 +105,7 @@ backendEnvironmentVariables: ingress: nginx: - enabled: true #<-- if you would like to install nginx along with Infisical + enabled: true ``` @@ -136,22 +115,6 @@ ingress: nginx: enabled: true - frontend: - enabled: true - name: frontend - podAnnotations: {} - deploymentAnnotations: {} - replicaCount: 4 - image: - repository: infisical/frontend - tag: "v0.34.2" # <--- frontend version - pullPolicy: IfNotPresent - kubeSecretRef: null - service: - annotations: {} - type: ClusterIP - nodePort: "" - backend: enabled: true name: backend @@ -159,8 +122,8 @@ ingress: deploymentAnnotations: {} replicaCount: 4 image: - repository: infisical/backend - tag: "v0.34.2" # <--- backend version + repository: infisical/infisical + tag: "v0.39.5" pullPolicy: IfNotPresent kubeSecretRef: null service: @@ -176,26 +139,9 @@ ingress: ## Mongo DB persistence mongodb: - enabled: false - persistence: - enabled: false - - ingress: enabled: true - annotations: - cert-manager.io/cluster-issuer: "letsencrypt-prod" # <-- if you are setting up HTTPS - hostName: app.infisical.com ## <- Replace with your own domain - frontend: - path: / - pathType: Prefix - backend: - path: /api - pathType: Prefix - tls: # <-- if you are setting up HTTPS - - secretName: echo-tls - hosts: - - app.infisical.com - + persistence: + enabled: true ``` diff --git a/docs/self-hosting/deployment-options/standalone-infisical.mdx b/docs/self-hosting/deployment-options/standalone-infisical.mdx index 03f132d69..26abf6c93 100644 --- a/docs/self-hosting/deployment-options/standalone-infisical.mdx +++ b/docs/self-hosting/deployment-options/standalone-infisical.mdx @@ -3,11 +3,15 @@ title: "Docker" description: "Learn to install Infisical purely on docker" --- -The Infisical standalone version combines all the essential components of the application into a single container, making deployment and management more straightforward than using Kubernetes or Docker Compose. +The Infisical standalone version combines all the essential components into a single container, making deployment and management more straightforward than other methods. -Since all the components are bundled into one image, running this version of Infisical requires a minimum of **230MB of memory**. +## Prerequisites -This guide assumes you have basic knowledge of Docker and have it installed on your system. If you don't have Docker installed, please follow the official installation guide: https://docs.docker.com/get-docker/ +This guide assumes you have basic knowledge of Docker and have it installed on your system. If you don't have Docker installed, please follow the official installation guide [here](https://docs.docker.com/get-docker/). + +#### System requirements +To have a functional deployment, we recommended compute with **2GB of RAM** and **1 CPU**. +However, depending on your usage, you may need to further scale up system resources to meet demand. ## Pull the Infisical Docker image @@ -18,59 +22,39 @@ docker pull infisical/infisical:latest ``` ## Run with docker -The Infisical Docker image requires several required environment variables. -Add the required environment variables listed below to your docker run command. View [all configurable environment variables](../configuration/envars) - +To run Infisical, we'll need to configure the required configs listed below. +Other configs can be found [here](../configuration/envars) Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` - + Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` - - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` - - - - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` - - - - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` - - - - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` - - - - Redis connection string - - + A MongoDB connection string. Can use any MongoDB PaaS such as Mongo Atlas, AWS Document DB, etc. *TLS based connection string is not yet supported + + Redis connection string. Only required if you plan to use web integrations. + + Once you have added the required environment variables to your docker run command, execute it in your terminal. ```bash -docker run -p 80:80 \ +docker run -p 80:8080 \ -e ENCRYPTION_KEY=f40c9178624764ad85a6830b37ce239a \ --e JWT_SIGNUP_SECRET=38ea90fb7998b92176080f457d890392 \ --e JWT_REFRESH_SECRET=7764c7bbf3928ad501591a3e005eb364 \ --e JWT_AUTH_SECRET=5239fea3a4720c0e524f814a540e14a2 \ --e JWT_SERVICE_SECRET=8509fb8b90c9b53e9e61d1e35826dcb5 \ +-e AUTH_SECRET=5239fea3a4720c0e524f814a540e14a2 \ -e MONGO_URL="<>" \ --e REDIS_URL="<>" \ infisical/infisical:latest ``` - The sample environment variables listed above are only to be used as an example and should not be used in production + The above environment variable values are only to be used as an example and should not be used in production ## Verify the installation: diff --git a/docs/self-hosting/overview.mdx b/docs/self-hosting/overview.mdx index 382ae9567..8e72c5c01 100644 --- a/docs/self-hosting/overview.mdx +++ b/docs/self-hosting/overview.mdx @@ -8,11 +8,11 @@ Self-hosted Infisical allows you to maintain your sensitive information within y Choose from a variety of deployment options listed below to get started. - Use our Helm chart to Install Infisical on your Kubernetes cluster + Use the fully packaged docker image to deploy Infisical anywhere - Use the fully packaged, single docker image Infisical to deploy anywhere - + title="Kubernetes" + color="#ea5a0c" + href="deployment-options/kubernetes-helm" +> + Use our Helm chart to Install Infisical on your Kubernetes cluster + diff --git a/ecosystem.config.js b/ecosystem.config.js deleted file mode 100644 index 5c8cc6832..000000000 --- a/ecosystem.config.js +++ /dev/null @@ -1,32 +0,0 @@ -module.exports = { - apps: [ - { - name: 'frontend', - script: "./scripts/start.sh", - instances: 1, - cwd: "./app", - interpreter: 'sh', - exec_mode: "fork", - autorestart: true, - watch: false, - max_memory_restart: '500M', - }, - { - name: 'backend', - script: 'npm', - args: 'run start', - cwd: "./backend", - instances: 1, - exec_mode: "fork", - autorestart: true, - watch: false, - max_memory_restart: '500M', - }, - { - name: "nginx", - script: "nginx", - args: "-g 'daemon off;'", - exec_interpreter: "none", - }, - ], -}; \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 49cbd3024..2060c214a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,6 +1,7 @@ ARG POSTHOG_HOST=https://app.posthog.com ARG POSTHOG_API_KEY=posthog-api-key ARG INTERCOM_ID=intercom-id +ARG NEXT_INFISICAL_PLATFORM_VERSION=next-infisical-platform-version FROM node:16-alpine AS deps # Install dependencies only when needed. Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. @@ -13,7 +14,6 @@ COPY package.json package-lock.json next.config.js ./ # Install dependencies RUN npm ci --only-production --ignore-scripts - # Rebuild the source code only when needed FROM node:16-alpine AS builder WORKDIR /app @@ -52,6 +52,8 @@ ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \ ARG INTERCOM_ID ENV NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID \ BAKED_NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID +ARG NEXT_INFISICAL_PLATFORM_VERSION +ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION=$NEXT_INFISICAL_PLATFORM_VERSION COPY --chown=nextjs:nodejs --chmod=555 scripts ./scripts COPY --from=builder /app/public ./public diff --git a/frontend/next.config.js b/frontend/next.config.js index 3e9336f15..9d894694f 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,8 +1,4 @@ -// @ts-check -/** - * @type {import('next').NextConfig} - **/ const path = require("path"); const ContentSecurityPolicy = ` @@ -11,7 +7,7 @@ const ContentSecurityPolicy = ` style-src 'self' https://rsms.me 'unsafe-inline'; child-src https://api.stripe.com; frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/; - connect-src 'self' wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://localhost:*; + connect-src 'self' wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:*; img-src 'self' https://static.intercomassets.com https://js.intercomcdn.com https://downloads.intercomcdn.com https://*.stripe.com https://i.ytimg.com/ data:; media-src https://js.intercomcdn.com; font-src 'self' https://fonts.intercomcdn.com/ https://maxcdn.bootstrapcdn.com https://rsms.me https://fonts.gstatic.com; @@ -53,7 +49,9 @@ const securityHeaders = [ value: ContentSecurityPolicy.replace(/\s{2,}/g, " ").trim() } ]; - +/** + * @type {import('next').NextConfig} + **/ module.exports = { output: "standalone", i18n: { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e6847b42c..6a8026255 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -51,6 +51,7 @@ "cookies": "^0.8.0", "cva": "npm:class-variance-authority@^0.4.0", "date-fns": "^2.30.0", + "file-saver": "^2.0.5", "framer-motion": "^6.2.3", "fs": "^0.0.2", "gray-matter": "^4.0.3", @@ -72,7 +73,6 @@ "react": "^17.0.2", "react-beautiful-dnd": "^13.1.1", "react-code-input": "^3.10.1", - "react-contenteditable": "^3.3.7", "react-day-picker": "^8.8.0", "react-dom": "^17.0.2", "react-grid-layout": "^1.3.4", @@ -84,7 +84,7 @@ "react-table": "^7.8.0", "sanitize-html": "^2.11.0", "set-cookie-parser": "^2.5.1", - "sharp": "^0.32.0", + "sharp": "^0.32.6", "styled-components": "^5.3.7", "tailwind-merge": "^1.8.1", "tweetnacl": "^1.0.3", @@ -93,7 +93,8 @@ "uuidv4": "^6.2.13", "yaml": "^2.2.2", "yup": "^0.32.11", - "zod": "^3.22.0" + "zod": "^3.22.0", + "zustand": "^4.4.1" }, "devDependencies": { "@storybook/addon-essentials": "^7.0.23", @@ -106,6 +107,7 @@ "@storybook/react": "^7.0.23", "@storybook/testing-library": "^0.2.0", "@tailwindcss/typography": "^0.5.4", + "@types/file-saver": "^2.0.5", "@types/jsrp": "^0.2.4", "@types/node": "^18.11.9", "@types/picomatch": "^2.3.0", @@ -8209,6 +8211,12 @@ "@types/send": "*" } }, + "node_modules/@types/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==", + "dev": true + }, "node_modules/@types/find-cache-dir": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz", @@ -9679,6 +9687,11 @@ "deep-equal": "^2.0.5" } }, + "node_modules/b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" + }, "node_modules/babel-core": { "version": "7.0.0-bridge.0", "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz", @@ -11684,9 +11697,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", - "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", "engines": { "node": ">=8" } @@ -13340,7 +13353,8 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "node_modules/fast-diff": { "version": "1.3.0", @@ -13348,6 +13362,11 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, "node_modules/fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -13444,6 +13463,11 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/file-system-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-2.3.0.tgz", @@ -19375,6 +19399,11 @@ } ] }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -19594,18 +19623,6 @@ "react-dom": ">=16.8.0" } }, - "node_modules/react-contenteditable": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz", - "integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "prop-types": "^15.7.1" - }, - "peerDependencies": { - "react": ">=16.3" - } - }, "node_modules/react-day-picker": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.8.0.tgz", @@ -21026,18 +21043,18 @@ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, "node_modules/sharp": { - "version": "0.32.1", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.1.tgz", - "integrity": "sha512-kQTFtj7ldpUqSe8kDxoGLZc1rnMFU0AO2pqbX6pLy3b7Oj8ivJIdoKNwxHVQG2HN6XpHPJqCSM2nsma2gOXvOg==", + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", "hasInstallScript": true, "dependencies": { "color": "^4.2.3", - "detect-libc": "^2.0.1", + "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", - "semver": "^7.5.0", + "semver": "^7.5.4", "simple-get": "^4.0.1", - "tar-fs": "^2.1.1", + "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" }, "engines": { @@ -21059,9 +21076,9 @@ } }, "node_modules/sharp/node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -21072,6 +21089,26 @@ "node": ">=10" } }, + "node_modules/sharp/node_modules/tar-fs": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", + "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", + "dependencies": { + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + } + }, + "node_modules/sharp/node_modules/tar-stream": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", + "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/sharp/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -21503,6 +21540,15 @@ "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", "dev": true }, + "node_modules/streamx": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.1.tgz", + "integrity": "sha512-fQMzy2O/Q47rgwErk/eGeLu/roaFWV0jVsogDmrszM9uIw8L5OA+t+V93MgYlufNptfjmYR1tOMWhei/Eh7TQA==", + "dependencies": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, "node_modules/strict-uri-encode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", @@ -23652,6 +23698,33 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.1.tgz", + "integrity": "sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } }, "dependencies": { @@ -29239,6 +29312,12 @@ "@types/send": "*" } }, + "@types/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==", + "dev": true + }, "@types/find-cache-dir": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz", @@ -30442,6 +30521,11 @@ "deep-equal": "^2.0.5" } }, + "b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" + }, "babel-core": { "version": "7.0.0-bridge.0", "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz", @@ -31974,9 +32058,9 @@ "dev": true }, "detect-libc": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", - "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==" + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==" }, "detect-node-es": { "version": "1.1.0", @@ -33272,7 +33356,8 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "fast-diff": { "version": "1.3.0", @@ -33280,6 +33365,11 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, + "fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, "fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -33369,6 +33459,11 @@ "flat-cache": "^3.0.4" } }, + "file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "file-system-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-2.3.0.tgz", @@ -37635,6 +37730,11 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, + "queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" + }, "quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -37807,15 +37907,6 @@ "dev": true, "requires": {} }, - "react-contenteditable": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz", - "integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==", - "requires": { - "fast-deep-equal": "^3.1.3", - "prop-types": "^15.7.1" - } - }, "react-day-picker": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.8.0.tgz", @@ -38843,17 +38934,17 @@ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, "sharp": { - "version": "0.32.1", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.1.tgz", - "integrity": "sha512-kQTFtj7ldpUqSe8kDxoGLZc1rnMFU0AO2pqbX6pLy3b7Oj8ivJIdoKNwxHVQG2HN6XpHPJqCSM2nsma2gOXvOg==", + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", "requires": { "color": "^4.2.3", - "detect-libc": "^2.0.1", + "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", - "semver": "^7.5.0", + "semver": "^7.5.4", "simple-get": "^4.0.1", - "tar-fs": "^2.1.1", + "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" }, "dependencies": { @@ -38866,13 +38957,33 @@ } }, "semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "requires": { "lru-cache": "^6.0.0" } }, + "tar-fs": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", + "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", + "requires": { + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + } + }, + "tar-stream": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", + "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", + "requires": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -39209,6 +39320,15 @@ "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", "dev": true }, + "streamx": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.1.tgz", + "integrity": "sha512-fQMzy2O/Q47rgwErk/eGeLu/roaFWV0jVsogDmrszM9uIw8L5OA+t+V93MgYlufNptfjmYR1tOMWhei/Eh7TQA==", + "requires": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, "strict-uri-encode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", @@ -40788,6 +40908,14 @@ "version": "3.22.0", "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.0.tgz", "integrity": "sha512-y5KZY/ssf5n7hCGDGGtcJO/EBJEm5Pa+QQvFBeyMOtnFYOSflalxIFFvdaYevPhePcmcKC4aTbFkCcXN7D0O8Q==" + }, + "zustand": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.1.tgz", + "integrity": "sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==", + "requires": { + "use-sync-external-store": "1.2.0" + } } } } diff --git a/frontend/package.json b/frontend/package.json index d954da2a8..3fe6b617a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,6 +59,7 @@ "cookies": "^0.8.0", "cva": "npm:class-variance-authority@^0.4.0", "date-fns": "^2.30.0", + "file-saver": "^2.0.5", "framer-motion": "^6.2.3", "fs": "^0.0.2", "gray-matter": "^4.0.3", @@ -80,7 +81,6 @@ "react": "^17.0.2", "react-beautiful-dnd": "^13.1.1", "react-code-input": "^3.10.1", - "react-contenteditable": "^3.3.7", "react-day-picker": "^8.8.0", "react-dom": "^17.0.2", "react-grid-layout": "^1.3.4", @@ -92,7 +92,7 @@ "react-table": "^7.8.0", "sanitize-html": "^2.11.0", "set-cookie-parser": "^2.5.1", - "sharp": "^0.32.0", + "sharp": "^0.32.6", "styled-components": "^5.3.7", "tailwind-merge": "^1.8.1", "tweetnacl": "^1.0.3", @@ -101,7 +101,8 @@ "uuidv4": "^6.2.13", "yaml": "^2.2.2", "yup": "^0.32.11", - "zod": "^3.22.0" + "zod": "^3.22.0", + "zustand": "^4.4.1" }, "devDependencies": { "@storybook/addon-essentials": "^7.0.23", @@ -114,6 +115,7 @@ "@storybook/react": "^7.0.23", "@storybook/testing-library": "^0.2.0", "@tailwindcss/typography": "^0.5.4", + "@types/file-saver": "^2.0.5", "@types/jsrp": "^0.2.4", "@types/node": "^18.11.9", "@types/picomatch": "^2.3.0", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 7425cb994..9e1bfb99d 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -19,6 +19,7 @@ const integrationSlugNameMapping: Mapping = { "travisci": "TravisCI", "supabase": "Supabase", "checkly": "Checkly", + "qovery": "Qovery", "terraform-cloud": "Terraform Cloud", "teamcity": "TeamCity", "hashicorp-vault": "Vault", diff --git a/frontend/public/images/infisical-update-september-2023.png b/frontend/public/images/infisical-update-september-2023.png new file mode 100644 index 000000000..c11852762 Binary files /dev/null and b/frontend/public/images/infisical-update-september-2023.png differ diff --git a/frontend/public/images/integrations/Qovery.png b/frontend/public/images/integrations/Qovery.png new file mode 100644 index 000000000..17343046b Binary files /dev/null and b/frontend/public/images/integrations/Qovery.png differ diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index f989f2f3a..9900176e1 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -107,6 +107,11 @@ } } }, + "approval": { + "title": "Admin Panel", + "og-title": "Manage your secret change management", + "og-description": "Infisical a simple end-to-end encrypted platform that enables teams to sync and manage their .env files." + }, "integrations": { "title": "Project Integrations", "description": "Manage your integrations of Infisical with third-party services.", diff --git a/frontend/public/lotties/system-outline-189-domain-verification.json b/frontend/public/lotties/system-outline-189-domain-verification.json new file mode 100644 index 000000000..cb38c4784 --- /dev/null +++ b/frontend/public/lotties/system-outline-189-domain-verification.json @@ -0,0 +1 @@ +{"v":"5.8.1","fr":60,"ip":0,"op":91,"w":500,"h":500,"nm":"189-domain-verification-outline","ddd":0,"assets":[{"id":"comp_0","nm":"in-domain","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":40,"s":[180]}],"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[-0.237,-9.809],[9.809,-0.237],[0.237,9.809],[-9.809,0.237]],"o":[[0.237,9.809],[-9.809,0.237],[-0.237,-9.809],[9.809,-0.237]],"v":[[17.524,-176.429],[0.193,-158.24],[-17.996,-175.571],[-0.666,-193.76]],"c":true}]},{"t":40,"s":[{"i":[[-2.572,-106.399],[106.399,-2.572],[2.572,106.399],[-106.399,2.572]],"o":[[2.572,106.399],[-106.399,2.572],[-2.572,-106.399],[106.399,-2.572]],"v":[[192.652,-4.656],[4.656,192.652],[-192.652,4.656],[-4.656,-192.652]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":73.3,"ix":1},"e":{"a":0,"k":93.2,"ix":2},"o":{"a":0,"k":-6,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[-0.237,-9.809],[9.809,-0.237],[0.237,9.809],[-9.809,0.237]],"o":[[0.237,9.809],[-9.809,0.237],[-0.237,-9.809],[9.809,-0.237]],"v":[[17.524,-176.429],[0.193,-158.24],[-17.996,-175.571],[-0.666,-193.76]],"c":true}]},{"t":40,"s":[{"i":[[-2.572,-106.399],[106.399,-2.572],[2.572,106.399],[-106.399,2.572]],"o":[[2.572,106.399],[-106.399,2.572],[-2.572,-106.399],[106.399,-2.572]],"v":[[192.652,-4.656],[4.656,192.652],[-192.652,4.656],[-4.656,-192.652]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":0,"k":66.5,"ix":2},"o":{"a":0,"k":-6,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[180]},{"t":40,"s":[360]}],"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":41,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[250.004,250.003,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.51,0],[0.51,0.05],[0.5,0.1],[0.479,0.15],[0.47,0.2],[0.45,0.24],[0.42,0.28],[0.39,0.32],[0.36,0.36],[0.319,0.4],[0.279,0.42],[0.239,0.45],[0.19,0.47],[0.149,0.49],[0.1,0.5],[0.05,0.5],[0,0.51],[-0.06,0.51],[-0.101,0.5],[-0.15,0.49],[-0.2,0.47],[-0.24,0.45],[-0.28,0.42],[-0.32,0.39],[-0.359,0.36],[-0.4,0.32],[-0.42,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05],[-1.021,-0.11],[-0.5,-0.1],[-0.48,-0.15],[-0.46,-0.2],[-0.45,-0.24],[-0.43,-0.29],[-0.39,-0.32],[-0.359,-0.36],[-0.33,-0.4],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.149,-0.48],[-0.1,-0.5],[-0.051,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.141,-0.48],[0.189,-0.47],[0.23,-0.45],[0.28,-0.43],[0.32,-0.39],[0.37,-0.37],[0.4,-0.33],[0.421,-0.28],[0.45,-0.24],[0.48,-0.19],[0.49,-0.15],[0.5,-0.1],[0.51,-0.05]],"o":[[-0.51,0],[-0.51,-0.05],[-0.5,-0.1],[-0.49,-0.15],[-0.47,-0.19],[-0.45,-0.24],[-0.42,-0.28],[-0.4,-0.33],[-0.359,-0.37],[-0.32,-0.39],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.15,-0.48],[-0.101,-0.5],[-0.06,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.149,-0.48],[0.19,-0.47],[0.239,-0.45],[0.279,-0.43],[0.319,-0.4],[0.36,-0.36],[0.39,-0.32],[0.42,-0.29],[0.45,-0.24],[0.47,-0.2],[0.489,-0.15],[0.5,-0.1],[1.02,-0.11],[0.51,0.05],[0.5,0.1],[0.49,0.15],[0.48,0.19],[0.45,0.24],[0.421,0.28],[0.4,0.32],[0.37,0.36],[0.32,0.39],[0.28,0.42],[0.23,0.45],[0.189,0.47],[0.141,0.49],[0.1,0.5],[0.05,0.51],[0,0.51],[-0.051,0.5],[-0.1,0.5],[-0.149,0.49],[-0.2,0.48],[-0.24,0.45],[-0.28,0.42],[-0.33,0.4],[-0.359,0.36],[-0.39,0.32],[-0.43,0.28],[-0.45,0.24],[-0.46,0.2],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05]],"v":[[0,15.651],[-1.54,15.571],[-3.061,15.341],[-4.54,14.971],[-5.99,14.451],[-7.37,13.801],[-8.69,13.011],[-9.92,12.101],[-11.07,11.071],[-12.1,9.921],[-13.01,8.691],[-13.8,7.371],[-14.45,5.981],[-14.97,4.541],[-15.35,3.061],[-15.57,1.541],[-15.65,0.001],[-15.57,-1.539],[-15.35,-3.059],[-14.97,-4.549],[-14.45,-5.989],[-13.8,-7.368],[-13.01,-8.689],[-12.1,-9.919],[-11.07,-11.069],[-9.92,-12.099],[-8.69,-13.009],[-7.37,-13.799],[-5.99,-14.449],[-4.55,-14.969],[-3.061,-15.349],[-1.54,-15.569],[1.54,-15.569],[3.06,-15.349],[4.54,-14.969],[5.979,-14.449],[7.37,-13.799],[8.689,-13.009],[9.92,-12.099],[11.06,-11.069],[12.1,-9.919],[13.01,-8.689],[13.8,-7.368],[14.45,-5.989],[14.97,-4.549],[15.34,-3.059],[15.57,-1.539],[15.65,0.001],[15.57,1.541],[15.34,3.061],[14.97,4.541],[14.45,5.981],[13.8,7.371],[13.01,8.691],[12.1,9.921],[11.06,11.071],[9.92,12.101],[8.689,13.011],[7.37,13.801],[5.979,14.451],[4.54,14.971],[3.06,15.341],[1.54,15.571]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[296.88,125.002],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.51,0],[0.51,0.05],[0.5,0.1],[0.479,0.15],[0.47,0.19],[0.45,0.24],[0.431,0.28],[0.39,0.32],[0.36,0.36],[0.319,0.4],[0.279,0.42],[0.239,0.45],[0.19,0.47],[0.149,0.49],[0.109,0.49],[0.05,0.5],[0,0.51],[-0.06,0.51],[-0.101,0.5],[-0.15,0.48],[-0.2,0.47],[-0.24,0.45],[-0.28,0.43],[-0.32,0.39],[-0.359,0.37],[-0.4,0.32],[-0.42,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.11],[-0.51,0.05],[-1.021,-0.11],[-0.5,-0.1],[-0.48,-0.15],[-0.47,-0.2],[-0.45,-0.24],[-0.43,-0.28],[-0.39,-0.32],[-0.359,-0.36],[-0.33,-0.4],[-0.28,-0.42],[-0.24,-0.45],[-0.19,-0.47],[-0.149,-0.49],[-0.1,-0.5],[-0.051,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.15,-0.49],[0.189,-0.47],[0.24,-0.45],[0.28,-0.43],[0.32,-0.39],[0.37,-0.37],[0.4,-0.33],[0.421,-0.28],[0.45,-0.24],[0.471,-0.19],[0.49,-0.15],[0.5,-0.1],[0.5,-0.05]],"o":[[-0.51,0],[-0.51,-0.05],[-0.5,-0.1],[-0.49,-0.15],[-0.47,-0.19],[-0.45,-0.24],[-0.42,-0.28],[-0.4,-0.33],[-0.359,-0.37],[-0.32,-0.39],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.15,-0.49],[-0.101,-0.5],[-0.06,-0.51],[0,-0.51],[0.05,-0.51],[0.109,-0.5],[0.149,-0.49],[0.19,-0.47],[0.239,-0.45],[0.279,-0.42],[0.319,-0.4],[0.36,-0.36],[0.39,-0.32],[0.42,-0.28],[0.45,-0.24],[0.47,-0.2],[0.489,-0.15],[0.5,-0.1],[1.02,-0.11],[0.5,0.05],[0.5,0.11],[0.49,0.15],[0.471,0.19],[0.45,0.24],[0.421,0.28],[0.4,0.32],[0.37,0.37],[0.32,0.39],[0.28,0.43],[0.24,0.45],[0.189,0.47],[0.15,0.48],[0.1,0.5],[0.05,0.51],[0,0.51],[-0.051,0.5],[-0.1,0.49],[-0.149,0.49],[-0.19,0.47],[-0.24,0.45],[-0.28,0.42],[-0.33,0.4],[-0.359,0.36],[-0.39,0.32],[-0.43,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05]],"v":[[0,15.651],[-1.54,15.571],[-3.061,15.341],[-4.54,14.971],[-5.99,14.451],[-7.37,13.801],[-8.69,13.011],[-9.92,12.101],[-11.07,11.071],[-12.1,9.921],[-13.01,8.691],[-13.8,7.371],[-14.45,5.981],[-14.97,4.541],[-15.35,3.061],[-15.57,1.541],[-15.65,0.001],[-15.57,-1.539],[-15.35,-3.059],[-14.97,-4.539],[-14.45,-5.989],[-13.8,-7.368],[-13.01,-8.689],[-12.1,-9.919],[-11.07,-11.069],[-9.92,-12.099],[-8.69,-13.009],[-7.37,-13.799],[-5.99,-14.449],[-4.55,-14.969],[-3.061,-15.349],[-1.54,-15.569],[1.54,-15.569],[3.06,-15.349],[4.54,-14.969],[5.979,-14.449],[7.37,-13.799],[8.689,-13.009],[9.92,-12.099],[11.06,-11.069],[12.1,-9.919],[13.01,-8.689],[13.8,-7.368],[14.45,-5.989],[14.97,-4.539],[15.34,-3.059],[15.57,-1.539],[15.65,0.001],[15.57,1.541],[15.34,3.061],[14.97,4.541],[14.45,5.981],[13.8,7.371],[13.01,8.691],[12.1,9.921],[11.06,11.071],[9.92,12.101],[8.689,13.011],[7.37,13.801],[5.979,14.451],[4.54,14.971],[3.06,15.341],[1.54,15.571]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[359.38,125.002],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[8.644,0],[0,0],[0,8.643],[-8.644,0],[0,0],[0,-8.643]],"o":[[0,0],[-8.644,0],[0,-8.643],[0,0],[8.644,0],[0,8.643]],"v":[[171.876,15.65],[-171.876,15.65],[-187.526,0],[-171.876,-15.65],[171.876,-15.65],[187.526,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,171.878],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,171.878],"ix":2},"a":{"a":0,"k":[250.004,171.878],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[4.002,0],[3.056,3.051],[0,0],[-6.107,6.116],[-6.116,-6.108],[0,0],[0,0],[-6.107,-6.115],[6.115,-6.108],[0,0]],"o":[[-4.002,0],[0,0],[-6.115,-6.108],[6.108,-6.114],[0,0],[0,0],[6.115,-6.107],[6.108,6.115],[0,0],[-3.055,3.051]],"v":[[-23.984,61.442],[-35.043,56.866],[-78.768,13.195],[-78.782,-8.938],[-56.649,-8.951],[-23.984,23.673],[56.649,-56.865],[78.781,-56.852],[78.768,-34.72],[-12.924,56.865]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250.003,292.753],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-2.858],[0,0],[-2.858,0],[0,0],[0,2.858],[0,0],[2.858,0]],"o":[[-2.858,0],[0,0],[0,2.858],[0,0],[2.858,0],[0,0],[0,-2.858],[0,0]],"v":[[-151.042,-156.226],[-156.226,-151.043],[-156.226,151.042],[-151.042,156.226],[151.042,156.226],[156.226,151.042],[156.226,-151.043],[151.042,-156.226]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[20.117,0],[0,0],[0,20.117],[0,0],[-20.117,0],[0,0],[0,-20.117],[0,0]],"o":[[0,0],[-20.117,0],[0,0],[0,-20.117],[0,0],[20.117,0],[0,0],[0,20.117]],"v":[[151.042,187.526],[-151.042,187.526],[-187.526,151.042],[-187.526,-151.043],[-151.042,-187.526],[151.042,-187.526],[187.526,-151.043],[187.526,151.042]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,250.003],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,250.003],"ix":2},"a":{"a":0,"k":[250.004,250.003],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":90,"op":300,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","parent":6,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,42.749,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-81.604,66.643],[-34.5,-33],[34.5,-35.5]],"o":[[0,0],[0,0],[0,0],[90,-73.5],[46.014,44.014],[0,0]],"v":[[67.709,-45.792],[-23.984,45.792],[-67.709,2.122],[-118.504,-193.753],[116.997,-195.253],[67.997,-45.753]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[22.5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[28.5]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[28.5]},{"t":90,"s":[22.5]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[181]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[181]},{"t":90,"s":[360]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":40,"op":90,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[11.505,0],[0,0],[0,-11.506],[0,0],[-11.506,0],[0,0],[0,11.505],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.505],[0,0],[11.505,0],[0,0],[0,-11.506]],"v":[[151.042,-171.876],[-151.042,-171.876],[-171.876,-151.042],[-171.876,151.042],[-151.042,171.876],[151.042,171.876],[171.876,151.042],[171.876,-151.042]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[{"i":[[107.136,0],[0,0],[0,-106.591],[0,0],[-106.043,0],[0,0],[0,106.032],[0,0]],"o":[[0,0],[-106.591,0],[0,0],[0,106.031],[0,0],[106.032,0],[0,0],[0,-107.148]],"v":[[-1.004,-192.997],[-0.004,-192.997],[-192.997,-0.004],[-192.997,0.997],[-0.997,192.997],[0.996,192.997],[192.997,0.996],[192.997,1.004]],"c":true}]},{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0},"t":60,"s":[{"i":[[107.136,0],[0,0],[0,-106.591],[0,0],[-106.043,0],[0,0],[0,106.032],[0,0]],"o":[[0,0],[-106.591,0],[0,0],[0,106.031],[0,0],[106.032,0],[0,0],[0,-107.148]],"v":[[-1.004,-192.997],[-0.004,-192.997],[-192.997,-0.004],[-192.997,0.997],[-0.997,192.997],[0.996,192.997],[192.997,0.996],[192.997,1.004]],"c":true}]},{"t":90,"s":[{"i":[[11.505,0],[0,0],[0,-11.506],[0,0],[-11.506,0],[0,0],[0,11.505],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.505],[0,0],[11.505,0],[0,0],[0,-11.506]],"v":[[151.042,-171.876],[-151.042,-171.876],[-171.876,-151.042],[-171.876,151.042],[-151.042,171.876],[151.042,171.876],[171.876,151.042],[171.876,-151.042]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":40,"op":90,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":".primary.design","cl":"primary design","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[250.004,148.439,0],"to":[0,-32.333,0],"ti":[0,32.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":15,"s":[250.004,-45.561,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.333,"y":0},"t":60,"s":[250.004,-45.561,0],"to":[0,32.333,0],"ti":[0,-32.333,0]},{"t":90,"s":[250.004,148.439,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250.004,148.439,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.51,0],[0.51,0.05],[0.5,0.1],[0.479,0.15],[0.47,0.2],[0.45,0.24],[0.42,0.28],[0.39,0.32],[0.36,0.36],[0.319,0.4],[0.279,0.42],[0.239,0.45],[0.19,0.47],[0.149,0.49],[0.1,0.5],[0.05,0.5],[0,0.51],[-0.06,0.51],[-0.101,0.5],[-0.15,0.49],[-0.2,0.47],[-0.24,0.45],[-0.28,0.42],[-0.32,0.39],[-0.359,0.36],[-0.4,0.32],[-0.42,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05],[-1.021,-0.11],[-0.5,-0.1],[-0.48,-0.15],[-0.46,-0.2],[-0.45,-0.24],[-0.43,-0.29],[-0.39,-0.32],[-0.359,-0.36],[-0.33,-0.4],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.149,-0.48],[-0.1,-0.5],[-0.051,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.141,-0.48],[0.189,-0.47],[0.23,-0.45],[0.28,-0.43],[0.32,-0.39],[0.37,-0.37],[0.4,-0.33],[0.421,-0.28],[0.45,-0.24],[0.48,-0.19],[0.49,-0.15],[0.5,-0.1],[0.51,-0.05]],"o":[[-0.51,0],[-0.51,-0.05],[-0.5,-0.1],[-0.49,-0.15],[-0.47,-0.19],[-0.45,-0.24],[-0.42,-0.28],[-0.4,-0.33],[-0.359,-0.37],[-0.32,-0.39],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.15,-0.48],[-0.101,-0.5],[-0.06,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.149,-0.48],[0.19,-0.47],[0.239,-0.45],[0.279,-0.43],[0.319,-0.4],[0.36,-0.36],[0.39,-0.32],[0.42,-0.29],[0.45,-0.24],[0.47,-0.2],[0.489,-0.15],[0.5,-0.1],[1.02,-0.11],[0.51,0.05],[0.5,0.1],[0.49,0.15],[0.48,0.19],[0.45,0.24],[0.421,0.28],[0.4,0.32],[0.37,0.36],[0.32,0.39],[0.28,0.42],[0.23,0.45],[0.189,0.47],[0.141,0.49],[0.1,0.5],[0.05,0.51],[0,0.51],[-0.051,0.5],[-0.1,0.5],[-0.149,0.49],[-0.2,0.48],[-0.24,0.45],[-0.28,0.42],[-0.33,0.4],[-0.359,0.36],[-0.39,0.32],[-0.43,0.28],[-0.45,0.24],[-0.46,0.2],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05]],"v":[[0,15.651],[-1.54,15.571],[-3.061,15.341],[-4.54,14.971],[-5.99,14.451],[-7.37,13.801],[-8.69,13.011],[-9.92,12.101],[-11.07,11.071],[-12.1,9.921],[-13.01,8.691],[-13.8,7.371],[-14.45,5.981],[-14.97,4.541],[-15.35,3.061],[-15.57,1.541],[-15.65,0.001],[-15.57,-1.539],[-15.35,-3.059],[-14.97,-4.549],[-14.45,-5.989],[-13.8,-7.368],[-13.01,-8.689],[-12.1,-9.919],[-11.07,-11.069],[-9.92,-12.099],[-8.69,-13.009],[-7.37,-13.799],[-5.99,-14.449],[-4.55,-14.969],[-3.061,-15.349],[-1.54,-15.569],[1.54,-15.569],[3.06,-15.349],[4.54,-14.969],[5.979,-14.449],[7.37,-13.799],[8.689,-13.009],[9.92,-12.099],[11.06,-11.069],[12.1,-9.919],[13.01,-8.689],[13.8,-7.368],[14.45,-5.989],[14.97,-4.549],[15.34,-3.059],[15.57,-1.539],[15.65,0.001],[15.57,1.541],[15.34,3.061],[14.97,4.541],[14.45,5.981],[13.8,7.371],[13.01,8.691],[12.1,9.921],[11.06,11.071],[9.92,12.101],[8.689,13.011],[7.37,13.801],[5.979,14.451],[4.54,14.971],[3.06,15.341],[1.54,15.571]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[296.88,125.002],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.51,0],[0.51,0.05],[0.5,0.1],[0.479,0.15],[0.47,0.19],[0.45,0.24],[0.431,0.28],[0.39,0.32],[0.36,0.36],[0.319,0.4],[0.279,0.42],[0.239,0.45],[0.19,0.47],[0.149,0.49],[0.109,0.49],[0.05,0.5],[0,0.51],[-0.06,0.51],[-0.101,0.5],[-0.15,0.48],[-0.2,0.47],[-0.24,0.45],[-0.28,0.43],[-0.32,0.39],[-0.359,0.37],[-0.4,0.32],[-0.42,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.11],[-0.51,0.05],[-1.021,-0.11],[-0.5,-0.1],[-0.48,-0.15],[-0.47,-0.2],[-0.45,-0.24],[-0.43,-0.28],[-0.39,-0.32],[-0.359,-0.36],[-0.33,-0.4],[-0.28,-0.42],[-0.24,-0.45],[-0.19,-0.47],[-0.149,-0.49],[-0.1,-0.5],[-0.051,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.15,-0.49],[0.189,-0.47],[0.24,-0.45],[0.28,-0.43],[0.32,-0.39],[0.37,-0.37],[0.4,-0.33],[0.421,-0.28],[0.45,-0.24],[0.471,-0.19],[0.49,-0.15],[0.5,-0.1],[0.5,-0.05]],"o":[[-0.51,0],[-0.51,-0.05],[-0.5,-0.1],[-0.49,-0.15],[-0.47,-0.19],[-0.45,-0.24],[-0.42,-0.28],[-0.4,-0.33],[-0.359,-0.37],[-0.32,-0.39],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.15,-0.49],[-0.101,-0.5],[-0.06,-0.51],[0,-0.51],[0.05,-0.51],[0.109,-0.5],[0.149,-0.49],[0.19,-0.47],[0.239,-0.45],[0.279,-0.42],[0.319,-0.4],[0.36,-0.36],[0.39,-0.32],[0.42,-0.28],[0.45,-0.24],[0.47,-0.2],[0.489,-0.15],[0.5,-0.1],[1.02,-0.11],[0.5,0.05],[0.5,0.11],[0.49,0.15],[0.471,0.19],[0.45,0.24],[0.421,0.28],[0.4,0.32],[0.37,0.37],[0.32,0.39],[0.28,0.43],[0.24,0.45],[0.189,0.47],[0.15,0.48],[0.1,0.5],[0.05,0.51],[0,0.51],[-0.051,0.5],[-0.1,0.49],[-0.149,0.49],[-0.19,0.47],[-0.24,0.45],[-0.28,0.42],[-0.33,0.4],[-0.359,0.36],[-0.39,0.32],[-0.43,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05]],"v":[[0,15.651],[-1.54,15.571],[-3.061,15.341],[-4.54,14.971],[-5.99,14.451],[-7.37,13.801],[-8.69,13.011],[-9.92,12.101],[-11.07,11.071],[-12.1,9.921],[-13.01,8.691],[-13.8,7.371],[-14.45,5.981],[-14.97,4.541],[-15.35,3.061],[-15.57,1.541],[-15.65,0.001],[-15.57,-1.539],[-15.35,-3.059],[-14.97,-4.539],[-14.45,-5.989],[-13.8,-7.368],[-13.01,-8.689],[-12.1,-9.919],[-11.07,-11.069],[-9.92,-12.099],[-8.69,-13.009],[-7.37,-13.799],[-5.99,-14.449],[-4.55,-14.969],[-3.061,-15.349],[-1.54,-15.569],[1.54,-15.569],[3.06,-15.349],[4.54,-14.969],[5.979,-14.449],[7.37,-13.799],[8.689,-13.009],[9.92,-12.099],[11.06,-11.069],[12.1,-9.919],[13.01,-8.689],[13.8,-7.368],[14.45,-5.989],[14.97,-4.539],[15.34,-3.059],[15.57,-1.539],[15.65,0.001],[15.57,1.541],[15.34,3.061],[14.97,4.541],[14.45,5.981],[13.8,7.371],[13.01,8.691],[12.1,9.921],[11.06,11.071],[9.92,12.101],[8.689,13.011],[7.37,13.801],[5.979,14.451],[4.54,14.971],[3.06,15.341],[1.54,15.571]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[359.38,125.002],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[8.644,0],[0,0],[0,8.643],[-8.644,0],[0,0],[0,-8.643]],"o":[[0,0],[-8.644,0],[0,-8.643],[0,0],[8.644,0],[0,8.643]],"v":[[171.876,15.65],[-171.876,15.65],[-187.526,0],[-171.876,-15.65],[171.876,-15.65],[187.526,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,171.878],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,171.878],"ix":2},"a":{"a":0,"k":[250.004,171.878],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":40,"op":90,"st":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"t":60,"s":[360]}],"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[11.505,0],[0,0],[0,-11.506],[0,0],[-11.506,0],[0,0],[0,11.505],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.505],[0,0],[11.505,0],[0,0],[0,-11.506]],"v":[[151.042,-171.876],[-151.042,-171.876],[-171.876,-151.042],[-171.876,151.042],[-151.042,171.876],[151.042,171.876],[171.876,151.042],[171.876,-151.042]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":15,"s":[{"i":[[107.136,0],[0,0],[0,-106.591],[0,0],[-106.043,0],[0,0],[0,106.032],[0,0]],"o":[[0,0],[-106.591,0],[0,0],[0,106.031],[0,0],[106.032,0],[0,0],[0,-107.148]],"v":[[-1.004,-192.997],[-0.004,-192.997],[-192.997,-0.004],[-192.997,0.997],[-0.997,192.997],[0.996,192.997],[192.997,0.996],[192.997,1.004]],"c":true}]},{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0.167},"t":60,"s":[{"i":[[107.136,0],[0,0],[0,-106.591],[0,0],[-106.043,0],[0,0],[0,106.032],[0,0]],"o":[[0,0],[-106.591,0],[0,0],[0,106.031],[0,0],[106.032,0],[0,0],[0,-107.148]],"v":[[-1.004,-192.997],[-0.004,-192.997],[-192.997,-0.004],[-192.997,0.997],[-0.997,192.997],[0.996,192.997],[192.997,0.996],[192.997,1.004]],"c":true}]},{"t":90,"s":[{"i":[[11.505,0],[0,0],[0,-11.506],[0,0],[-11.506,0],[0,0],[0,11.505],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.505],[0,0],[11.505,0],[0,0],[0,-11.506]],"v":[[151.042,-171.876],[-151.042,-171.876],[-171.876,-151.042],[-171.876,151.042],[-151.042,171.876],[151.042,171.876],[171.876,151.042],[171.876,-151.042]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":7,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9,"s":[7]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":14,"s":[0]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.299],"y":[0]},"t":60,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[0]},{"t":72,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":7,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":14,"s":[66.5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0]},"t":60,"s":[66.5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[89.5]},{"t":72,"s":[100]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[60]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[16]},{"t":72,"s":[0]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":40,"op":90,"st":0,"bm":0}]},{"id":"comp_1","nm":"hover-domain","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[250.004,250.003,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.51,0],[0.51,0.05],[0.5,0.1],[0.479,0.15],[0.47,0.2],[0.45,0.24],[0.42,0.28],[0.39,0.32],[0.36,0.36],[0.319,0.4],[0.279,0.42],[0.239,0.45],[0.19,0.47],[0.149,0.49],[0.1,0.5],[0.05,0.5],[0,0.51],[-0.06,0.51],[-0.101,0.5],[-0.15,0.49],[-0.2,0.47],[-0.24,0.45],[-0.28,0.42],[-0.32,0.39],[-0.359,0.36],[-0.4,0.32],[-0.42,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05],[-1.021,-0.11],[-0.5,-0.1],[-0.48,-0.15],[-0.46,-0.2],[-0.45,-0.24],[-0.43,-0.29],[-0.39,-0.32],[-0.359,-0.36],[-0.33,-0.4],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.149,-0.48],[-0.1,-0.5],[-0.051,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.141,-0.48],[0.189,-0.47],[0.23,-0.45],[0.28,-0.43],[0.32,-0.39],[0.37,-0.37],[0.4,-0.33],[0.421,-0.28],[0.45,-0.24],[0.48,-0.19],[0.49,-0.15],[0.5,-0.1],[0.51,-0.05]],"o":[[-0.51,0],[-0.51,-0.05],[-0.5,-0.1],[-0.49,-0.15],[-0.47,-0.19],[-0.45,-0.24],[-0.42,-0.28],[-0.4,-0.33],[-0.359,-0.37],[-0.32,-0.39],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.15,-0.48],[-0.101,-0.5],[-0.06,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.149,-0.48],[0.19,-0.47],[0.239,-0.45],[0.279,-0.43],[0.319,-0.4],[0.36,-0.36],[0.39,-0.32],[0.42,-0.29],[0.45,-0.24],[0.47,-0.2],[0.489,-0.15],[0.5,-0.1],[1.02,-0.11],[0.51,0.05],[0.5,0.1],[0.49,0.15],[0.48,0.19],[0.45,0.24],[0.421,0.28],[0.4,0.32],[0.37,0.36],[0.32,0.39],[0.28,0.42],[0.23,0.45],[0.189,0.47],[0.141,0.49],[0.1,0.5],[0.05,0.51],[0,0.51],[-0.051,0.5],[-0.1,0.5],[-0.149,0.49],[-0.2,0.48],[-0.24,0.45],[-0.28,0.42],[-0.33,0.4],[-0.359,0.36],[-0.39,0.32],[-0.43,0.28],[-0.45,0.24],[-0.46,0.2],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05]],"v":[[0,15.651],[-1.54,15.571],[-3.061,15.341],[-4.54,14.971],[-5.99,14.451],[-7.37,13.801],[-8.69,13.011],[-9.92,12.101],[-11.07,11.071],[-12.1,9.921],[-13.01,8.691],[-13.8,7.371],[-14.45,5.981],[-14.97,4.541],[-15.35,3.061],[-15.57,1.541],[-15.65,0.001],[-15.57,-1.539],[-15.35,-3.059],[-14.97,-4.549],[-14.45,-5.989],[-13.8,-7.368],[-13.01,-8.689],[-12.1,-9.919],[-11.07,-11.069],[-9.92,-12.099],[-8.69,-13.009],[-7.37,-13.799],[-5.99,-14.449],[-4.55,-14.969],[-3.061,-15.349],[-1.54,-15.569],[1.54,-15.569],[3.06,-15.349],[4.54,-14.969],[5.979,-14.449],[7.37,-13.799],[8.689,-13.009],[9.92,-12.099],[11.06,-11.069],[12.1,-9.919],[13.01,-8.689],[13.8,-7.368],[14.45,-5.989],[14.97,-4.549],[15.34,-3.059],[15.57,-1.539],[15.65,0.001],[15.57,1.541],[15.34,3.061],[14.97,4.541],[14.45,5.981],[13.8,7.371],[13.01,8.691],[12.1,9.921],[11.06,11.071],[9.92,12.101],[8.689,13.011],[7.37,13.801],[5.979,14.451],[4.54,14.971],[3.06,15.341],[1.54,15.571]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[296.88,125.002],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.51,0],[0.51,0.05],[0.5,0.1],[0.479,0.15],[0.47,0.19],[0.45,0.24],[0.431,0.28],[0.39,0.32],[0.36,0.36],[0.319,0.4],[0.279,0.42],[0.239,0.45],[0.19,0.47],[0.149,0.49],[0.109,0.49],[0.05,0.5],[0,0.51],[-0.06,0.51],[-0.101,0.5],[-0.15,0.48],[-0.2,0.47],[-0.24,0.45],[-0.28,0.43],[-0.32,0.39],[-0.359,0.37],[-0.4,0.32],[-0.42,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.11],[-0.51,0.05],[-1.021,-0.11],[-0.5,-0.1],[-0.48,-0.15],[-0.47,-0.2],[-0.45,-0.24],[-0.43,-0.28],[-0.39,-0.32],[-0.359,-0.36],[-0.33,-0.4],[-0.28,-0.42],[-0.24,-0.45],[-0.19,-0.47],[-0.149,-0.49],[-0.1,-0.5],[-0.051,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.15,-0.49],[0.189,-0.47],[0.24,-0.45],[0.28,-0.43],[0.32,-0.39],[0.37,-0.37],[0.4,-0.33],[0.421,-0.28],[0.45,-0.24],[0.471,-0.19],[0.49,-0.15],[0.5,-0.1],[0.5,-0.05]],"o":[[-0.51,0],[-0.51,-0.05],[-0.5,-0.1],[-0.49,-0.15],[-0.47,-0.19],[-0.45,-0.24],[-0.42,-0.28],[-0.4,-0.33],[-0.359,-0.37],[-0.32,-0.39],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.15,-0.49],[-0.101,-0.5],[-0.06,-0.51],[0,-0.51],[0.05,-0.51],[0.109,-0.5],[0.149,-0.49],[0.19,-0.47],[0.239,-0.45],[0.279,-0.42],[0.319,-0.4],[0.36,-0.36],[0.39,-0.32],[0.42,-0.28],[0.45,-0.24],[0.47,-0.2],[0.489,-0.15],[0.5,-0.1],[1.02,-0.11],[0.5,0.05],[0.5,0.11],[0.49,0.15],[0.471,0.19],[0.45,0.24],[0.421,0.28],[0.4,0.32],[0.37,0.37],[0.32,0.39],[0.28,0.43],[0.24,0.45],[0.189,0.47],[0.15,0.48],[0.1,0.5],[0.05,0.51],[0,0.51],[-0.051,0.5],[-0.1,0.49],[-0.149,0.49],[-0.19,0.47],[-0.24,0.45],[-0.28,0.42],[-0.33,0.4],[-0.359,0.36],[-0.39,0.32],[-0.43,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05]],"v":[[0,15.651],[-1.54,15.571],[-3.061,15.341],[-4.54,14.971],[-5.99,14.451],[-7.37,13.801],[-8.69,13.011],[-9.92,12.101],[-11.07,11.071],[-12.1,9.921],[-13.01,8.691],[-13.8,7.371],[-14.45,5.981],[-14.97,4.541],[-15.35,3.061],[-15.57,1.541],[-15.65,0.001],[-15.57,-1.539],[-15.35,-3.059],[-14.97,-4.539],[-14.45,-5.989],[-13.8,-7.368],[-13.01,-8.689],[-12.1,-9.919],[-11.07,-11.069],[-9.92,-12.099],[-8.69,-13.009],[-7.37,-13.799],[-5.99,-14.449],[-4.55,-14.969],[-3.061,-15.349],[-1.54,-15.569],[1.54,-15.569],[3.06,-15.349],[4.54,-14.969],[5.979,-14.449],[7.37,-13.799],[8.689,-13.009],[9.92,-12.099],[11.06,-11.069],[12.1,-9.919],[13.01,-8.689],[13.8,-7.368],[14.45,-5.989],[14.97,-4.539],[15.34,-3.059],[15.57,-1.539],[15.65,0.001],[15.57,1.541],[15.34,3.061],[14.97,4.541],[14.45,5.981],[13.8,7.371],[13.01,8.691],[12.1,9.921],[11.06,11.071],[9.92,12.101],[8.689,13.011],[7.37,13.801],[5.979,14.451],[4.54,14.971],[3.06,15.341],[1.54,15.571]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[359.38,125.002],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[8.644,0],[0,0],[0,8.643],[-8.644,0],[0,0],[0,-8.643]],"o":[[0,0],[-8.644,0],[0,-8.643],[0,0],[8.644,0],[0,8.643]],"v":[[171.876,15.65],[-171.876,15.65],[-187.526,0],[-171.876,-15.65],[171.876,-15.65],[187.526,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,171.878],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,171.878],"ix":2},"a":{"a":0,"k":[250.004,171.878],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[4.002,0],[3.056,3.051],[0,0],[-6.107,6.116],[-6.116,-6.108],[0,0],[0,0],[-6.107,-6.115],[6.115,-6.108],[0,0]],"o":[[-4.002,0],[0,0],[-6.115,-6.108],[6.108,-6.114],[0,0],[0,0],[6.115,-6.107],[6.108,6.115],[0,0],[-3.055,3.051]],"v":[[-23.984,61.442],[-35.043,56.866],[-78.768,13.195],[-78.782,-8.938],[-56.649,-8.951],[-23.984,23.673],[56.649,-56.865],[78.781,-56.852],[78.768,-34.72],[-12.924,56.865]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250.003,292.753],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-2.858],[0,0],[-2.858,0],[0,0],[0,2.858],[0,0],[2.858,0]],"o":[[-2.858,0],[0,0],[0,2.858],[0,0],[2.858,0],[0,0],[0,-2.858],[0,0]],"v":[[-151.042,-156.226],[-156.226,-151.043],[-156.226,151.042],[-151.042,156.226],[151.042,156.226],[156.226,151.042],[156.226,-151.043],[151.042,-156.226]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[20.117,0],[0,0],[0,20.117],[0,0],[-20.117,0],[0,0],[0,-20.117],[0,0]],"o":[[0,0],[-20.117,0],[0,0],[0,-20.117],[0,0],[20.117,0],[0,0],[0,20.117]],"v":[[151.042,187.526],[-151.042,187.526],[-187.526,151.042],[-187.526,-151.043],[-151.042,-187.526],[151.042,-187.526],[187.526,-151.043],[187.526,151.042]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,250.003],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,250.003],"ix":2},"a":{"a":0,"k":[250.004,250.003],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":90,"op":300,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[250.004,250.003,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.51,0],[0.51,0.05],[0.5,0.1],[0.479,0.15],[0.47,0.2],[0.45,0.24],[0.42,0.28],[0.39,0.32],[0.36,0.36],[0.319,0.4],[0.279,0.42],[0.239,0.45],[0.19,0.47],[0.149,0.49],[0.1,0.5],[0.05,0.5],[0,0.51],[-0.06,0.51],[-0.101,0.5],[-0.15,0.49],[-0.2,0.47],[-0.24,0.45],[-0.28,0.42],[-0.32,0.39],[-0.359,0.36],[-0.4,0.32],[-0.42,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05],[-1.021,-0.11],[-0.5,-0.1],[-0.48,-0.15],[-0.46,-0.2],[-0.45,-0.24],[-0.43,-0.29],[-0.39,-0.32],[-0.359,-0.36],[-0.33,-0.4],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.149,-0.48],[-0.1,-0.5],[-0.051,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.141,-0.48],[0.189,-0.47],[0.23,-0.45],[0.28,-0.43],[0.32,-0.39],[0.37,-0.37],[0.4,-0.33],[0.421,-0.28],[0.45,-0.24],[0.48,-0.19],[0.49,-0.15],[0.5,-0.1],[0.51,-0.05]],"o":[[-0.51,0],[-0.51,-0.05],[-0.5,-0.1],[-0.49,-0.15],[-0.47,-0.19],[-0.45,-0.24],[-0.42,-0.28],[-0.4,-0.33],[-0.359,-0.37],[-0.32,-0.39],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.15,-0.48],[-0.101,-0.5],[-0.06,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.149,-0.48],[0.19,-0.47],[0.239,-0.45],[0.279,-0.43],[0.319,-0.4],[0.36,-0.36],[0.39,-0.32],[0.42,-0.29],[0.45,-0.24],[0.47,-0.2],[0.489,-0.15],[0.5,-0.1],[1.02,-0.11],[0.51,0.05],[0.5,0.1],[0.49,0.15],[0.48,0.19],[0.45,0.24],[0.421,0.28],[0.4,0.32],[0.37,0.36],[0.32,0.39],[0.28,0.42],[0.23,0.45],[0.189,0.47],[0.141,0.49],[0.1,0.5],[0.05,0.51],[0,0.51],[-0.051,0.5],[-0.1,0.5],[-0.149,0.49],[-0.2,0.48],[-0.24,0.45],[-0.28,0.42],[-0.33,0.4],[-0.359,0.36],[-0.39,0.32],[-0.43,0.28],[-0.45,0.24],[-0.46,0.2],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05]],"v":[[0,15.651],[-1.54,15.571],[-3.061,15.341],[-4.54,14.971],[-5.99,14.451],[-7.37,13.801],[-8.69,13.011],[-9.92,12.101],[-11.07,11.071],[-12.1,9.921],[-13.01,8.691],[-13.8,7.371],[-14.45,5.981],[-14.97,4.541],[-15.35,3.061],[-15.57,1.541],[-15.65,0.001],[-15.57,-1.539],[-15.35,-3.059],[-14.97,-4.549],[-14.45,-5.989],[-13.8,-7.368],[-13.01,-8.689],[-12.1,-9.919],[-11.07,-11.069],[-9.92,-12.099],[-8.69,-13.009],[-7.37,-13.799],[-5.99,-14.449],[-4.55,-14.969],[-3.061,-15.349],[-1.54,-15.569],[1.54,-15.569],[3.06,-15.349],[4.54,-14.969],[5.979,-14.449],[7.37,-13.799],[8.689,-13.009],[9.92,-12.099],[11.06,-11.069],[12.1,-9.919],[13.01,-8.689],[13.8,-7.368],[14.45,-5.989],[14.97,-4.549],[15.34,-3.059],[15.57,-1.539],[15.65,0.001],[15.57,1.541],[15.34,3.061],[14.97,4.541],[14.45,5.981],[13.8,7.371],[13.01,8.691],[12.1,9.921],[11.06,11.071],[9.92,12.101],[8.689,13.011],[7.37,13.801],[5.979,14.451],[4.54,14.971],[3.06,15.341],[1.54,15.571]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[296.88,125.002],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.51,0],[0.51,0.05],[0.5,0.1],[0.479,0.15],[0.47,0.19],[0.45,0.24],[0.431,0.28],[0.39,0.32],[0.36,0.36],[0.319,0.4],[0.279,0.42],[0.239,0.45],[0.19,0.47],[0.149,0.49],[0.109,0.49],[0.05,0.5],[0,0.51],[-0.06,0.51],[-0.101,0.5],[-0.15,0.48],[-0.2,0.47],[-0.24,0.45],[-0.28,0.43],[-0.32,0.39],[-0.359,0.37],[-0.4,0.32],[-0.42,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.11],[-0.51,0.05],[-1.021,-0.11],[-0.5,-0.1],[-0.48,-0.15],[-0.47,-0.2],[-0.45,-0.24],[-0.43,-0.28],[-0.39,-0.32],[-0.359,-0.36],[-0.33,-0.4],[-0.28,-0.42],[-0.24,-0.45],[-0.19,-0.47],[-0.149,-0.49],[-0.1,-0.5],[-0.051,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.15,-0.49],[0.189,-0.47],[0.24,-0.45],[0.28,-0.43],[0.32,-0.39],[0.37,-0.37],[0.4,-0.33],[0.421,-0.28],[0.45,-0.24],[0.471,-0.19],[0.49,-0.15],[0.5,-0.1],[0.5,-0.05]],"o":[[-0.51,0],[-0.51,-0.05],[-0.5,-0.1],[-0.49,-0.15],[-0.47,-0.19],[-0.45,-0.24],[-0.42,-0.28],[-0.4,-0.33],[-0.359,-0.37],[-0.32,-0.39],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.15,-0.49],[-0.101,-0.5],[-0.06,-0.51],[0,-0.51],[0.05,-0.51],[0.109,-0.5],[0.149,-0.49],[0.19,-0.47],[0.239,-0.45],[0.279,-0.42],[0.319,-0.4],[0.36,-0.36],[0.39,-0.32],[0.42,-0.28],[0.45,-0.24],[0.47,-0.2],[0.489,-0.15],[0.5,-0.1],[1.02,-0.11],[0.5,0.05],[0.5,0.11],[0.49,0.15],[0.471,0.19],[0.45,0.24],[0.421,0.28],[0.4,0.32],[0.37,0.37],[0.32,0.39],[0.28,0.43],[0.24,0.45],[0.189,0.47],[0.15,0.48],[0.1,0.5],[0.05,0.51],[0,0.51],[-0.051,0.5],[-0.1,0.49],[-0.149,0.49],[-0.19,0.47],[-0.24,0.45],[-0.28,0.42],[-0.33,0.4],[-0.359,0.36],[-0.39,0.32],[-0.43,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05]],"v":[[0,15.651],[-1.54,15.571],[-3.061,15.341],[-4.54,14.971],[-5.99,14.451],[-7.37,13.801],[-8.69,13.011],[-9.92,12.101],[-11.07,11.071],[-12.1,9.921],[-13.01,8.691],[-13.8,7.371],[-14.45,5.981],[-14.97,4.541],[-15.35,3.061],[-15.57,1.541],[-15.65,0.001],[-15.57,-1.539],[-15.35,-3.059],[-14.97,-4.539],[-14.45,-5.989],[-13.8,-7.368],[-13.01,-8.689],[-12.1,-9.919],[-11.07,-11.069],[-9.92,-12.099],[-8.69,-13.009],[-7.37,-13.799],[-5.99,-14.449],[-4.55,-14.969],[-3.061,-15.349],[-1.54,-15.569],[1.54,-15.569],[3.06,-15.349],[4.54,-14.969],[5.979,-14.449],[7.37,-13.799],[8.689,-13.009],[9.92,-12.099],[11.06,-11.069],[12.1,-9.919],[13.01,-8.689],[13.8,-7.368],[14.45,-5.989],[14.97,-4.539],[15.34,-3.059],[15.57,-1.539],[15.65,0.001],[15.57,1.541],[15.34,3.061],[14.97,4.541],[14.45,5.981],[13.8,7.371],[13.01,8.691],[12.1,9.921],[11.06,11.071],[9.92,12.101],[8.689,13.011],[7.37,13.801],[5.979,14.451],[4.54,14.971],[3.06,15.341],[1.54,15.571]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[359.38,125.002],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[8.644,0],[0,0],[0,8.643],[-8.644,0],[0,0],[0,-8.643]],"o":[[0,0],[-8.644,0],[0,-8.643],[0,0],[8.644,0],[0,8.643]],"v":[[171.876,15.65],[-171.876,15.65],[-187.526,0],[-171.876,-15.65],[171.876,-15.65],[187.526,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,171.878],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,171.878],"ix":2},"a":{"a":0,"k":[250.004,171.878],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[4.002,0],[3.056,3.051],[0,0],[-6.107,6.116],[-6.116,-6.108],[0,0],[0,0],[-6.107,-6.115],[6.115,-6.108],[0,0]],"o":[[-4.002,0],[0,0],[-6.115,-6.108],[6.108,-6.114],[0,0],[0,0],[6.115,-6.107],[6.108,6.115],[0,0],[-3.055,3.051]],"v":[[-23.984,61.442],[-35.043,56.866],[-78.768,13.195],[-78.782,-8.938],[-56.649,-8.951],[-23.984,23.673],[56.649,-56.865],[78.781,-56.852],[78.768,-34.72],[-12.924,56.865]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250.003,292.753],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-2.858],[0,0],[-2.858,0],[0,0],[0,2.858],[0,0],[2.858,0]],"o":[[-2.858,0],[0,0],[0,2.858],[0,0],[2.858,0],[0,0],[0,-2.858],[0,0]],"v":[[-151.042,-156.226],[-156.226,-151.043],[-156.226,151.042],[-151.042,156.226],[151.042,156.226],[156.226,151.042],[156.226,-151.043],[151.042,-156.226]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[20.117,0],[0,0],[0,20.117],[0,0],[-20.117,0],[0,0],[0,-20.117],[0,0]],"o":[[0,0],[-20.117,0],[0,0],[0,-20.117],[0,0],[20.117,0],[0,0],[0,20.117]],"v":[[151.042,187.526],[-151.042,187.526],[-187.526,151.042],[-187.526,-151.043],[-151.042,-187.526],[151.042,-187.526],[187.526,-151.043],[187.526,151.042]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,250.003],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,250.003],"ix":2},"a":{"a":0,"k":[250.004,250.003],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","parent":6,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,42.749,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-81.604,66.643],[-34.5,-33],[34.5,-35.5]],"o":[[0,0],[0,0],[0,0],[90,-73.5],[46.014,44.014],[0,0]],"v":[[67.709,-45.792],[-23.984,45.792],[-67.709,2.122],[-118.504,-193.753],[116.997,-195.253],[67.997,-45.753]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[22.5]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[28.5]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[28.5]},{"t":90,"s":[22.5]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[181]},{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[181]},{"t":90,"s":[360]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":90,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[11.505,0],[0,0],[0,-11.506],[0,0],[-11.506,0],[0,0],[0,11.505],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.505],[0,0],[11.505,0],[0,0],[0,-11.506]],"v":[[151.042,-171.876],[-151.042,-171.876],[-171.876,-151.042],[-171.876,151.042],[-151.042,171.876],[151.042,171.876],[171.876,151.042],[171.876,-151.042]],"c":true}]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[{"i":[[107.136,0],[0,0],[0,-106.591],[0,0],[-106.043,0],[0,0],[0,106.032],[0,0]],"o":[[0,0],[-106.591,0],[0,0],[0,106.031],[0,0],[106.032,0],[0,0],[0,-107.148]],"v":[[-1.004,-192.997],[-0.004,-192.997],[-192.997,-0.004],[-192.997,0.997],[-0.997,192.997],[0.996,192.997],[192.997,0.996],[192.997,1.004]],"c":true}]},{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0},"t":60,"s":[{"i":[[107.136,0],[0,0],[0,-106.591],[0,0],[-106.043,0],[0,0],[0,106.032],[0,0]],"o":[[0,0],[-106.591,0],[0,0],[0,106.031],[0,0],[106.032,0],[0,0],[0,-107.148]],"v":[[-1.004,-192.997],[-0.004,-192.997],[-192.997,-0.004],[-192.997,0.997],[-0.997,192.997],[0.996,192.997],[192.997,0.996],[192.997,1.004]],"c":true}]},{"t":90,"s":[{"i":[[11.505,0],[0,0],[0,-11.506],[0,0],[-11.506,0],[0,0],[0,11.505],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.505],[0,0],[11.505,0],[0,0],[0,-11.506]],"v":[[151.042,-171.876],[-151.042,-171.876],[-171.876,-151.042],[-171.876,151.042],[-151.042,171.876],[151.042,171.876],[171.876,151.042],[171.876,-151.042]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":90,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":".primary.design","cl":"primary design","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[250.004,148.439,0],"to":[0,-32.333,0],"ti":[0,32.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":15,"s":[250.004,-45.561,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.333,"y":0},"t":60,"s":[250.004,-45.561,0],"to":[0,32.333,0],"ti":[0,-32.333,0]},{"t":90,"s":[250.004,148.439,0]}],"ix":2,"l":2},"a":{"a":0,"k":[250.004,148.439,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.51,0],[0.51,0.05],[0.5,0.1],[0.479,0.15],[0.47,0.2],[0.45,0.24],[0.42,0.28],[0.39,0.32],[0.36,0.36],[0.319,0.4],[0.279,0.42],[0.239,0.45],[0.19,0.47],[0.149,0.49],[0.1,0.5],[0.05,0.5],[0,0.51],[-0.06,0.51],[-0.101,0.5],[-0.15,0.49],[-0.2,0.47],[-0.24,0.45],[-0.28,0.42],[-0.32,0.39],[-0.359,0.36],[-0.4,0.32],[-0.42,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05],[-1.021,-0.11],[-0.5,-0.1],[-0.48,-0.15],[-0.46,-0.2],[-0.45,-0.24],[-0.43,-0.29],[-0.39,-0.32],[-0.359,-0.36],[-0.33,-0.4],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.149,-0.48],[-0.1,-0.5],[-0.051,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.141,-0.48],[0.189,-0.47],[0.23,-0.45],[0.28,-0.43],[0.32,-0.39],[0.37,-0.37],[0.4,-0.33],[0.421,-0.28],[0.45,-0.24],[0.48,-0.19],[0.49,-0.15],[0.5,-0.1],[0.51,-0.05]],"o":[[-0.51,0],[-0.51,-0.05],[-0.5,-0.1],[-0.49,-0.15],[-0.47,-0.19],[-0.45,-0.24],[-0.42,-0.28],[-0.4,-0.33],[-0.359,-0.37],[-0.32,-0.39],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.15,-0.48],[-0.101,-0.5],[-0.06,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.149,-0.48],[0.19,-0.47],[0.239,-0.45],[0.279,-0.43],[0.319,-0.4],[0.36,-0.36],[0.39,-0.32],[0.42,-0.29],[0.45,-0.24],[0.47,-0.2],[0.489,-0.15],[0.5,-0.1],[1.02,-0.11],[0.51,0.05],[0.5,0.1],[0.49,0.15],[0.48,0.19],[0.45,0.24],[0.421,0.28],[0.4,0.32],[0.37,0.36],[0.32,0.39],[0.28,0.42],[0.23,0.45],[0.189,0.47],[0.141,0.49],[0.1,0.5],[0.05,0.51],[0,0.51],[-0.051,0.5],[-0.1,0.5],[-0.149,0.49],[-0.2,0.48],[-0.24,0.45],[-0.28,0.42],[-0.33,0.4],[-0.359,0.36],[-0.39,0.32],[-0.43,0.28],[-0.45,0.24],[-0.46,0.2],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05]],"v":[[0,15.651],[-1.54,15.571],[-3.061,15.341],[-4.54,14.971],[-5.99,14.451],[-7.37,13.801],[-8.69,13.011],[-9.92,12.101],[-11.07,11.071],[-12.1,9.921],[-13.01,8.691],[-13.8,7.371],[-14.45,5.981],[-14.97,4.541],[-15.35,3.061],[-15.57,1.541],[-15.65,0.001],[-15.57,-1.539],[-15.35,-3.059],[-14.97,-4.549],[-14.45,-5.989],[-13.8,-7.368],[-13.01,-8.689],[-12.1,-9.919],[-11.07,-11.069],[-9.92,-12.099],[-8.69,-13.009],[-7.37,-13.799],[-5.99,-14.449],[-4.55,-14.969],[-3.061,-15.349],[-1.54,-15.569],[1.54,-15.569],[3.06,-15.349],[4.54,-14.969],[5.979,-14.449],[7.37,-13.799],[8.689,-13.009],[9.92,-12.099],[11.06,-11.069],[12.1,-9.919],[13.01,-8.689],[13.8,-7.368],[14.45,-5.989],[14.97,-4.549],[15.34,-3.059],[15.57,-1.539],[15.65,0.001],[15.57,1.541],[15.34,3.061],[14.97,4.541],[14.45,5.981],[13.8,7.371],[13.01,8.691],[12.1,9.921],[11.06,11.071],[9.92,12.101],[8.689,13.011],[7.37,13.801],[5.979,14.451],[4.54,14.971],[3.06,15.341],[1.54,15.571]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[296.88,125.002],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.51,0],[0.51,0.05],[0.5,0.1],[0.479,0.15],[0.47,0.19],[0.45,0.24],[0.431,0.28],[0.39,0.32],[0.36,0.36],[0.319,0.4],[0.279,0.42],[0.239,0.45],[0.19,0.47],[0.149,0.49],[0.109,0.49],[0.05,0.5],[0,0.51],[-0.06,0.51],[-0.101,0.5],[-0.15,0.48],[-0.2,0.47],[-0.24,0.45],[-0.28,0.43],[-0.32,0.39],[-0.359,0.37],[-0.4,0.32],[-0.42,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.11],[-0.51,0.05],[-1.021,-0.11],[-0.5,-0.1],[-0.48,-0.15],[-0.47,-0.2],[-0.45,-0.24],[-0.43,-0.28],[-0.39,-0.32],[-0.359,-0.36],[-0.33,-0.4],[-0.28,-0.42],[-0.24,-0.45],[-0.19,-0.47],[-0.149,-0.49],[-0.1,-0.5],[-0.051,-0.51],[0,-0.51],[0.05,-0.51],[0.1,-0.5],[0.15,-0.49],[0.189,-0.47],[0.24,-0.45],[0.28,-0.43],[0.32,-0.39],[0.37,-0.37],[0.4,-0.33],[0.421,-0.28],[0.45,-0.24],[0.471,-0.19],[0.49,-0.15],[0.5,-0.1],[0.5,-0.05]],"o":[[-0.51,0],[-0.51,-0.05],[-0.5,-0.1],[-0.49,-0.15],[-0.47,-0.19],[-0.45,-0.24],[-0.42,-0.28],[-0.4,-0.33],[-0.359,-0.37],[-0.32,-0.39],[-0.28,-0.43],[-0.24,-0.45],[-0.2,-0.47],[-0.15,-0.49],[-0.101,-0.5],[-0.06,-0.51],[0,-0.51],[0.05,-0.51],[0.109,-0.5],[0.149,-0.49],[0.19,-0.47],[0.239,-0.45],[0.279,-0.42],[0.319,-0.4],[0.36,-0.36],[0.39,-0.32],[0.42,-0.28],[0.45,-0.24],[0.47,-0.2],[0.489,-0.15],[0.5,-0.1],[1.02,-0.11],[0.5,0.05],[0.5,0.11],[0.49,0.15],[0.471,0.19],[0.45,0.24],[0.421,0.28],[0.4,0.32],[0.37,0.37],[0.32,0.39],[0.28,0.43],[0.24,0.45],[0.189,0.47],[0.15,0.48],[0.1,0.5],[0.05,0.51],[0,0.51],[-0.051,0.5],[-0.1,0.49],[-0.149,0.49],[-0.19,0.47],[-0.24,0.45],[-0.28,0.42],[-0.33,0.4],[-0.359,0.36],[-0.39,0.32],[-0.43,0.28],[-0.45,0.24],[-0.47,0.19],[-0.48,0.15],[-0.5,0.1],[-0.51,0.05]],"v":[[0,15.651],[-1.54,15.571],[-3.061,15.341],[-4.54,14.971],[-5.99,14.451],[-7.37,13.801],[-8.69,13.011],[-9.92,12.101],[-11.07,11.071],[-12.1,9.921],[-13.01,8.691],[-13.8,7.371],[-14.45,5.981],[-14.97,4.541],[-15.35,3.061],[-15.57,1.541],[-15.65,0.001],[-15.57,-1.539],[-15.35,-3.059],[-14.97,-4.539],[-14.45,-5.989],[-13.8,-7.368],[-13.01,-8.689],[-12.1,-9.919],[-11.07,-11.069],[-9.92,-12.099],[-8.69,-13.009],[-7.37,-13.799],[-5.99,-14.449],[-4.55,-14.969],[-3.061,-15.349],[-1.54,-15.569],[1.54,-15.569],[3.06,-15.349],[4.54,-14.969],[5.979,-14.449],[7.37,-13.799],[8.689,-13.009],[9.92,-12.099],[11.06,-11.069],[12.1,-9.919],[13.01,-8.689],[13.8,-7.368],[14.45,-5.989],[14.97,-4.539],[15.34,-3.059],[15.57,-1.539],[15.65,0.001],[15.57,1.541],[15.34,3.061],[14.97,4.541],[14.45,5.981],[13.8,7.371],[13.01,8.691],[12.1,9.921],[11.06,11.071],[9.92,12.101],[8.689,13.011],[7.37,13.801],[5.979,14.451],[4.54,14.971],[3.06,15.341],[1.54,15.571]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[359.38,125.002],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[8.644,0],[0,0],[0,8.643],[-8.644,0],[0,0],[0,-8.643]],"o":[[0,0],[-8.644,0],[0,-8.643],[0,0],[8.644,0],[0,8.643]],"v":[[171.876,15.65],[-171.876,15.65],[-187.526,0],[-171.876,-15.65],[171.876,-15.65],[187.526,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,171.878],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[250.004,171.878],"ix":2},"a":{"a":0,"k":[250.004,171.878],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":90,"st":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"t":60,"s":[360]}],"ix":10},"p":{"a":0,"k":[250.004,250.003,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[11.505,0],[0,0],[0,-11.506],[0,0],[-11.506,0],[0,0],[0,11.505],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.505],[0,0],[11.505,0],[0,0],[0,-11.506]],"v":[[151.042,-171.876],[-151.042,-171.876],[-171.876,-151.042],[-171.876,151.042],[-151.042,171.876],[151.042,171.876],[171.876,151.042],[171.876,-151.042]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":15,"s":[{"i":[[107.136,0],[0,0],[0,-106.591],[0,0],[-106.043,0],[0,0],[0,106.032],[0,0]],"o":[[0,0],[-106.591,0],[0,0],[0,106.031],[0,0],[106.032,0],[0,0],[0,-107.148]],"v":[[-1.004,-192.997],[-0.004,-192.997],[-192.997,-0.004],[-192.997,0.997],[-0.997,192.997],[0.996,192.997],[192.997,0.996],[192.997,1.004]],"c":true}]},{"i":{"x":0.2,"y":1},"o":{"x":0.167,"y":0.167},"t":60,"s":[{"i":[[107.136,0],[0,0],[0,-106.591],[0,0],[-106.043,0],[0,0],[0,106.032],[0,0]],"o":[[0,0],[-106.591,0],[0,0],[0,106.031],[0,0],[106.032,0],[0,0],[0,-107.148]],"v":[[-1.004,-192.997],[-0.004,-192.997],[-192.997,-0.004],[-192.997,0.997],[-0.997,192.997],[0.996,192.997],[192.997,0.996],[192.997,1.004]],"c":true}]},{"t":90,"s":[{"i":[[11.505,0],[0,0],[0,-11.506],[0,0],[-11.506,0],[0,0],[0,11.505],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.505],[0,0],[11.505,0],[0,0],[0,-11.506]],"v":[[151.042,-171.876],[-151.042,-171.876],[-171.876,-151.042],[-171.876,151.042],[-151.042,171.876],[151.042,171.876],[171.876,151.042],[171.876,-151.042]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":7,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9,"s":[7]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":14,"s":[0]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.299],"y":[0]},"t":60,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[0]},{"t":72,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":7,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":14,"s":[66.5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0]},"t":60,"s":[66.5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[89.5]},{"t":72,"s":[100]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[60]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[16]},{"t":72,"s":[0]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0.070588235294,0.074509803922,0.192156862745,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('189-domain-verification-outline').layer('Color & Stroke Change').effect('Primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":90,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"lordicon.com Outlines","cl":"com","sr":1,"ks":{"o":{"a":0,"k":20,"ix":11,"x":"var $bm_rt;\nvar checkbox = thisComp.layer('02092020').effect('02092020002')('Checkbox');\nif (checkbox == 1) {\n $bm_rt = 20;\n} else {\n $bm_rt = 0;\n}\n;"},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[249.934,481.369,0],"ix":2,"l":2},"a":{"a":0,"k":[79.934,0.369,0],"ix":1,"l":2},"s":{"a":0,"k":[265.159,265.159,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[1.415,0],[11.014,0],[11.014,-2.523],[4.656,-2.523],[4.656,-14.809],[1.415,-14.809]],"c":true},"ix":2},"nm":"l","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"l","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-3.938],[-1.62,-1.723],[-1.949,0],[-1.641,1.846],[0,2.154],[1.579,1.805],[1.579,0]],"o":[[0,1.354],[1.354,1.415],[1.231,0],[1.21,-1.354],[0,-1.456],[-1.456,-1.641],[-5.333,0]],"v":[[11.167,-7.199],[12.992,-1.661],[18.243,0.369],[23.514,-1.743],[25.381,-7.548],[23.494,-13.127],[18.284,-15.137]],"c":true},"ix":2},"nm":"o","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,1.415],[-0.841,1.026],[-1.19,0],[-0.615,-1.825],[0,-0.718],[0.492,-0.738],[1.292,0],[0.451,0.615]],"o":[[0,-1.682],[0.595,-0.759],[1.518,0],[0.308,0.902],[0,2.359],[-0.595,0.923],[-1.477,0],[-0.882,-1.149]],"v":[[14.49,-7.302],[15.577,-11.609],[18.305,-12.86],[21.689,-10.235],[22.058,-7.589],[21.053,-3.343],[18.284,-1.969],[15.597,-3.159]],"c":true},"ix":2},"nm":"o","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"o","np":5,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.287,-0.841],[-0.144,-0.82],[0,0],[0.164,0.656],[0.226,1.743],[2.236,0.205],[0,2.769],[0.923,0.8],[1.641,-0.021],[0,0]],"o":[[0,0],[0,0],[0,0],[0.533,0],[0.205,0.574],[0,0],[-0.164,-0.246],[-0.103,-0.41],[-0.267,-1.928],[0.718,-0.205],[0,-0.964],[-1.19,-1.026],[0,0],[0,0]],"v":[[27.381,0],[30.622,0],[30.622,-5.989],[33.411,-5.989],[35.011,-5.148],[35.811,0],[39.318,0],[38.867,-1.067],[38.416,-3.938],[35.749,-7.343],[38.847,-10.973],[37.554,-13.824],[33.063,-14.829],[27.381,-14.829]],"c":true},"ix":2},"nm":"r","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-0.492,-0.349],[0,-1.005],[0.226,-0.164],[0.369,0],[0,0]],"o":[[0,0],[1.005,0],[0.287,0.185],[0,1.046],[-0.513,0.41],[0,0],[0,0]],"v":[[30.519,-12.491],[32.652,-12.491],[34.744,-12.142],[35.524,-10.481],[34.703,-8.758],[33.083,-8.348],[30.519,-8.348]],"c":true},"ix":2},"nm":"r","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"r","np":5,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-0.554,0.103],[0,4.553],[1.866,1.374],[0.82,0],[0,0]],"o":[[0,0],[1.497,0],[2.81,-0.513],[0,-2.113],[-1.784,-1.313],[0,0],[0,0]],"v":[[41.068,0],[45.683,0],[48.349,-0.164],[53.6,-7.609],[51.077,-13.434],[45.97,-14.768],[41.068,-14.788]],"c":true},"ix":2},"nm":"d","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[-0.656,-0.185],[0,-2.092],[1.251,-1.251],[1.354,0],[0.349,0.021]],"o":[[1.825,-0.082],[1.99,0.554],[0,0.718],[-0.923,0.923],[-0.369,0],[0,0]],"v":[[44.288,-12.388],[47.611,-12.183],[50.318,-7.609],[48.985,-3.425],[45.539,-2.4],[44.288,-2.441]],"c":true},"ix":2},"nm":"d","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"d","np":5,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[55.669,0],[58.849,0],[58.849,-14.87],[55.669,-14.87]],"c":true},"ix":2},"nm":"i","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"i","np":3,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[3.241,0],[0,-4.697],[-5.107,0],[-1.313,1.354],[-0.062,0.882],[0,0],[1.333,0],[0,0.882],[-2.359,0],[-0.062,-0.513]],"o":[[0,-2.954],[-4.164,0],[0,3.671],[1.354,0],[1.19,-1.231],[0,0],[-0.062,1.969],[-3.097,0],[0,-3.056],[2.154,0],[0,0]],"v":[[73.104,-9.989],[67.587,-14.911],[60.798,-7.097],[67.566,0.349],[71.894,-1.313],[73.227,-4.799],[69.884,-4.799],[67.218,-1.99],[64.121,-7.076],[67.464,-12.593],[69.864,-9.989]],"c":true},"ix":2},"nm":"c","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"c","np":3,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-3.938],[-1.62,-1.723],[-1.949,0],[-1.641,1.846],[0,2.154],[1.579,1.805],[1.579,0]],"o":[[0,1.354],[1.354,1.415],[1.231,0],[1.21,-1.354],[0,-1.456],[-1.456,-1.641],[-5.333,0]],"v":[[74.546,-7.199],[76.372,-1.661],[81.622,0.369],[86.894,-1.743],[88.76,-7.548],[86.873,-13.127],[81.663,-15.137]],"c":true},"ix":2},"nm":"o","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,1.415],[-0.841,1.026],[-1.19,0],[-0.615,-1.825],[0,-0.718],[0.492,-0.738],[1.292,0],[0.451,0.615]],"o":[[0,-1.682],[0.595,-0.759],[1.518,0],[0.308,0.902],[0,2.359],[-0.595,0.923],[-1.477,0],[-0.882,-1.149]],"v":[[77.869,-7.302],[78.956,-11.609],[81.684,-12.86],[85.068,-10.235],[85.437,-7.589],[84.432,-3.343],[81.663,-1.969],[78.977,-3.159]],"c":true},"ix":2},"nm":"o","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"o","np":5,"cix":2,"bm":0,"ix":7,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[91.007,0],[94.001,0],[94.001,-12.306],[99.744,0],[104.113,0],[104.113,-14.829],[101.159,-14.829],[101.159,-3.159],[95.601,-14.829],[91.007,-14.829]],"c":true},"ix":2},"nm":"n","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"n","np":3,"cix":2,"bm":0,"ix":8,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[106.893,0],[109.497,0],[109.497,-2.728],[106.893,-2.728]],"c":true},"ix":2},"nm":".","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":".","np":3,"cix":2,"bm":0,"ix":9,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[3.241,0],[0,-4.697],[-5.107,0],[-1.313,1.354],[-0.062,0.882],[0,0],[1.333,0],[0,0.882],[-2.359,0],[-0.062,-0.513]],"o":[[0,-2.954],[-4.164,0],[0,3.671],[1.354,0],[1.19,-1.231],[0,0],[-0.062,1.969],[-3.097,0],[0,-3.056],[2.154,0],[0,0]],"v":[[124.04,-9.989],[118.523,-14.911],[111.734,-7.097],[118.502,0.349],[122.83,-1.313],[124.163,-4.799],[120.82,-4.799],[118.154,-1.99],[115.057,-7.076],[118.4,-12.593],[120.8,-9.989]],"c":true},"ix":2},"nm":"c","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"c","np":3,"cix":2,"bm":0,"ix":10,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-3.938],[-1.62,-1.723],[-1.949,0],[-1.641,1.846],[0,2.154],[1.579,1.805],[1.579,0]],"o":[[0,1.354],[1.354,1.415],[1.231,0],[1.21,-1.354],[0,-1.456],[-1.456,-1.641],[-5.333,0]],"v":[[125.482,-7.199],[127.308,-1.661],[132.558,0.369],[137.829,-1.743],[139.696,-7.548],[137.809,-13.127],[132.599,-15.137]],"c":true},"ix":2},"nm":"o","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,1.415],[-0.841,1.026],[-1.19,0],[-0.615,-1.825],[0,-0.718],[0.492,-0.738],[1.292,0],[0.451,0.615]],"o":[[0,-1.682],[0.595,-0.759],[1.518,0],[0.308,0.902],[0,2.359],[-0.595,0.923],[-1.477,0],[-0.882,-1.149]],"v":[[128.805,-7.302],[129.892,-11.609],[132.62,-12.86],[136.004,-10.235],[136.373,-7.589],[135.368,-3.343],[132.599,-1.969],[129.912,-3.159]],"c":true},"ix":2},"nm":"o","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"o","np":5,"cix":2,"bm":0,"ix":11,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[141.696,0],[144.67,0],[144.67,-12.716],[148.629,0],[151.254,0],[155.295,-12.716],[155.295,0],[158.453,0],[158.453,-14.829],[153.408,-14.829],[150.024,-4.041],[146.885,-14.829],[141.696,-14.829]],"c":true},"ix":2},"nm":"m","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"m","np":3,"cix":2,"bm":0,"ix":12,"mn":"ADBE Vector Group","hd":false}],"ip":2.5,"op":25,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":3,"nm":"02092020","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-105,15,0],"ix":2,"l":2},"a":{"a":0,"k":[60,60,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"02092020002","np":3,"mn":"ADBE Checkbox Control","ix":1,"en":1,"ef":[{"ty":7,"nm":"Checkbox","mn":"ADBE Checkbox Control-0001","ix":1,"v":{"a":0,"k":0,"ix":1}}]}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":3,"nm":"Color & Stroke Change","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-55,-102,0],"ix":2,"l":2,"x":"var $bm_rt;\n$bm_rt = effect('Axis')('Point');"},"a":{"a":0,"k":[50,50,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2,"x":"var $bm_rt;\nvar temp;\ntemp = effect('Scale')('Slider');\n$bm_rt = [\n temp,\n temp\n];"}},"ao":0,"ef":[{"ty":5,"nm":"Primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[1,1,1],"ix":1}}]},{"ty":5,"nm":"Axis","np":3,"mn":"ADBE Point Control","ix":2,"en":1,"ef":[{"ty":3,"nm":"Point","mn":"ADBE Point Control-0001","ix":1,"v":{"a":0,"k":[250,250],"ix":1}}]},{"ty":5,"nm":"Scale","np":3,"mn":"ADBE Slider Control","ix":3,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":0,"k":100,"ix":1}}]},{"ty":5,"nm":"State-Intro","np":3,"mn":"ADBE Slider Control","ix":4,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":0,"k":0,"ix":1}}]},{"ty":5,"nm":"State-Hover","np":3,"mn":"ADBE Slider Control","ix":5,"en":1,"ef":[{"ty":0,"nm":"Slider","mn":"ADBE Slider Control-0001","ix":1,"v":{"a":0,"k":1,"ix":1}}]}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"in-domain","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11,"x":"var $bm_rt;\n$bm_rt = $bm_mul(thisComp.layer('Color & Stroke Change').effect('State-Intro')('Slider'), 100);"},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50,50,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":101,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"hover-domain","parent":3,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11,"x":"var $bm_rt;\n$bm_rt = $bm_mul(thisComp.layer('Color & Stroke Change').effect('State-Hover')('Slider'), 100);"},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50,50,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":101,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/frontend/scripts/initialize-standalone-build.sh b/frontend/scripts/initialize-standalone-build.sh new file mode 100755 index 000000000..d9138bb77 --- /dev/null +++ b/frontend/scripts/initialize-standalone-build.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_POSTHOG_API_KEY" "$NEXT_PUBLIC_POSTHOG_API_KEY" + +scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_INTERCOM_ID" "$NEXT_PUBLIC_INTERCOM_ID" + +if [ "$TELEMETRY_ENABLED" != "false" ]; then + echo "Telemetry is enabled" + scripts/set-standalone-build-telemetry.sh true +else + echo "Client opted out of telemetry" + scripts/set-standalone-build-telemetry.sh false +fi diff --git a/frontend/scripts/replace-standalone-build-variable.sh b/frontend/scripts/replace-standalone-build-variable.sh new file mode 100755 index 000000000..fde4ca282 --- /dev/null +++ b/frontend/scripts/replace-standalone-build-variable.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +ORIGINAL=$1 +REPLACEMENT=$2 + +if [ "${ORIGINAL}" = "${REPLACEMENT}" ]; then + echo "Environment variable replacement is the same, skipping.." + exit 0 +fi + +echo "Replacing pre-baked value.." + +find public .next -type f -name "*.js" | +while read file; do + sed -i "s|$ORIGINAL|$REPLACEMENT|g" "$file" +done diff --git a/frontend/scripts/replace-variable.sh b/frontend/scripts/replace-variable.sh old mode 100644 new mode 100755 diff --git a/frontend/scripts/set-standalone-build-telemetry.sh b/frontend/scripts/set-standalone-build-telemetry.sh new file mode 100644 index 000000000..5e788b6b9 --- /dev/null +++ b/frontend/scripts/set-standalone-build-telemetry.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +VALUE=$1 + +find public .next -type f -name "*.js" | +while read file; do + sed -i "s|TELEMETRY_CAPTURING_ENABLED|$VALUE|g" "$file" +done diff --git a/frontend/scripts/set-telemetry.sh b/frontend/scripts/set-telemetry.sh old mode 100644 new mode 100755 diff --git a/frontend/src/components/analytics/posthog.ts b/frontend/src/components/analytics/posthog.ts index 706f79515..246d5c9cc 100644 --- a/frontend/src/components/analytics/posthog.ts +++ b/frontend/src/components/analytics/posthog.ts @@ -10,7 +10,7 @@ export const initPostHog = () => { try { if (typeof window !== "undefined") { // @ts-ignore - if (ENV === "production" && TELEMETRY_CAPTURING_ENABLED) { + if (ENV === "production" && TELEMETRY_CAPTURING_ENABLED === "true") { posthog.init(POSTHOG_API_KEY, { api_host: POSTHOG_HOST }); diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index d4a5c4352..d2a046139 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -1,7 +1,6 @@ -import { useMemo } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; -import { faAngleRight } from "@fortawesome/free-solid-svg-icons"; +import { faAngleRight, faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useOrganization, useWorkspace } from "@app/context"; @@ -15,8 +14,10 @@ type Props = { currentEnv?: string; userAvailableEnvs?: any[]; onEnvChange?: (slug: string) => void; - folders?: Array<{ id: string; name: string }>; + secretPath?: string; isFolderMode?: boolean; + isProtectedBranch?: boolean; + protectionPolicyName?: string; }; // TODO: make links clickable and clean up @@ -42,19 +43,16 @@ export default function NavHeader({ currentEnv, userAvailableEnvs = [], onEnvChange, - folders = [], - isFolderMode + isFolderMode, + secretPath = "/", + isProtectedBranch = false, + protectionPolicyName }: Props): JSX.Element { const { currentWorkspace } = useWorkspace(); const { currentOrg } = useOrganization(); const router = useRouter(); - const isInRootFolder = isFolderMode && folders.length <= 1; - - const selectedEnv = useMemo( - () => userAvailableEnvs?.find((uae) => uae.name === currentEnv), - [userAvailableEnvs, currentEnv] - ); + const secretPathSegments = secretPath.split("/").filter(Boolean); return (
@@ -90,13 +88,13 @@ export default function NavHeader({ ) : (
{pageName}
)} - {currentEnv && isInRootFolder && ( + {currentEnv && secretPath === "/" && ( <>
+ + )} + /> +
+
+ Tag Color +
+
+
+
+
+
+ {!showHexInput ? ( +
+ {secretTagsColors.map(($tagColor: TagColor) => { + return ( +
+ +
setValue("color", $tagColor.hex)} + tabIndex={0} + role="button" + onKeyDown={() => {}} + > + {$tagColor.hex === selectedTagColor && ( + + )} +
+
+
+ ); + })} +
+ ) : ( +
+
+ {isValidHexColor(selectedTagColor) && ( +
+ +
+ )} + {!isValidHexColor(selectedTagColor) && ( +
+ )} +
+
+ ) => + setValue("color", e.target.value) + } + /> +
+
+ )} +
+
+
setShowHexInput((prev) => !prev)} + style={{ border: "1px solid rgba(220, 216, 254, 0.376)" }} + tabIndex={0} + role="button" + onKeyDown={() => {}} + > + {!showHexInput && #} +
+
+
+
+
+ +
+ + + + +
+ + + + ); +}; diff --git a/frontend/src/components/tags/CreateTagModal/index.tsx b/frontend/src/components/tags/CreateTagModal/index.tsx new file mode 100644 index 000000000..e9d283b3c --- /dev/null +++ b/frontend/src/components/tags/CreateTagModal/index.tsx @@ -0,0 +1 @@ +export { CreateTagModal } from "./CreateTagModal"; diff --git a/frontend/src/components/utilities/attemptCliLogin.ts b/frontend/src/components/utilities/attemptCliLogin.ts index b2eeeceea..b8812918a 100644 --- a/frontend/src/components/utilities/attemptCliLogin.ts +++ b/frontend/src/components/utilities/attemptCliLogin.ts @@ -2,8 +2,6 @@ import jsrp from "jsrp"; import { login1, login2 } from "@app/hooks/api/auth/queries"; -import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -import { fetchMyOrganizationProjects } from "@app/hooks/api/users/queries"; import KeyService from "@app/services/KeyService"; import Telemetry from "./telemetry/Telemetry"; @@ -122,17 +120,7 @@ const attemptLogin = async ( iv, tag, privateKey - }); - - const userOrgs = await fetchOrganizations(); - const orgId = userOrgs[0]._id; - localStorage.setItem("orgData.id", orgId); - - const orgUserProjects = await fetchMyOrganizationProjects(orgId); - - if (orgUserProjects.length > 0) { - localStorage.setItem("projectData.id", orgUserProjects[0]._id); - } + }); if (email) { telemetry.identify(email, email); diff --git a/frontend/src/components/utilities/attemptCliLoginMfa.ts b/frontend/src/components/utilities/attemptCliLoginMfa.ts index 6681e8464..3a99e6e8e 100644 --- a/frontend/src/components/utilities/attemptCliLoginMfa.ts +++ b/frontend/src/components/utilities/attemptCliLoginMfa.ts @@ -2,9 +2,6 @@ import jsrp from "jsrp"; import { login1 , verifyMfaToken } from "@app/hooks/api/auth/queries"; -import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -import { fetchMyOrganizationProjects } from "@app/hooks/api/users/queries"; -// import verifyMfaToken from "@app/pages/api/auth/verifyMfaToken"; import KeyService from "@app/services/KeyService"; import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage"; @@ -19,7 +16,6 @@ interface IsMfaLoginSuccessful { privateKey: string; JTWToken: string; } - } /** @@ -93,16 +89,6 @@ const attemptLoginMfa = async ({ privateKey }); - // TODO: in the future - move this logic elsewhere - // because this function is about logging the user in - // and not initializing the login details - const userOrgs = await fetchOrganizations(); - const orgId = userOrgs[0]._id; - localStorage.setItem("orgData.id", orgId); - - const orgUserProjects = await fetchMyOrganizationProjects(orgId); - localStorage.setItem("projectData.id", orgUserProjects[0]._id); - resolve({ success: true, loginResponse:{ diff --git a/frontend/src/components/utilities/attemptLogin.ts b/frontend/src/components/utilities/attemptLogin.ts index c4fe91b05..c9cedbf68 100644 --- a/frontend/src/components/utilities/attemptLogin.ts +++ b/frontend/src/components/utilities/attemptLogin.ts @@ -2,8 +2,6 @@ import jsrp from "jsrp"; import { login1, login2 } from "@app/hooks/api/auth/queries"; -import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -import { fetchMyOrganizationProjects } from "@app/hooks/api/users/queries"; import KeyService from "@app/services/KeyService"; import Telemetry from "./telemetry/Telemetry"; @@ -119,20 +117,6 @@ const attemptLogin = async ( tag, privateKey }); - - // TODO: in the future - move this logic elsewhere - // because this function is about logging the user in - // and not initializing the login details - const userOrgs = await fetchOrganizations(); - - const orgId = userOrgs[0]._id; - localStorage.setItem("orgData.id", orgId); - - const orgUserProjects = await fetchMyOrganizationProjects(orgId); - - if (orgUserProjects.length > 0) { - localStorage.setItem("projectData.id", orgUserProjects[0]._id); - } if (email) { telemetry.identify(email, email); diff --git a/frontend/src/components/utilities/attemptLoginMfa.ts b/frontend/src/components/utilities/attemptLoginMfa.ts index c588eb965..fdbf495c1 100644 --- a/frontend/src/components/utilities/attemptLoginMfa.ts +++ b/frontend/src/components/utilities/attemptLoginMfa.ts @@ -2,8 +2,6 @@ import jsrp from "jsrp"; import { login1 , verifyMfaToken } from "@app/hooks/api/auth/queries"; -import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -import { fetchMyOrganizationProjects } from "@app/hooks/api/users/queries"; import KeyService from "@app/services/KeyService"; import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage"; @@ -83,16 +81,6 @@ const attemptLoginMfa = async ({ privateKey }); - // TODO: in the future - move this logic elsewhere - // because this function is about logging the user in - // and not initializing the login details - const userOrgs = await fetchOrganizations(); - const orgId = userOrgs[0]._id; - localStorage.setItem("orgData.id", orgId); - - const orgUserProjects = await fetchMyOrganizationProjects(orgId); - localStorage.setItem("projectData.id", orgUserProjects[0]._id); - resolve(true); } catch (err) { reject(err); diff --git a/frontend/src/components/utilities/telemetry/Telemetry.ts b/frontend/src/components/utilities/telemetry/Telemetry.ts index 2676bed85..76e86fa37 100644 --- a/frontend/src/components/utilities/telemetry/Telemetry.ts +++ b/frontend/src/components/utilities/telemetry/Telemetry.ts @@ -13,7 +13,7 @@ class Capturer { } capture(item: string) { - if (ENV === 'production' && TELEMETRY_CAPTURING_ENABLED) { + if (ENV === 'production' && TELEMETRY_CAPTURING_ENABLED === "true") { try { this.api.capture(item); } catch (error) { @@ -23,7 +23,7 @@ class Capturer { } identify(id: string, email?: string) { - if (ENV === 'production' && TELEMETRY_CAPTURING_ENABLED) { + if (ENV === 'production' && TELEMETRY_CAPTURING_ENABLED === "true") { try { this.api.identify(id, { email: email diff --git a/frontend/src/components/v2/Accordion/Accordion.stories.tsx b/frontend/src/components/v2/Accordion/Accordion.stories.tsx new file mode 100644 index 000000000..bd17c8743 --- /dev/null +++ b/frontend/src/components/v2/Accordion/Accordion.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./Accordion"; + +const meta: Meta = { + title: "Components/Accordion", + component: Accordion, + tags: ["v2"], + argTypes: {} +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + render: (args) => ( +
+ + + Section 1 + Description of Section 1 + + + Section 2 + Description of Section 2 + + +
+ ) +}; diff --git a/frontend/src/components/v2/Accordion/Accordion.tsx b/frontend/src/components/v2/Accordion/Accordion.tsx index 20ec5bacd..9188b873e 100644 --- a/frontend/src/components/v2/Accordion/Accordion.tsx +++ b/frontend/src/components/v2/Accordion/Accordion.tsx @@ -8,7 +8,7 @@ export const AccordionItem = forwardRef ( @@ -51,13 +51,13 @@ export const AccordionContent = forwardRef< >(({ children, className, ...props }, forwardedRef) => ( -
{children}
+
{children}
)); @@ -68,10 +68,7 @@ export const Accordion = ({ children, ...props }: AccordionPrimitive.AccordionSingleProps | AccordionPrimitive.AccordionMultipleProps) => ( - + {children} ); diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx index 7c2a5fd6d..63a735d65 100644 --- a/frontend/src/components/v2/Button/Button.tsx +++ b/frontend/src/components/v2/Button/Button.tsx @@ -11,6 +11,9 @@ type Props = { isLoading?: boolean; }; +// refactor(akhilmhdh): both color and size variants are together need to split it +// colorSchema should handle all color class names +// variant should handle how the button padding and other types should be set const buttonVariants = cva( [ "button", @@ -67,7 +70,7 @@ const buttonVariants = cva( { colorSchema: "primary", variant: "solid", - className: "bg-primary-500 bg-opacity-90 hover:bg-primary-500 hover:text-black" + className: "text-black bg-primary-500 bg-opacity-90 hover:bg-primary-500 hover:text-black" }, { colorSchema: "primary", @@ -106,6 +109,12 @@ const buttonVariants = cva( variant: "outline", className: "text-red hover:bg-red hover:text-black" }, + { + colorSchema: "danger", + variant: "outline_bg", + className: + "bg-mineshaft-600 border border-red-500 hover:bg-red/[0.1] hover:border-red/40 text-red-500" + }, { colorSchema: "primary", variant: "plain", diff --git a/frontend/src/components/v2/Checkbox/Checkbox.tsx b/frontend/src/components/v2/Checkbox/Checkbox.tsx index 8c79a5ff2..87e278119 100644 --- a/frontend/src/components/v2/Checkbox/Checkbox.tsx +++ b/frontend/src/components/v2/Checkbox/Checkbox.tsx @@ -30,9 +30,10 @@ export const Checkbox = ({
{ + const [pos, setPos] = useState(0); + const isTextArray = Array.isArray(text); + useEffect(() => { + let interval: NodeJS.Timer; + if (isTextArray) { + interval = setInterval(() => { + setPos((state) => (state + 1) % text.length); + }, frequency); + } + return () => clearInterval(interval); + }, []); + + return ( +
+ loading animation + {text && isTextArray && ( + + + {text[pos]} + + + )} + {text && !isTextArray &&
{text}
} +
+ ); +}; diff --git a/frontend/src/components/v2/ContentLoader/index.tsx b/frontend/src/components/v2/ContentLoader/index.tsx new file mode 100644 index 000000000..78b373523 --- /dev/null +++ b/frontend/src/components/v2/ContentLoader/index.tsx @@ -0,0 +1 @@ +export { ContentLoader } from "./ContentLoader"; diff --git a/frontend/src/components/v2/Drawer/Drawer.tsx b/frontend/src/components/v2/Drawer/Drawer.tsx index 3bfcbe527..3a92c14db 100644 --- a/frontend/src/components/v2/Drawer/Drawer.tsx +++ b/frontend/src/components/v2/Drawer/Drawer.tsx @@ -37,7 +37,7 @@ export const DrawerContent = forwardRef( ) => ( ( + ({ children, className, ...props }, forwardedRef) => { + return ( + + + {children} + + + ); + } +); + +DropdownSubMenuContent.displayName = "DropdownMenuContent"; + // item label component export type DropdownLabelProps = DropdownMenuPrimitive.MenuLabelProps; export const DropdownMenuLabel = ({ className, ...props }: DropdownLabelProps) => ( @@ -76,11 +103,50 @@ export const DropdownMenuItem = ({ ); +// trigger +export type DropdownSubMenuTriggerProps = + DropdownMenuPrimitive.DropdownMenuSubTriggerProps & { + icon?: ReactNode; + as?: T; + inputRef?: Ref; + iconPos?: "left" | "right"; + }; + +export const DropdownSubMenuTrigger = ({ + children, + inputRef, + className, + icon, + as: Item = "button", + iconPos = "left", + ...props +}: DropdownMenuItemProps & ComponentPropsWithRef) => ( + + + {icon && iconPos === "left" && {icon}} + {children} + {icon && iconPos === "right" && {icon}} + + +); + // grouping items into 1 export type DropdownMenuGroupProps = DropdownMenuPrimitive.DropdownMenuGroupProps; export const DropdownMenuGroup = forwardRef( - ({ ...props }, ref) => + ({ ...props }, ref) => ( + + ) ); DropdownMenuGroup.displayName = "DropdownMenuGroup"; @@ -98,3 +164,5 @@ export const DropdownMenuSeparator = forwardRef< )); DropdownMenuSeparator.displayName = "DropdownMenuSeperator"; + +DropdownMenuSeparator.displayName = "DropdownMenuSeperator"; diff --git a/frontend/src/components/v2/Dropdown/index.tsx b/frontend/src/components/v2/Dropdown/index.tsx index 8ecbebd09..bde01449e 100644 --- a/frontend/src/components/v2/Dropdown/index.tsx +++ b/frontend/src/components/v2/Dropdown/index.tsx @@ -4,7 +4,10 @@ export type { DropdownMenuGroupProps, DropdownMenuItemProps, DropdownMenuProps, - DropdownMenuTriggerProps + DropdownMenuTriggerProps, + DropdownSubMenuContentProps, + DropdownSubMenuProps, + DropdownSubMenuTriggerProps } from "./Dropdown"; export { DropdownMenu, @@ -13,5 +16,8 @@ export { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuTrigger + DropdownMenuTrigger, + DropdownSubMenu, + DropdownSubMenuContent, + DropdownSubMenuTrigger } from "./Dropdown"; diff --git a/frontend/src/components/v2/Menu/Menu.tsx b/frontend/src/components/v2/Menu/Menu.tsx index d56d16fec..e4771e551 100644 --- a/frontend/src/components/v2/Menu/Menu.tsx +++ b/frontend/src/components/v2/Menu/Menu.tsx @@ -2,8 +2,8 @@ /* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable global-require */ import { ComponentPropsWithRef, ElementType, ReactNode, Ref, useRef } from "react"; -import { motion } from "framer-motion" -import Lottie from "lottie-react" +import { motion } from "framer-motion"; +import Lottie from "lottie-react"; import { twMerge } from "tailwind-merge"; export type MenuProps = { @@ -39,13 +39,10 @@ export const MenuItem = ({ inputRef, ...props }: MenuItemProps & ComponentPropsWithRef): JSX.Element => { - const iconRef = useRef() + const iconRef = useRef(); - return( - iconRef.current?.play()} - onMouseLeave={() => iconRef.current?.stop()} - > + return ( + iconRef.current?.play()} onMouseLeave={() => iconRef.current?.stop()}>
  • ({ )} > - -
    - {/* {icon && {icon}} */} - +
    + {/* {icon && {icon}} */} + {icon && ( + + )} {children} {description && {description}}
  • - ) + ); }; export const SubMenuItem = ({ @@ -88,13 +97,10 @@ export const SubMenuItem = ({ inputRef, ...props }: MenuItemProps & ComponentPropsWithRef): JSX.Element => { - const iconRef = useRef() + const iconRef = useRef(); - return( - iconRef.current?.play()} - onMouseLeave={() => iconRef.current?.stop()} - > + return ( + iconRef.current?.play()} onMouseLeave={() => iconRef.current?.stop()}>
  • ({ )} > - + ({
  • - ) + ); }; - MenuItem.displayName = "MenuItem"; export type MenuGroupProps = { diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index 200ceefb2..98a4ad71a 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -22,14 +22,14 @@ export const ModalContent = forwardRef( ) => ( diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index 666c41d69..314dada0c 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -1,7 +1,7 @@ /* eslint-disable react/no-danger */ -import { forwardRef, HTMLAttributes } from "react"; -import ContentEditable from "react-contenteditable"; +import { forwardRef, TextareaHTMLAttributes } from "react"; import sanitizeHtml, { DisallowedTagsModes } from "sanitize-html"; +import { twMerge } from "tailwind-merge"; import { useToggle } from "@app/hooks"; @@ -22,7 +22,7 @@ const sanitizeConf = { const syntaxHighlight = (content?: string | null, isVisible?: boolean) => { if (content === "") return "EMPTY"; - if (!content) return "missing"; + if (!content) return "EMPTY"; if (!isVisible) return replaceContentWithDot(content); const sanitizedContent = sanitizeHtml( @@ -35,58 +35,61 @@ const syntaxHighlight = (content?: string | null, isVisible?: boolean) => { `${${b}}` ); - return newContent; + // akhilmhdh: Dont remove this br. I am still clueless how this works but weirdly enough + // when break is added a line break works properly + return `${newContent}
    `; }; -type Props = Omit, "onChange" | "onBlur"> & { +type Props = TextareaHTMLAttributes & { value?: string | null; isVisible?: boolean; + isReadOnly?: boolean; isDisabled?: boolean; - onChange?: (val: string) => void; - onBlur?: () => void; + containerClassName?: string; }; -export const SecretInput = forwardRef( - ({ value, isVisible, onChange, onBlur, isDisabled, ...props }, ref) => { +const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all"; + +export const SecretInput = forwardRef( + ( + { value, isVisible, containerClassName, onBlur, isDisabled, isReadOnly, onFocus, ...props }, + ref + ) => { const [isSecretFocused, setIsSecretFocused] = useToggle(); return (
    -
    - { - if (onChange) onChange(evt.currentTarget.innerText.trim()); - }} - onFocus={() => setIsSecretFocused.on()} - disabled={isDisabled} - spellCheck={false} - onBlur={() => { - if (onBlur) onBlur(); - setIsSecretFocused.off(); - }} - html={ - isVisible || isSecretFocused - ? sanitizeHtml( - value?.replaceAll("<", "<").replaceAll(">", ">") || "", - sanitizeConf - ) - : syntaxHighlight(value, false) - } - {...props} - /> +
    +
    +            
    +              
    +            
    +          
    +